Raw Source
Nuyoah / 合同信息页 - 费用备注框

// ==UserScript==
// @name         合同信息页 - 费用备注框
// @namespace    https://greasyfork.org/users/spotter9427
// @version      2.9.36
// @description  在帆软系统(sys.nxsafe.com)合同信息页的"项目信息"区域添加费用备注;并在合同列表页对合同编号悬停显示备注气泡,支持导出/导入手动同步
// @author       佐助
// @match        https://sys.nxsafe.com/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// @downloadURL https://update.greasyfork.org/scripts/592603/%E5%90%88%E5%90%8C%E4%BF%A1%E6%81%AF%E9%A1%B5%20-%20%E8%B4%B9%E7%94%A8%E5%A4%87%E6%B3%A8%E6%A1%86.user.js
// @updateURL https://update.greasyfork.org/scripts/592603/%E5%90%88%E5%90%8C%E4%BF%A1%E6%81%AF%E9%A1%B5%20-%20%E8%B4%B9%E7%94%A8%E5%A4%87%E6%B3%A8%E6%A1%86.meta.js
// ==/UserScript==

(function () {
    'use strict';

    // ─────────────────────────────────────────────────────────
    // 目标页白名单:脚本只在「合同详情 / 合同列表 / 报价提交 / 价格确认」
    // 等关键页面执行;其余页面(任务完成确认弹窗等)立即静默退出。
    // 判定策略(两道):
    //   1) 必须先处于帆软决策平台 (/webroot/decision/...) 域名下;
    //   2) 明确排除「从报表超链接弹出的填报窗口」(如「任务完成」确认页),
    //      其 URL 带 __pi__=true / fine_hyperlink= / __parameters__,通常是覆盖层弹窗,
    //      注入面板会盖住原生「完成」等按钮,必须跳过。
    // 决策平台下的其它页面(合同列表页、合同详情页、仪表板等)一律进入主体逻辑,
    // 由各 init 函数依据页面自身特征决定是否真正生效:
    //   - 列表页:扫描到 CONTRACT_RE 合同号单元格才绑定气泡/行底色;
    //   - 详情页:injectPanel 要求合同号过 CONTRACT_RE 校验,仪表板无合法合同号→不注入;
    //   - 提交/价格确认页:各自校验 URL 与页面标志。
    // 这样既不误杀合同列表页(其 URL 未必带报表实例参数),又能挡住弹窗误注入。
    // ─────────────────────────────────────────────────────────
    function decideTargetPage() {
        try {
            var href = String(location.href || '');
            var hash = String(location.hash || '');
            var u = (href + '|' + hash).toLowerCase();

            // 必须在决策平台域名下
            if (!/^https?:\/\/[^/]*sys\.nxsafe\.com\/webroot\/decision(\/|\?|#|$)/i.test(href)) {
                return null;
            }

            // 仅排除「弹窗填报 iframe」:FineReport 用 __pi__=true 标记从超链接弹出的填报窗口
            // (如「任务完成」确认页)。这类弹窗会盖住原生「完成」按钮,必须跳过。
            // 注意:普通「合同详情页」即便通过超链接打开、带 fine_hyperlink=/__parameters__ 参数,
            // 只要是整页导航(非 __pi__ 弹窗)就应当正常注入,故此处不再排除这些参数。
            if (/[?&#]__pi__=/.test(u)) {
                logSkip('弹窗填报(__pi__=true)');
                return null;
            }

            // 排除仪表板大屏(.fvs 文件,如「消费个人工作台.fvs」)。
            // 仪表板工作台里的 widget 不应注入费用面板/气泡,否则会干扰原生交互。
            // 合同列表页 / 合同详情页通常是 .cpt 报表,不受影响。
            if (/(\.fvs|%2Efvs)/i.test(decodeURIComponent(href))) {
                logSkip('仪表板大屏(.fvs)');
                return null;
            }

            // 其余决策平台页面均进入主体逻辑,由各自 init 函数依据页面特征决定是否生效
            return 'report';
        } catch (e) {
            return null;
        }
    }

    // 入口跳过时的原因日志(便于排查某页为何未注入)
    function logSkip(reason) {
        console.log('[费用备注] 跳过非目标页:' + reason + ' @ ' + location.href);
    }

    // 判断当前文档是否处于弹窗/对话框上下文(如「任务完成」确认弹窗)。
    // 作为 __pi__=true 之外的兜底,防止弹窗被误注入面板盖住原生按钮。
    function isModalContext() {
        if (document.querySelector('dialog, .fr-core-dialog, .fr-dialog, .fr-window, .el-dialog, .el-dialog__wrapper, .ant-modal, .ant-modal-wrap, .ant-modal-mask, .fancybox-overlay, .modal, [role="dialog"], .ui-dialog')) {
            return true;
        }
        var bodyClosest = document.body.closest && document.body.closest('.fr-core-dialog, .fr-dialog, [role="dialog"], .el-dialog, .ant-modal');
        if (bodyClosest) return true;
        return false;
    }

    var targetKind = decideTargetPage();
    if (!targetKind) {
        // 原因已由 logSkip 说明(仅当确实命中排除规则时才会打印),
        // 不挂 MutationObserver、不跑任何 init,避免干扰仪表板 / 任务完成弹窗等无关页面。
        return;
    }

    console.log('[费用备注] 脚本已加载 v2.9.36 @ ' + location.href + '(' + targetKind + ')');

    var STORAGE_PREFIX = 'contract_remarks:';
    var AUTO_PREFIX = 'auto:';                 // 报价确认页自动抓取的暂存:auto:<报价单号>
    var QUOTE_MAP_PREFIX = 'quote_map:';        // 报价单号 -> 合同号
    var CONTRACT_QUOTE_PREFIX = 'contract_quote:'; // 合同号 -> 报价单号
    var QUOTE_RE = /^[A-Z]{2,5}-\d{8,}$/;       // 报价单号格式(如 ZWBJ-20260813003)
    var PENDING_QUOTE_KEY = 'pending_quote';  // 报价提交页 → 价格确认页 的编号接力
    var PENDING_QUOTE_TTL = 30 * 60 * 1000;   // 30 分钟内有效
    var PANEL_ID = 'tm-contract-remarks-panel';
    var MAX_ROWS = 5;
    var retryCount = 0;
    var MAX_RETRY = 60; // 60 * 800ms ≈ 48 秒,覆盖帆软报表慢加载场景
    var observer = null;

    // 每行的三个字段
    var FIELDS = [
        { field: 'projectName', label: '项目名称', width: 190 },
        { field: 'projectAmount', label: '项目金额', width: 110, numeric: true },
        { field: 'remark', label: '备注', width: 170, grow: true, maxWidth: 600 }
    ];

    // 统一字体族(现代风格)
    var FONT_FAMILY = "'PingFang SC','Microsoft YaHei','Helvetica Neue',Arial,sans-serif";

    // ---------- 工具函数 ----------

    function getContractNoFromUrl() {
        // 详情页 URL 形如 ...&CONTRACT_ID=HJ202608079&...,这是帆软传入的合同主键参数,
        // 比页面文本解析更稳定可靠(部分详情页的「合同编号」标签/结构特殊,DOM 提取会落空)。
        // 仅读取 query/hash 中的 CONTRACT_ID;不再读取 SPA 页签名 activeTab 等,避免旧 bug 复发。
        // 取值仍须经 CONTRACT_RE 校验(injectPanel 内再次校验),确保不会把无关参数误当合同号。
        try {
            var src = (location.search || '') + '&' + (location.hash || '');
            var m = src.match(/[?&]CONTRACT_ID=([A-Za-z0-9\-_]+)/i);
            if (m) {
                var v = decodeURIComponent(m[1]);
                if (CONTRACT_RE.test(v)) return v;
            }
        } catch (e) { /* 忽略 */ }
        return null;
    }

    function getContractNoFromPage() {
        var candidates = document.querySelectorAll('td, th, span, div, label, .el-form-item__label');
        for (var i = 0; i < candidates.length; i++) {
            var el = candidates[i];
            if (el.childElementCount > 2) continue;
            var text = (el.textContent || '').replace(/\s/g, '');
            if (text === '合同编号' || text === '合同编号:' || text === '合同编号:' ||
                text === '合同号' || text === '合同号:' || text === '合同号:') {
                var scope = el.closest('tr') || el.parentElement.parentElement || el.parentElement;
                if (scope) {
                    var input = scope.querySelector('input');
                    if (input && input.value && input.value.trim()) return input.value.trim();
                    var sib = el.nextElementSibling;
                    if (sib) {
                        var v = (sib.value || sib.textContent || '').trim();
                        if (v) return v;
                    }
                }
            }
        }
        var m = (document.body.innerText || '').match(/合同编号[::\s]*([A-Za-z0-9\-_]{6,})/);
        return m ? m[1] : null;
    }

    function getContractNo() {
        return getContractNoFromPage() || getContractNoFromUrl();
    }

    // 从页面提取「报价单编号」(形如 ZWBJ-20260813003)
    function getQuoteNoFromPage() {
        var candidates = document.querySelectorAll('td, th, span, div, label');
        for (var i = 0; i < candidates.length; i++) {
            var el = candidates[i];
            if (el.childElementCount > 2) continue;
            var text = (el.textContent || '').replace(/\s/g, '');
            if (text === '报价单编号' || text === '报价单编号:' || text === '报价单编号:') {
                var scope = el.closest('tr') || el.parentElement.parentElement || el.parentElement;
                if (scope) {
                    var input = scope.querySelector('input');
                    if (input && input.value && input.value.trim()) return input.value.trim();
                    var sib = el.nextElementSibling;
                    if (sib) {
                        var v = (sib.value || sib.textContent || '').trim();
                        if (v) return v;
                    }
                }
            }
        }
        var m = (document.body.innerText || '').match(/报价单编号[::\s]*([A-Z]{2,5}-\d{8,})/);
        return m ? m[1] : null;
    }

    // 数据规范化:兼容旧版格式,统一为 { locked, rows:[{projectName, projectAmount, remark}] }
    function normalizeData(raw) {
        var data = { locked: false, rows: [], totalAmount: null };
        if (raw && typeof raw === 'object') {
            if (Array.isArray(raw.rows)) {
                data.rows = raw.rows;
            } else if (raw.projectName || raw.projectAmount) {
                data.rows = [{
                    projectName: raw.projectName || '',
                    projectAmount: raw.projectAmount || '',
                    remark: ''
                }];
            }
            data.locked = !!raw.locked;
            if (raw.totalAmount != null) data.totalAmount = raw.totalAmount;
        }
        data.rows = data.rows
            .map(function (r) {
                return {
                    projectName: (r && r.projectName) || '',
                    projectAmount: (r && r.projectAmount) || '',
                    remark: (r && r.remark) || ''
                };
            })
            .filter(function (r) {
                return r.projectName.trim() || r.projectAmount.trim() || r.remark.trim();
            });
        if (data.rows.length === 0) {
            data.rows = [{ projectName: '', projectAmount: '', remark: '' }];
        }
        return data;
    }

    function loadData(contractNo) {
        try {
            var raw = localStorage.getItem(STORAGE_PREFIX + contractNo);
            var data = normalizeData(raw ? JSON.parse(raw) : null);
            // 若合同本身没有备注,尝试通过报价单号映射拉取价格确认页抓取的数据
            if (!hasRemarks(data)) {
                var q = null;
                try { q = localStorage.getItem(CONTRACT_QUOTE_PREFIX + contractNo); } catch (e) { /* 忽略 */ }
                if (q) {
                    var autoRaw = null;
                    try { autoRaw = localStorage.getItem(AUTO_PREFIX + q); } catch (e) { /* 忽略 */ }
                    if (autoRaw) {
                        var autoData = normalizeData(JSON.parse(autoRaw));
                        if (hasRemarks(autoData)) return autoData;
                    }
                }
            }
            return data;
        } catch (e) {
            return normalizeData(null);
        }
    }

    function saveData(contractNo, data) {
        try {
            localStorage.setItem(STORAGE_PREFIX + contractNo, JSON.stringify(data));
        } catch (e) {
            console.warn('[费用备注] 保存失败', e);
        }
    }

    // ---------- 导出 / 导入(手动同步) ----------

    function exportAll() {
        var out = {};
        var count = 0;
        for (var i = 0; i < localStorage.length; i++) {
            var key = localStorage.key(i);
            if (key && key.indexOf(STORAGE_PREFIX) === 0) {
                var no = key.slice(STORAGE_PREFIX.length);
                try { out[no] = JSON.parse(localStorage.getItem(key)); count++; } catch (e) { /* 忽略损坏项 */ }
            }
        }
        var stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
        var blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
        var url = URL.createObjectURL(blob);
        var a = document.createElement('a');
        a.href = url;
        a.download = '合同备注备份_' + stamp + '.json';
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
        return count;
    }

    function makeFileInput(onParsed) {
        var fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = '.json,application/json';
        fileInput.style.display = 'none';
        fileInput.addEventListener('change', function () {
            var f = fileInput.files && fileInput.files[0];
            if (!f) return;
            var reader = new FileReader();
            reader.onload = function () {
                try {
                    var data = JSON.parse(reader.result);
                    var keys = Object.keys(data);
                    keys.forEach(function (k) {
                        localStorage.setItem(STORAGE_PREFIX + k, JSON.stringify(data[k]));
                    });
                    onParsed(keys.length);
                } catch (e) {
                    onParsed(-1);
                }
            };
            reader.readAsText(f, 'utf-8');
            fileInput.value = '';
        });
        return fileInput;
    }

    // 解析金额
    function parseAmount(text) {
        var n = parseFloat(String(text || '').replace(/[^0-9.\-]/g, ''));
        return isNaN(n) ? 0 : n;
    }

    function formatAmount(n) {
        var r = Math.round(n * 100) / 100;
        return String(r);
    }

    // 合计金额与合同额(总价)的颜色规则:
    //   - 一致(数值接近) → 同色(深红 COLOR_MATCH),视觉无警示
    //   - 不一致 → 合同额转琥珀 COLOR_MISMATCH,明显区分,提示两者对不上
    var COLOR_MATCH = '#dc2626';     // 深红(合计/合同额一致时)
    var COLOR_MISMATCH = '#d97706';  // 琥珀(不一致时,明显区分)
    function amountsMatch(sum, total) {
        sum = parseAmount(sum);
        total = parseAmount(total);
        return total > 0 && Math.abs(sum - total) < 0.005;
    }

    // ---------- UI 构建 ----------

    var BTN_BASE =
        'padding:6px 18px;border:none;border-radius:6px;font-size:14px;font-weight:600;' +
        'cursor:pointer;color:#fff;white-space:nowrap;font-family:' + FONT_FAMILY + ';' +
        'box-shadow:0 1px 3px rgba(0,0,0,0.12);transition:filter .15s;';

    function makeButton(text, bgColor) {
        var btn = document.createElement('button');
        btn.type = 'button';
        btn.textContent = text;
        btn.style.cssText = BTN_BASE + 'background:' + bgColor + ';';
        return btn;
    }

    function makeInput(field, value, width, numeric, grow, maxWidth) {
        var input = document.createElement('input');
        input.type = 'text';
        input.dataset.field = field;
        input.value = value || '';
        input.placeholder = '填写后点击确定保存';
        input.style.cssText =
            'width:' + width + 'px;padding:7px 10px;border:1px solid #d1d5db;' +
            'border-radius:6px;outline:none;font-size:15px;color:#111827;' +
            'background:#fff;box-sizing:border-box;font-family:' + FONT_FAMILY + ';' +
            'transition:border-color .15s, box-shadow .15s;';
        if (numeric) {
            input.inputMode = 'decimal';
            input.addEventListener('input', function () {
                var v = input.value.replace(/[^0-9.]/g, '');
                var parts = v.split('.');
                if (parts.length > 2) v = parts[0] + '.' + parts.slice(1).join('');
                if (v !== input.value) input.value = v;
            });
        }
        if (grow) {
            var adjust = function () {
                input.style.width = width + 'px';
                var w = input.scrollWidth + 16;
                if (w < width) w = width;
                if (w > maxWidth) w = maxWidth;
                input.style.width = w + 'px';
            };
            input.addEventListener('input', adjust);
            setTimeout(adjust, 0);
        }
        input.addEventListener('focus', function () {
            if (!input.disabled) {
                input.style.borderColor = '#f59e0b';
                input.style.boxShadow = '0 0 0 3px rgba(245,158,11,0.18)';
            }
        });
        input.addEventListener('blur', function () {
            input.style.borderColor = '#d1d5db';
            input.style.boxShadow = 'none';
        });
        return input;
    }

    function buildPanel(contractNo) {
        var data = loadData(contractNo);

        var panel = document.createElement('div');
        panel.id = PANEL_ID;
        panel.style.cssText =
            'box-sizing:border-box;clear:both;' +
            'margin:10px 0;padding:14px 18px;border:1px solid #f0c36d;' +
            'border-radius:10px;background:linear-gradient(180deg,#fffdf5 0%,#fff7e3 100%);' +
            'box-shadow:0 2px 10px rgba(230,162,60,0.16);' +
            'display:flex;flex-direction:column;gap:12px;font-size:15px;' +
            'position:relative;z-index:9999;line-height:1.5;font-family:' + FONT_FAMILY + ';';

        // 标题行:与下方项目行采用相同的「86px 右对齐 label + 8px gap + 输入框/占位」三段结构,
        // 段间 16px gap,使各冒号与下方「项目名称1 / 项目金额1 / 备注1」一一对齐。
        //   seg1:合同:(86px 右对齐)+ 合同号占位(190px)  → 对齐下方「项目名称1:input」
        //   seg2:项目合计:(86px 右对齐)+ 金额值          → 对齐下方「项目金额1:input」
        //   seg3:合同金额:(86px 右对齐)+ input + 提示     → 对齐下方「备注1:input」
        var TITLE_TXT = 'font-weight:700;font-size:16px;color:#dc2626;white-space:nowrap;letter-spacing:0.5px;';
        var TITLE_LAB = TITLE_TXT + 'display:inline-block;width:86px;text-align:right;flex-shrink:0;';

        var titleRow = document.createElement('div');
        titleRow.style.cssText = 'display:flex;align-items:center;gap:16px;flex-wrap:wrap;';

        // 段 1:合同:AQ202608003(冒号对齐下方「项目名称1:」冒号)
        var seg1 = document.createElement('div');
        seg1.style.cssText = 'display:flex;align-items:center;gap:8px;width:284px;flex-shrink:0;';
        var titleLab = document.createElement('span');
        titleLab.textContent = '合同:';
        titleLab.style.cssText = TITLE_LAB;
        var titleNo = document.createElement('span');
        titleNo.textContent = contractNo;
        titleNo.style.cssText = TITLE_TXT + 'width:190px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;';
        seg1.appendChild(titleLab);
        seg1.appendChild(titleNo);
        titleRow.appendChild(seg1);

        // 段 2:项目合计:+ 金额值(对齐下方「项目金额1:input」)
        var seg2 = document.createElement('div');
        seg2.style.cssText = 'display:flex;align-items:center;gap:8px;width:204px;flex-shrink:0;';
        var sumLabel = document.createElement('span');
        sumLabel.textContent = '项目合计:';
        sumLabel.style.cssText = TITLE_LAB;
        var sumSpan = document.createElement('span');
        sumSpan.style.cssText = TITLE_TXT + 'min-width:80px;';
        seg2.appendChild(sumLabel);
        seg2.appendChild(sumSpan);
        titleRow.appendChild(seg2);

        // 段 3:合同金额:+ input + 不一致提示(对齐下方「备注1:input」)
        var seg3 = document.createElement('div');
        seg3.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;';
        var ctLabel = document.createElement('span');
        ctLabel.textContent = '合同金额:';
        ctLabel.style.cssText = TITLE_LAB;
        var ctInput = makeInput('contractTotal',
            parseAmount(data.totalAmount) > 0 ? formatAmount(data.totalAmount) : '', 160, true);
        ctInput.placeholder = '老合同/未关联时可手动录入';
        ctInput.style.fontSize = '16px';
        ctInput.style.fontWeight = '700';
        ctInput.style.color = '#dc2626';
        // 不一致时的灰色提示(仅金额不等时出现)
        var ctWarn = document.createElement('span');
        ctWarn.textContent = '注意:两个金额不一致!!';
        ctWarn.style.cssText = 'font-size:16px;font-weight:700;color:#9ca3af;display:none;white-space:nowrap;';
        // 锁定后用于「框消失、数字保留」的只读文本节点(编辑态隐藏,锁定态显示)
        var ctText = document.createElement('span');
        ctText.style.cssText = TITLE_TXT + 'display:none;min-width:80px;';
        // 仅在输入时刷新数字颜色与提示;边框保持默认不变
        ctInput.addEventListener('input', updateSum);
        seg3.appendChild(ctLabel);
        seg3.appendChild(ctInput);
        seg3.appendChild(ctText);
        seg3.appendChild(ctWarn);
        titleRow.appendChild(seg3);

        panel.appendChild(titleRow);

        // 行容器
        var rowsBox = document.createElement('div');
        rowsBox.style.cssText = 'display:flex;flex-direction:column;gap:8px;';
        panel.appendChild(rowsBox);

        // 底部按钮区
        var btnBox = document.createElement('div');
        btnBox.style.cssText = 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;';
        panel.appendChild(btnBox);

        var addBtn = makeButton('+ 添加一行', '#909399');
        var confirmBtn = makeButton('确 定', '#67c23a');
        var editBtn = makeButton('修 改', '#409eff');
        var exportBtn = makeButton('导出', '#409eff');
        var importBtn = makeButton('导入', '#e6a23c');
        var tip = document.createElement('span');
        tip.style.cssText = 'color:#67c23a;font-size:12px;opacity:0;transition:opacity .3s;';
        tip.textContent = '已保存并锁定';

        btnBox.appendChild(addBtn);
        btnBox.appendChild(confirmBtn);
        btnBox.appendChild(editBtn);
        btnBox.appendChild(exportBtn);
        btnBox.appendChild(importBtn);
        btnBox.appendChild(tip);

        function flash(msg, color) {
            tip.textContent = msg;
            tip.style.color = color || '#67c23a';
            tip.style.opacity = '1';
            setTimeout(function () { tip.style.opacity = '0'; }, 2000);
        }

        function currentRowCount() {
            return rowsBox.querySelectorAll('[data-row]').length;
        }

        function updateSum() {
            var sum = 0;
            rowsBox.querySelectorAll('input[data-field="projectAmount"]').forEach(function (inp) {
                sum += parseAmount(inp.value);
            });
            sumSpan.textContent = formatAmount(sum);

            // 颜色规则:
            //  - 项目合计数字:始终深红(首行红色)
            //  - 合同金额框:未录入→灰;合同金额==项目合计→深红;不等→琥珀(与列表逻辑一致 COLOR_MISMATCH)
            //  - 仅当合同金额≠项目合计(且已录入)时显示灰色提示
            var total = parseAmount(ctInput.value);
            sumSpan.style.color = COLOR_MATCH;
            // 合同金额:未录入→灰;合同金额==项目合计→深红;不等→琥珀(与列表逻辑一致)
            // 数字颜色同步应用到「输入框」与「锁定后显示的只读文本」
            var ctColor;
            if (total <= 0) {
                ctColor = '#9ca3af';
                ctWarn.style.display = 'none';
            } else if (amountsMatch(sum, total)) {
                ctColor = COLOR_MATCH;
                ctWarn.style.display = 'none';
            } else {
                ctColor = COLOR_MISMATCH;
                ctWarn.style.display = '';
            }
            ctInput.style.color = ctColor;
            ctText.style.color = ctColor;
            ctText.textContent = total > 0 ? formatAmount(total) : '';
        }

        function addRow(rowData) {
            var idx = currentRowCount() + 1;

            var row = document.createElement('div');
            row.dataset.row = String(idx);
            row.style.cssText = 'display:flex;align-items:center;gap:16px;flex-wrap:wrap;';

            FIELDS.forEach(function (f) {
                var wrap = document.createElement('label');
                wrap.style.cssText = 'display:flex;align-items:center;gap:8px;color:#374151;white-space:nowrap;';

                var lab = document.createElement('span');
                lab.textContent = f.label + idx + ':';
                lab.style.cssText = 'display:inline-block;width:86px;text-align:right;white-space:nowrap;font-weight:600;font-size:14px;';

                var input = makeInput(f.field, rowData ? rowData[f.field] : '', f.width, f.numeric, f.grow, f.maxWidth);
                if (f.field === 'projectAmount') {
                    input.addEventListener('input', updateSum);
                }
                // 锁定后用于「框消失、值保留」的只读文本节点(编辑态隐藏,锁定态显示)
                var roText = document.createElement('span');
                roText.dataset.roField = f.field;
                var roStyle = 'display:none;font-size:15px;color:#111827;font-family:' + FONT_FAMILY + ';' +
                    'min-width:' + f.width + 'px;';
                if (f.grow) {
                    roStyle += 'word-break:break-all;';
                } else {
                    roStyle += 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
                }
                roText.style.cssText = roStyle;
                wrap.appendChild(lab);
                wrap.appendChild(input);
                wrap.appendChild(roText);
                row.appendChild(wrap);
            });

            rowsBox.appendChild(row);
        }

        // 清空并重新渲染当前合同的数据
        function refresh() {
            rowsBox.querySelectorAll('[data-row]').forEach(function (r) { r.remove(); });
            data = loadData(contractNo);
            ctInput.value = parseAmount(data.totalAmount) > 0 ? formatAmount(data.totalAmount) : '';
            data.rows.forEach(function (r) { addRow(r); });
            applyLock(data.locked);
            updateSum();
        }

        function collectAndSave(locked) {
            var rows = [];
            rowsBox.querySelectorAll('[data-row]').forEach(function (row) {
                var r = { projectName: '', projectAmount: '', remark: '' };
                row.querySelectorAll('input[data-field]').forEach(function (inp) {
                    r[inp.dataset.field] = inp.value;
                });
                if (r.projectName.trim() || r.projectAmount.trim() || r.remark.trim() || rows.length === 0) {
                    rows.push(r);
                }
            });
            // 合同额以「合同总额输入框」为准:手动录入或自动抓取统一来源;≤0 则清除
            var ctVal = parseAmount(ctInput.value);
            saveData(contractNo, {
                locked: locked,
                rows: rows,
                totalAmount: ctVal > 0 ? ctVal : undefined
            });
        }

        function applyLock(locked) {
            // 各行输入框/只读文本:编辑态显示输入框;锁定态隐藏输入框、改用只读文字显示(框消失、值保留)
            rowsBox.querySelectorAll('input[data-field]').forEach(function (inp) {
                var ro = inp.parentElement && inp.parentElement.querySelector(
                    'span[data-ro-field="' + inp.dataset.field + '"]');
                if (!ro) return;
                if (locked) {
                    ro.textContent = inp.value;
                    inp.style.display = 'none';
                    ro.style.display = '';
                } else {
                    inp.style.display = '';
                    ro.style.display = 'none';
                    inp.disabled = false;
                    inp.style.background = '#fff';
                    inp.style.cursor = 'text';
                }
            });
            // 合同金额:编辑态显示输入框(可改);锁定态隐藏输入框、改用只读数字显示(框消失、数字保留)
            if (locked) {
                ctInput.style.display = 'none';
                ctText.style.display = '';
            } else {
                ctInput.style.display = '';
                ctText.style.display = 'none';
                ctInput.disabled = false;
                ctInput.style.background = '#fff';
                ctInput.style.cursor = 'text';
            }
            confirmBtn.disabled = locked;
            confirmBtn.style.opacity = locked ? '0.4' : '1';
            confirmBtn.style.cursor = locked ? 'not-allowed' : 'pointer';
            editBtn.disabled = !locked;
            editBtn.style.opacity = !locked ? '0.4' : '1';
            editBtn.style.cursor = !locked ? 'not-allowed' : 'pointer';
            addBtn.style.display = (!locked && currentRowCount() < MAX_ROWS) ? '' : 'none';
            updateSum();
        }

        confirmBtn.addEventListener('click', function () {
            collectAndSave(true);
            applyLock(true);
            updateSum();
            flash('已保存并锁定');
        });

        editBtn.addEventListener('click', function () {
            collectAndSave(false);
            applyLock(false);
            flash('已解锁,可编辑', '#409eff');
        });

        addBtn.addEventListener('click', function () {
            if (currentRowCount() >= MAX_ROWS) return;
            addRow(null);
            applyLock(false);
            updateSum();
        });

        exportBtn.addEventListener('click', function () {
            var n = exportAll();
            flash('已导出 ' + n + ' 个合同的备注', '#409eff');
        });

        importBtn.addEventListener('click', function () {
            var fileInput = makeFileInput(function (n) {
                if (n < 0) {
                    flash('导入失败:文件格式不正确', '#f56c6c');
                } else {
                    refresh();
                    flash('已导入 ' + n + ' 个合同的备注', '#e6a23c');
                }
            });
            document.body.appendChild(fileInput);
            fileInput.click();
            fileInput.remove();
        });

        // 初始化
        data.rows.forEach(function (r) { addRow(r); });
        applyLock(data.locked);
        updateSum();

        return panel;
    }

    // ---------- 定位 ----------

    function findProjectSectionHeader() {
        var nodes = document.querySelectorAll('h1,h2,h3,h4,h5,div,span,strong,b,td,th,legend');
        for (var i = 0; i < nodes.length; i++) {
            var el = nodes[i];
            if (el.childElementCount > 2) continue;
            var text = (el.textContent || '').replace(/\s/g, '');
            if (text === '项目信息') return el;
        }
        return null;
    }

    function findProjectTable() {
        var tables = document.querySelectorAll('table');
        for (var i = 0; i < tables.length; i++) {
            var head = (tables[i].textContent || '').replace(/\s/g, '').slice(0, 300);
            if (head.indexOf('项目编号') > -1 && head.indexOf('项目名称') > -1) {
                return tables[i];
            }
        }
        return null;
    }

    function climbToWideContainer(el) {
        var anchor = el;
        while (
            anchor.parentElement &&
            anchor.parentElement !== document.body &&
            anchor.parentElement.offsetWidth > 0 &&
            anchor.parentElement.offsetWidth < 500
        ) {
            anchor = anchor.parentElement;
        }
        return anchor;
    }

    // ---------- 列表页:合同编号悬停气泡 ----------

    var CONTRACT_RE = /^[A-Z]{2,3}\d{6,}$/i;
    var TOOLTIP_ID = 'tm-remarks-tooltip';

    function escHtml(s) {
        return String(s || '').replace(/[&<>]/g, function (c) {
            return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c];
        });
    }

    function hasRemarks(data) {
        if (!data || !data.rows) return false;
        return data.rows.some(function (r) {
            return (r.projectName || '').trim() || (r.projectAmount || '').trim() || (r.remark || '').trim();
        });
    }

    // 项目名称颜色映射:按类别首次出现顺序稳定分配 6 种色相差异明显的颜色
    // 采用「从右往左」排列(紫/蓝/青/绿/黄),并把原红位替换为黄绿 #65a30d;
    // 6 色均落在与合计深红(#dc2626)差异明显的区间(避开红/橙/品红色相),确保「合」前最后一个项目不会与合计色相近;
    // 业务项目类别通常 ≤6 个,前 6 类各占一色,超出则循环复用。
    var NAME_COLOR_PALETTE = ['#7c3aed', '#2563eb', '#0891b2', '#16a34a', '#ca8a04', '#65a30d'];
    var _nameColorSeq = 0;
    var _nameColorMap = {};
    function colorForName(name) {
        var s = String(name || '').trim();
        if (!s) return '#374151';
        if (!(s in _nameColorMap)) {
            _nameColorMap[s] = NAME_COLOR_PALETTE[_nameColorSeq % NAME_COLOR_PALETTE.length];
            _nameColorSeq++;
        }
        return _nameColorMap[s];
    }

    // 生成单行精简摘要:每行「名称+金额」用 | 分隔,最后“合+总额”醒目加粗
    function buildInlineSummary(data) {
        var rows = data.rows || [];
        var sum = rows.reduce(function (a, r) { return a + parseAmount(r.projectAmount); }, 0);
        var parts = rows.map(function (r, i) {
            var name = (r.projectName || '').trim() || ('项目' + (i + 1));
            var amt = (r.projectAmount || '').trim();
            // 同一行的名称+金额统一为该行颜色(内联摘要不含备注列)
            return '<span style="color:' + colorForName(name) + ';">' + escHtml(name) + (amt ? escHtml(amt) : '') + '</span>';
        });
        var html = parts.join('<span style="color:#9ca3af;"> | </span>');
        if (sum > 0) {
            html += '<span style="color:#9ca3af;">=</span><span style="color:#dc2626;font-weight:700;">合' + formatAmount(sum) + '</span>';
            var total0 = parseAmount(data.totalAmount);
            if (total0 > 0) {
                var ic = amountsMatch(sum, total0) ? '#dc2626' : '#d97706';
                html += ' <span style="color:#9ca3af;">/</span><span style="color:' + ic + ';font-weight:700;">合同额' + formatAmount(total0) + '</span>';
            }
        }
        if (data.locked) html += ' 🔒';
        return html;
    }

    // 单行摘要:追加到合同编号所在单元格内(左侧,紧跟在编号后)
    function injectInlineBadge(data, el, anchor) {
        if (anchor.querySelector('.tm-remarks-summary')) return;
        var badge = document.createElement('span');
        badge.className = 'tm-remarks-summary';
        badge.style.cssText = 'display:inline-block;margin-left:6px;font-size:12px;' +
            'color:#b45309;font-weight:600;white-space:nowrap;vertical-align:middle;';
        badge.innerHTML = buildInlineSummary(data);
        el.appendChild(badge);
    }

    function renderTooltipHtml(contractNo, data) {
        var rows = data.rows || [];
        if (!hasRemarks(data)) {
            return '<div style="color:#909399;">暂无备注</div>';
        }
        var sum = rows.reduce(function (a, r) { return a + parseAmount(r.projectAmount); }, 0);
        // 统一基础样式:单元格内边距 + 行底分割线 + 文字左对齐 + 统一字号 13px / 字体继承
        var cellBase = 'padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:left;font-size:13px;';
        var headBase = 'padding:3px 8px;border-bottom:1px solid #d1d5db;text-align:left;font-size:13px;font-weight:600;color:#6b7280;';
        var html = '<table style="border-collapse:collapse;font-family:inherit;font-size:13px;width:100%;">';
        // 标题行:# / 项目 / 金额 / 备注(简洁,去掉「名称」「项目」前缀)
        html += '<thead><tr>' +
            '<th style="' + headBase + 'text-align:right;">#</th>' +
            '<th style="' + headBase + '">项目</th>' +
            '<th style="' + headBase + '">金额</th>' +
            '<th style="' + headBase + '">备注</th>' +
            '</tr></thead><tbody>';
        // 项目行:金额列靠左,三个数据列均用行色,字号/格式与其他行统一,不再加粗金额
        rows.forEach(function (r, i) {
            var rowColor = colorForName(r.projectName);
            html += '<tr>' +
                '<td style="' + cellBase + 'color:#9ca3af;text-align:right;">' + (i + 1) + '</td>' +
                '<td style="' + cellBase + 'color:' + rowColor + ';">' + escHtml(r.projectName) + '</td>' +
                '<td style="' + cellBase + 'color:' + rowColor + ';">' + escHtml(r.projectAmount) + '</td>' +
                '<td style="' + cellBase + 'color:' + rowColor + ';">' + escHtml(r.remark) + '</td>' +
                '</tr>';
        });
        // 合计行:4 个 td 都加 border-top,确保红色分隔线连续贯穿整行;
        // border-collapse:collapse 下,只有每个单元格都设 border-top,整条线才会完整渲染。
        // 同行展示「合计」与「合同额」:一致→同色(深红),不一致→合同额转琥珀色警示。
        var total = parseAmount(data.totalAmount);
        var totalMatched = total > 0 && amountsMatch(sum, total);
        var contractColor = totalMatched ? COLOR_MATCH : COLOR_MISMATCH;
        html += '<tr style="background:#fff1f2;">' +
            '<td colspan="2" style="padding:6px 8px;border-top:2px solid #fca5a5;border-bottom:none;text-align:left;color:' + COLOR_MATCH + ';font-weight:700;font-size:13px;">合计:' + formatAmount(sum) + '</td>' +
            (total > 0
                ? '<td colspan="2" style="padding:6px 8px;border-top:2px solid #fca5a5;border-bottom:none;text-align:left;color:' + contractColor + ';font-weight:700;font-size:13px;">合同额:' + formatAmount(total) + '</td>'
                : '<td colspan="2" style="padding:6px 8px;border-top:2px solid #fca5a5;border-bottom:none;"></td>') +
            '</tr>';
        html += '</tbody></table>';
        return html;
    }

    function getTooltipEl() {
        var el = document.getElementById(TOOLTIP_ID);
        if (el) return el;
        el = document.createElement('div');
        el.id = TOOLTIP_ID;
        el.style.cssText = 'position:fixed;z-index:99999;background:#fff;' +
            'border:1px solid #f0c36d;border-radius:8px;padding:10px 14px;' +
            'box-shadow:0 6px 20px rgba(0,0,0,0.18);font-family:' + FONT_FAMILY + ';' +
            'font-size:13px;line-height:1.55;display:none;pointer-events:none;';
        document.body.appendChild(el);
        return el;
    }

    function showTooltip(anchorCell, contractNo) {
        var data = loadData(contractNo);
        var tip = getTooltipEl();
        tip.innerHTML = renderTooltipHtml(contractNo, data);
        // 渲染后才能拿到尺寸
        var rect = anchorCell.getBoundingClientRect();
        var top = rect.bottom + 6;
        var left = rect.left;
        tip.style.top = top + 'px';
        tip.style.left = left + 'px';
        tip.style.display = 'block';
        var w = tip.offsetWidth;
        var h = tip.offsetHeight;
        if (left + w > window.innerWidth - 8) tip.style.left = (window.innerWidth - w - 8) + 'px';
        if (top + h > window.innerHeight - 8) tip.style.top = (rect.top - h - 6) + 'px';
    }

    function hideTooltip() {
        var el = document.getElementById(TOOLTIP_ID);
        if (el) el.style.display = 'none';
    }

    // 列宽调整:加宽合同编号列容纳备注,压缩内容较短的列
    var WIDTH_OVERRIDES = {
        '合同编号': 400,   // 加宽(原 207)
        '客户类型': 80,    // 原 130
        '签订日期': 96,    // 原 130
        '合同金额': 100,   // 原 130
        '开票金额': 100,   // 原 130
        '回款金额': 100,   // 原 129
        '未完成项目': 110  // 原 130
    };

    function adjustColumnWidths() {
        var tables = document.querySelectorAll('table.x-table');
        if (!tables.length) return;

        // 建立 表头文本 -> 列索引(0-based) 映射(由含表头的表提取)
        var headerMap = {};
        for (var ti = 0; ti < tables.length; ti++) {
            var tds = tables[ti].querySelectorAll('td[id]');
            for (var ci = 0; ci < tds.length; ci++) {
                var mm = (tds[ci].id || '').match(/^([A-Z])\d+-/);
                if (!mm) continue;
                var txt = (tds[ci].textContent || '').replace(/\s/g, '');
                if (txt && WIDTH_OVERRIDES.hasOwnProperty(txt) && headerMap[txt] == null) {
                    headerMap[txt] = mm[1].charCodeAt(0) - 65; // A=0
                }
            }
        }
        var keys = Object.keys(headerMap);
        if (!keys.length) return;

        // 应用到所有 x-table(含冻结表头/冻结列,保持对齐)
        for (var tj = 0; tj < tables.length; tj++) {
            var cols = tables[tj].querySelectorAll('colgroup col');
            for (var cj = 0; cj < cols.length; cj++) {
                var idx = parseInt(cols[cj].getAttribute('col'), 10);
                if (isNaN(idx)) continue;
                for (var k = 0; k < keys.length; k++) {
                    if (headerMap[keys[k]] === idx) {
                        cols[cj].style.width = WIDTH_OVERRIDES[keys[k]] + 'px';
                    }
                }
            }
        }
        console.log('[费用备注] 已调整列宽');
    }

    // 行悬停高亮:鼠标移入整行亮黄高亮+文字加粗,移出恢复(不破坏点击/选中等原有交互)
    // 背景使用亮黄 (#fde047),相比原极淡浅蓝更显眼
    var ROW_HOVER_COLOR = '#fde047';

    function applyRowHoverHighlight() {
        var tables = document.querySelectorAll('table.x-table');
        for (var t = 0; t < tables.length; t++) {
            var rows = tables[t].querySelectorAll('tbody tr');
            for (var r = 0; r < rows.length; r++) {
                var tr = rows[r];
                if (tr.dataset.tmHoverBound) continue;
                // 跳过表头行(tridx=0)
                if ((tr.getAttribute('tridx') || '0') === '0') continue;
                tr.dataset.tmHoverBound = '1';

                tr.addEventListener('mouseenter', function (ev) {
                    var self = ev.currentTarget;
                    var cells = self.querySelectorAll('td');
                    for (var i = 0; i < cells.length; i++) {
                        var c = cells[i];
                        if (c.dataset.tmOrigBg === undefined) {
                            c.dataset.tmOrigBg = c.style.backgroundColor || '';
                            c.dataset.tmOrigFw = c.style.fontWeight || '';
                        }
                        c.style.backgroundColor = ROW_HOVER_COLOR;
                        c.style.fontWeight = '700';
                    }
                });
                tr.addEventListener('mouseleave', function (ev) {
                    var self = ev.currentTarget;
                    var cells = self.querySelectorAll('td');
                    for (var i = 0; i < cells.length; i++) {
                        var c = cells[i];
                        c.style.backgroundColor = c.dataset.tmOrigBg || '';
                        c.style.fontWeight = c.dataset.tmOrigFw || '';
                    }
                });
            }
        }
    }

    // 列表页初始化:扫描合同编号文本节点,按编号去重、选最宽表格绑定,插入完整摘要行
    function initListPage() {
        // 详情页已注入面板时跳过
        if (document.getElementById(PANEL_ID)) return true;
        // 详情页特征(项目信息表/标题)存在时跳过,避免把项目编号误判为合同编号
        if (findProjectTable() || findProjectSectionHeader()) return false;

        adjustColumnWidths();
        applyRowHoverHighlight();

        var candidates = document.querySelectorAll('td, span, div, a, li, label, b, strong, p, em');

        // 第一遍:按合同编号去重,每个编号保留「所在表格最宽」的那个锚点(避免冻结列/主表重复插入)
        var map = {};
        for (var i = 0; i < candidates.length; i++) {
            var el = candidates[i];
            if (el.children && el.children.length > 0) continue; // 只看叶子
            if (el.closest('#' + PANEL_ID)) continue;
            var text = (el.textContent || '').trim();
            var m = text.match(CONTRACT_RE);
            if (!m) continue;
            var contractNo = m[0];
            var tbl = el.closest('table');
            var w = tbl ? tbl.offsetWidth : 0;
            if (!map[contractNo] || w > map[contractNo].width) {
                map[contractNo] = { el: el, width: w };
            }
        }

        var attached = 0;
        var nameBound = 0;

        // 给指定单元格绑定悬停气泡
        function bindHover(cell, contractNo) {
            cell.style.cursor = 'help';
            var timer = null;
            cell.addEventListener('mouseenter', function () {
                clearTimeout(timer);
                timer = setTimeout(function () { showTooltip(cell, contractNo); }, 120);
            });
            cell.addEventListener('mouseleave', function () {
                clearTimeout(timer);
                hideTooltip();
            });
        }

        // 解析“合同名称”列:优先返回列字母(跨分片 row 查找),再返回表+列下标(同表兜底)
        function resolveNameColumn() {
            // 方法1:带 id 的单元格(FineReport 单元格 id 形如 列字母+行号-片段-seq),可拿列字母做跨表查找
            var idCells = document.querySelectorAll('td[id], th[id]');
            for (var i = 0; i < idCells.length; i++) {
                var idc = idCells[i];
                var hm = (idc.id || '').match(/^([A-Z])(\d+)/);
                if (!hm) continue;
                if ((idc.textContent || '').replace(/\s/g, '').indexOf('合同名称') > -1) {
                    return { letter: hm[1], table: idc.closest('table'), index: -1 };
                }
            }
            // 方法2:遍历所有表,找含“合同名称”表头所在列下标(不依赖单元格 id,覆盖无 id/表头为 th 的情况)
            var tables = document.querySelectorAll('table');
            for (var t = 0; t < tables.length; t++) {
                var tbl = tables[t];
                var trs = tbl.querySelectorAll('tr');
                for (var r = 0; r < trs.length; r++) {
                    var cells = trs[r].querySelectorAll('td, th');
                    for (var c = 0; c < cells.length; c++) {
                        if ((cells[c].textContent || '').replace(/\s/g, '').indexOf('合同名称') > -1) {
                            return { letter: null, table: tbl, index: c };
                        }
                    }
                }
            }
            return null;
        }

        // 同表按列下标找“合同名称”单元格(兜底,适用于非冻结分片简单表格)
        function findNameCellByIndex(el, table, index) {
            if (!table || index < 0) return null;
            var tr = el.closest('tr');
            if (!tr) return null;
            var rowCells = tr.querySelectorAll('td, th');
            return rowCells[index] || null;
        }

        // 主方法:按「列字母 + 行号」跨表查找可见单元格(覆盖冻结列/滚动区分片)
        // 锚点 id 形如 B2-0-97140(B=列, 2=行);目标 id 形如 D2-0-xxxxx(跨表行号一致)
        function findNameCellCrossTable(el, letter) {
            if (!letter) return null;
            var anchorTd = el.closest('td');
            var am = (anchorTd && anchorTd.id || '').match(/^([A-Z])(\d+)/);
            if (!am) return null;
            var rowNum = am[2];
            var cands = document.querySelectorAll('td[id^="' + letter + rowNum + '-"], th[id^="' + letter + rowNum + '-"]');
            var visible = [];
            for (var x = 0; x < cands.length; x++) {
                if (cands[x].offsetWidth > 0 && cands[x].offsetHeight > 0) {
                    visible.push(cands[x]);
                }
            }
            return visible.length ? visible[0] : null;
        }

        var nameInfo = resolveNameColumn();
        var nameLetter = nameInfo ? nameInfo.letter : null;

        Object.keys(map).forEach(function (contractNo) {
            var item = map[contractNo];
            var el = item.el;
            var anchor = el.closest('td') || el;

            if (!anchor.dataset.tmAttached) {
                anchor.dataset.tmAttached = '1';
                var data = loadData(contractNo);
                // 有备注时,行内显示单行精简摘要
                if (hasRemarks(data)) {
                    injectInlineBadge(data, el, anchor);
                }
                bindHover(anchor, contractNo);
            }

            // 同逻辑行“合同名称”单元格绑定相同气泡(独立守卫,重扫可补绑)
            // 优先跨表列字母查找(覆盖冻结/滚动分片),失败则同表按列下标兜底
            var nameCell = null;
            if (nameInfo) {
                if (nameInfo.letter) {
                    nameCell = findNameCellCrossTable(el, nameInfo.letter);
                }
                if (!nameCell) {
                    var tbl = el.closest('table') || nameInfo.table;
                    nameCell = findNameCellByIndex(el, tbl, nameInfo.index);
                }
            }
            if (nameCell && !nameCell.dataset.tmNameHover) {
                nameCell.dataset.tmNameHover = '1';
                bindHover(nameCell, contractNo);
                nameBound++;
            }
            attached++;
        });

        if (attached > 0) {
            var nameDesc = nameInfo ? (nameInfo.letter ? ('列字母' + nameInfo.letter) : ('表内列#' + nameInfo.index)) : '未找到';
            console.log('[费用备注] 列表页扫描到 ' + attached + ' 个合同编号,合同名称列绑定 ' + nameBound + ' 个(名称列解析:' + nameDesc + ')');
        }
        return attached > 0;
    }

    function injectPanel() {
        if (document.getElementById(PANEL_ID)) return true;

        // 弹窗/对话框上下文(如任务完成确认弹窗)不注入,避免盖住原生按钮
        if (isModalContext()) return false;

        var contractNo = getContractNo();
        // 仅当合同编号格式合法(CONTRACT_RE)才注入,避免把 dashboard 的 SPA 页签名
        // (如 dec-tabs-homepage)或非合同页误当成合同编号进而注入面板盖住原生按钮。
        if (!contractNo || !CONTRACT_RE.test(contractNo)) return false;

        // 在合同详情页建立「报价单号 ↔ 合同号」映射(供价格确认页关联用)
        // 必须验证合同号格式(如 HJ202608079),防止帆软入口 UUID 被误认为合同号
        var quoteNo = getQuoteNoFromPage();
        if (quoteNo && QUOTE_RE.test(quoteNo) && CONTRACT_RE.test(contractNo)) {
            try {
                localStorage.setItem(QUOTE_MAP_PREFIX + quoteNo, contractNo);
                localStorage.setItem(CONTRACT_QUOTE_PREFIX + contractNo, quoteNo);
            } catch (e) { /* 忽略 */ }
        }

        var anchor = null;
        var table = findProjectTable();
        if (table) {
            anchor = climbToWideContainer(table);
        } else {
            var header = findProjectSectionHeader();
            if (!header) return false;
            anchor = climbToWideContainer(header);
        }

        if (!anchor.parentElement) {
            console.log('[费用备注] 注入跳过:未找到「项目信息」区域锚点(合同号=' + contractNo + ')');
            return false;
        }

        var panel = buildPanel(contractNo);
        anchor.parentElement.insertBefore(panel, anchor);
        console.log('[费用备注] 已注入,合同编号:' + contractNo + (quoteNo ? (',报价单号:' + quoteNo) : ''));
        return true;
    }

    function stopObserver() {
        if (observer) {
            observer.disconnect();
            observer = null;
        }
    }

    // ---------- 价格确认页:自动抓取费用并关联 ----------

    // 报价提交页:记录当前报价单编号(价格确认页没有编号,靠这个接力)
    function initQuoteSubmissionPage() {
        // ===== 方法1(最可靠):从 URL 参数提取 QUOTATION_ID =====
        // 帆软cpt填报页URL形如:...&QUOTATION_ID=ZWBJ-20260813003&...
        var urlMatch = location.search.match(/QUOTATION_ID=([A-Za-z0-9\-_]+)/);
        if (urlMatch) {
            var urlQuote = decodeURIComponent(urlMatch[1]);
            if (QUOTE_RE.test(urlQuote)) {
                try {
                    localStorage.setItem(PENDING_QUOTE_KEY, JSON.stringify({
                        quoteNo: urlQuote, at: Date.now()
                    }));
                    console.log('[费用备注] 报价提交页已记录报价单号(URL参数):' + urlQuote);
                    return true;
                } catch (e) {
                    console.warn('[费用备注] 写入 pending_quote 失败', e);
                }
            }
        }

        // ===== 方法2(备选):URL hash 中提取 =====
        var hashMatch = (location.href || '').match(/QUOTATION_ID=([A-Za-z0-9\-_]+)/);
        if (hashMatch) {
            var hashQuote = decodeURIComponent(hashMatch[1]);
            if (QUOTE_RE.test(hashQuote)) {
                try {
                    localStorage.setItem(PENDING_QUOTE_KEY, JSON.stringify({
                        quoteNo: hashQuote, at: Date.now()
                    }));
                    console.log('[费用备注] 报价提交页已记录报价单号(hash):' + hashQuote);
                    return true;
                } catch (e) { /* 忽略 */ }
            }
        }

        // ===== 方法3(兜底):页面 DOM 扫描(帆软 cpt 表单可能不是标准 table)=====
        // 特征1:URL 含 op=write(填报表单)
        // 特征2:页面含报价单编号文本
        var isWriteForm = (location.search || '').indexOf('op=write') > -1;
        if (!isWriteForm) return false;

        var quoteNo2 = getQuoteNoFromPage();
        if (quoteNo2 && QUOTE_RE.test(quoteNo2)) {
            try {
                localStorage.setItem(PENDING_QUOTE_KEY, JSON.stringify({
                    quoteNo: quoteNo2, at: Date.now()
                }));
                console.log('[费用备注] 报价提交页已记录报价单号(页面提取):' + quoteNo2);
                return true;
            } catch (e) { /* 忽略 */ }
        }
        return false;
    }

    // 重试循环:帆软页面渲染慢/网络波动,重复检测直到成功或确认无需抓取
    var submissionRetryCount = 0;
    var submissionCaptured = false;     // 已成功记录报价单编号
    var submissionLoopActive = false;   // 防止多次触发叠加循环
    var SUBMISSION_MAX_RETRY = 75;      // 75 * 800ms ≈ 60 秒,容忍慢加载与短暂延迟
    var SUBMISSION_INTERVAL = 800;      // 比原 600ms 略长,减少无谓轮询
    function tryCaptureSubmission() {
        if (submissionCaptured) return;
        if (initQuoteSubmissionPage()) { submissionCaptured = true; submissionLoopActive = false; return; }
        if (submissionLoopActive) return; // 已有循环在跑,避免叠加
        submissionLoopActive = true;
        scheduleSubmissionRetry();
    }
    function scheduleSubmissionRetry() {
        submissionRetryCount++;
        if (submissionRetryCount < SUBMISSION_MAX_RETRY) {
            setTimeout(function () {
                if (submissionCaptured) { submissionLoopActive = false; return; }
                if (initQuoteSubmissionPage()) { submissionCaptured = true; submissionLoopActive = false; return; }
                scheduleSubmissionRetry();
            }, SUBMISSION_INTERVAL);
        } else {
            submissionLoopActive = false;
            // 区分“本页无需抓取”与“真正未捕获”:无写表单/无编号属正常情况,不报超时误警
            var looksLikeSubmission = (location.search || '').indexOf('op=write') > -1
                                   || /QUOTATION_ID=/.test(location.href);
            if (looksLikeSubmission) {
                console.log('[费用备注] 提交页未在 ' + Math.round(SUBMISSION_MAX_RETRY * SUBMISSION_INTERVAL / 1000) + ' 秒内检测到报价单编号(已重试 ' + SUBMISSION_MAX_RETRY + ' 次)');
            } else {
                console.log('[费用备注] 提交页未检测到报价单表单,跳过(无需抓取)');
            }
        }
    }

    // 页面内 SPA 跳转 / URL 变化后重新尝试抓取,覆盖慢加载与延迟注入场景(仅当看起来像提交页时才重跑,避免无关 hash 变化空跑)
    function onSubmissionUrlChange() {
        if (!(/QUOTATION_ID=/.test(location.href) || (location.search || '').indexOf('op=write') > -1)) return;
        submissionCaptured = false;
        submissionRetryCount = 0;
        submissionLoopActive = false;
        tryCaptureSubmission();
    }
    window.addEventListener('hashchange', onSubmissionUrlChange);
    window.addEventListener('popstate', onSubmissionUrlChange);

    // 读取接力的报价单号(30 分钟内有效)
    function getPendingQuote() {
        try {
            var raw = localStorage.getItem(PENDING_QUOTE_KEY);
            if (!raw) return null;
            var obj = JSON.parse(raw);
            if (obj && obj.quoteNo && (Date.now() - (obj.at || 0)) < PENDING_QUOTE_TTL) {
                return obj.quoteNo;
            }
        } catch (e) { /* 忽略 */ }
        return null;
    }

    function showPageTip(msg, color) {
        var el = document.createElement('div');
        el.textContent = msg;
        el.style.cssText = 'position:fixed;top:16px;right:16px;z-index:99999;' +
            'background:' + (color || '#67c23a') + ';color:#fff;padding:10px 18px;' +
            'border-radius:6px;font-size:14px;font-weight:600;box-shadow:0 4px 12px rgba(0,0,0,0.2);' +
            'font-family:' + FONT_FAMILY + ';transition:opacity .3s;';
        document.body.appendChild(el);
        setTimeout(function () {
            el.style.opacity = '0';
            setTimeout(function () { if (el.parentElement) el.parentElement.removeChild(el); }, 350);
        }, 5000);
    }

    // ---------- 价格确认页:总价(合同额)抓取 ----------
    // 定位「总价」标签单元格(文本精确匹配,去除空白)
    function findTotalPriceEl() {
        var nodes = document.querySelectorAll('td, th, span, div, label, strong, b, p');
        for (var i = 0; i < nodes.length; i++) {
            var el = nodes[i];
            if (el.childElementCount > 1) continue;
            // 去除空白与冒号(全角/半角),兼容「总价」「总价:」等写法
            var text = (el.textContent || '').replace(/\s/g, '').replace(/[::]/g, '');
            if (text === '总价') return el;
        }
        return null;
    }

    // 从「总价」元素或页面文本提取总价数值
    function getTotalPriceValue(el) {
        if (el) {
            var sib = el.nextElementSibling;
            if (sib) {
                var v = parseAmount(sib.textContent);
                if (v > 0) return v;
            }
            var m = ((el.parentElement && el.parentElement.textContent) || el.textContent || '').match(/总价[::\s]*([\d.,]+)/);
            if (m) { var v2 = parseAmount(m[1]); if (v2 > 0) return v2; }
        }
        var m2 = (document.body.innerText || '').match(/总价[::\s]*([\d.,]+)/);
        return m2 ? parseAmount(m2[1]) : null;
    }

    // 计算当前页面总价(不写存储),价格确认页金额变化后据此重抓
    function computeTotalPrice() {
        var el = findTotalPriceEl();
        var v = getTotalPriceValue(el);
        return (v != null && v > 0) ? v : null;
    }

    // 检测价格确认页:费用信息表表头含「费用类型 + 原价 + 报价」且无「操作」列(与提交页区分)
    function findConfirmationFeeTable() {
        var tables = document.querySelectorAll('table');
        for (var i = 0; i < tables.length; i++) {
            var firstTr = tables[i].querySelector('tr');
            if (!firstTr) continue;
            var cells = firstTr.querySelectorAll('th, td');
            var texts = [];
            for (var c = 0; c < cells.length; c++) texts.push((cells[c].textContent || '').trim());
            var joined = texts.join('|');
            if (joined.indexOf('费用类型') > -1 &&
                joined.indexOf('原价') > -1 &&
                joined.indexOf('报价') > -1 &&
                joined.indexOf('操作') < 0) {
                return tables[i];
            }
        }
        return null;
    }

    // 从页面文本解析费用行(适用于帆软cpt div渲染的表单)
    // 文本结构示例:
    //   费用类型	原价	报价	费用备注
    //   控制效果评价	39900          ← 报价在 input 元素中(用户可编辑),文本只有原价
    //   不考核	3000	3000       ← 原价和报价都在文本中
    //   专家评审	4000	4000
    // rowInputs:按行分组的 input 列表,每项 {quote, remark}
    //  - quote:  报价 input 值(默认或用户填的)
    //  - remark: 备注 input 值(仅当用户在 input 里填了备注才有)
    function extractFeesFromText(pageText, rowInputs) {
        rowInputs = rowInputs || [];
        var rows = [];
        var lines = pageText.split(/\n/);
        var inFeeSection = false;
        var inputIdx = 0; // 当前消费到 rowInputs 的哪一项

        for (var i = 0; i < lines.length; i++) {
            var line = lines[i].trim();

            // 进入费用区域:表头行
            if (line.indexOf('费用类型') > -1 && line.indexOf('原价') > -1) {
                inFeeSection = true;
                continue;
            }
            // 退出费用区域:按钮行
            if (inFeeSection && (line.indexOf('提交') > -1 || line.indexOf('上一步') > -1)) {
                break;
            }
            if (!inFeeSection || !line) continue;

            // 按空白(tab/空格)分词
            var parts = line.split(/[\t\s]+/).filter(function (p) { return p; });
            if (parts.length < 2) continue;

            // 分离:名称(第一个非数字)、数字列表、备注(数字后面的剩余非数字文本)
            var name = '';
            var numbers = [];
            var remark = '';
            for (var j = 0; j < parts.length; j++) {
                if (/^[\d.,]+%?$/.test(parts[j])) {
                    numbers.push(parts[j].replace(/[,%]/g, ''));
                } else if (!name) {
                    name = parts[j];
                } else {
                    remark += (remark ? ' ' : '') + parts[j];
                }
            }

            // 跳过非费用行(没有名称或没有数字)
            if (!name || numbers.length === 0) continue;
            // 跳过疑似标题/汇总行
            if (name === '费用类型' || name === '原价' || name === '报价' || name === '总价') continue;

            var amount;
            if (numbers.length >= 2) {
                // 文本中有原价+报价两列 → 取最后一个(报价列)
                amount = numbers[numbers.length - 1];
            } else {
                // 文本只有原价 → 报价在 input 元素中
                if (inputIdx < rowInputs.length && rowInputs[inputIdx].quote) {
                    amount = rowInputs[inputIdx].quote;
                    console.log('[费用备注] "' + name + '" 报价取自 input:' + amount + '(原价:' + numbers[0] + ')');
                } else {
                    amount = numbers[0]; // 兜底
                }
            }

            // 备注:优先用 input 里的值(用户编辑过),否则用文本里的
            if (!remark && inputIdx < rowInputs.length && rowInputs[inputIdx].remark) {
                remark = rowInputs[inputIdx].remark;
            }
            inputIdx++;

            rows.push({
                projectName: name,
                projectAmount: amount,
                remark: remark
            });
        }
        return rows;
    }

    // 按行收集页面上的 input 值(每行第一个=报价,最后一个=备注)
    // 报价列在左、备注列在右,按 x 坐标区分;同行(y 差<20px)内最左为报价、最右为备注
    function collectInputValues() {
        var inputs = document.querySelectorAll('input[type="text"], input:not([type])');
        var items = [];
        for (var i = 0; i < inputs.length; i++) {
            var v = (inputs[i].value || '').trim();
            if (!v) continue;
            var rect = inputs[i].getBoundingClientRect();
            items.push({ value: v, x: rect.left, y: rect.top });
        }
        if (!items.length) return [];

        // 按 y 排序分组(同一行)
        items.sort(function (a, b) { return a.y - b.y || a.x - b.x; });
        var rows = [];
        var currentRow = null;
        var lastY = -9999;
        for (var j = 0; j < items.length; j++) {
            if (items[j].y - lastY > 20) {
                if (currentRow) rows.push(currentRow);
                currentRow = { quote: null, remark: null };
            }
            // 同行:第一个=报价,最后一个=备注
            if (currentRow.quote === null) {
                currentRow.quote = items[j].value;
            } else {
                currentRow.remark = items[j].value; // 后来的(x更大)会覆盖,实现"最后一个=备注"
            }
            lastY = items[j].y;
        }
        if (currentRow) rows.push(currentRow);
        return rows;
    }

    function initPriceConfirmPage() {
        if (document.body.dataset.tmPriceCaptured) return; // 本页已处理过

        // ===== 第一步:确认是价格确认页 =====
        // 方法1:URL 含 QUOTATION_ID 且 reportlet 含"总价"(与提交页"报价"区分)
        var urlStr = location.href || '';
        var hasQuoteId = /QUOTATION_ID=/.test(urlStr);
        var isConfirmUrl = /总价/.test(decodeURIComponent(urlStr));

        // 方法2:页面含"费用类型"和"原价"文本(不依赖 table 标签)
        var pageText = (document.body.innerText || '');
        var hasFeeHeaders = pageText.indexOf('费用类型') > -1 && pageText.indexOf('原价') > -1;

        if (!hasQuoteId || (!isConfirmUrl && !hasFeeHeaders)) return;

        // ===== 第二步:获取报价单号(URL 最优先) =====
        var quoteNo = null;
        var urlMatch = urlStr.match(/QUOTATION_ID=([A-Za-z0-9\-_]+)/);
        if (urlMatch) {
            quoteNo = decodeURIComponent(urlMatch[1]);
            if (!QUOTE_RE.test(quoteNo)) quoteNo = null;
        }
        if (!quoteNo) quoteNo = getQuoteNoFromPage();
        if (!quoteNo || !QUOTE_RE.test(quoteNo)) {
            quoteNo = getPendingQuote();
        }
        if (!quoteNo) return;

        // ===== 第三步:提取费用数据 =====
        var rows = [];

        // 方法1:标准 table
        var feeTable = findConfirmationFeeTable();
        if (feeTable) {
            var trs = feeTable.querySelectorAll('tr');
            for (var t = 1; t < trs.length; t++) {
                var tds = trs[t].querySelectorAll('td');
                if (tds.length < 3) continue;
                var name = (tds[0].textContent || '').trim();
                if (!name) continue;
                rows.push({
                    projectName: name,
                    projectAmount: (tds[2].textContent || '').trim(),
                    remark: tds.length > 3 ? (tds[3].textContent || '').trim() : ''
                });
            }
        }

        // 方法2:从页面文本解析(帆软cpt表单div渲染,innerText含结构化数据)
        // 文本中只有原价时,报价在可编辑 input 元素中——同时收集 input 值补充
        if (!rows.length) {
            var inputValues = collectInputValues();
            rows = extractFeesFromText(pageText, inputValues);
        }

        if (!rows.length) {
            console.log('[费用备注] 确认页检测到了,但未能提取费用行数据');
            return;
        }

        // ===== 第四步:保存(含总价/合同额) =====
        var totalAmount = computeTotalPrice();
        try {
            localStorage.setItem(AUTO_PREFIX + quoteNo, JSON.stringify({
                rows: rows,
                totalAmount: totalAmount,
                totalAmountSource: totalAmount != null ? 'price_confirm' : undefined,
                capturedAt: Date.now()
            }));
        } catch (e) { /* 忽略 */ }

        var contractNo = null;
        try { contractNo = localStorage.getItem(QUOTE_MAP_PREFIX + quoteNo); } catch (e) { /* 忽略 */ }
        if (contractNo) {
            var existing = null;
            try {
                var raw = localStorage.getItem(STORAGE_PREFIX + contractNo);
                if (raw) existing = JSON.parse(raw);
            } catch (e) { /* 忽略 */ }
            var existingData = normalizeData(existing);
            if (!hasRemarks(existingData)) {
                try {
                    localStorage.setItem(STORAGE_PREFIX + contractNo, JSON.stringify({
                        locked: false, rows: rows, totalAmount: totalAmount
                    }));
                } catch (e) { /* 忽略 */ }
            } else {
                // 已有手动备注:仅刷新自动抓取的总价,不覆盖用户填写的项目行
                if (existingData.totalAmount !== totalAmount) {
                    existingData.totalAmount = totalAmount;
                    try {
                        localStorage.setItem(STORAGE_PREFIX + contractNo, JSON.stringify(existingData));
                    } catch (e) { /* 忽略 */ }
                }
            }
        }

        document.body.dataset.tmPriceCaptured = '1';
        var msg = '已自动抓取 ' + rows.length + ' 项费用' +
            (totalAmount != null ? ',总价 ' + formatAmount(totalAmount) : '') +
            (contractNo && CONTRACT_RE.test(contractNo) ? '(已关联合同 ' + contractNo + ')' : '(报价单 ' + quoteNo + ',待关联合同)');
        showPageTip(msg, '#67c23a');
        console.log('[费用备注] ' + msg);

        // ===== 第五步:监听后续变化,用户修改后自动重新抓取 =====
        setupConfirmPageListeners(quoteNo);
    }

    // 监听确认页的 input 变化和提交按钮点击,确保抓到用户最终修改的值
    function setupConfirmPageListeners(quoteNo) {
        if (document.body.dataset.tmConfirmListeners) return; // 已绑定过
        document.body.dataset.tmConfirmListeners = '1';

        var recapture = function () {
            // 清除"已抓取"标记,让 initPriceConfirmPage 重新执行
            delete document.body.dataset.tmPriceCaptured;
            initPriceConfirmPage();
        };

        // 监听所有 input 的 change 事件(用户修改值后触发)
        var inputs = document.querySelectorAll('input[type="text"], input:not([type])');
        for (var i = 0; i < inputs.length; i++) {
            inputs[i].addEventListener('change', function () {
                console.log('[费用备注] 检测到报价修改,重新抓取...');
                setTimeout(recapture, 200); // 稍等让 DOM 更新
            });
        }

        // 监听"提交"按钮点击(最终确认时的数据最准确)
        var buttons = document.querySelectorAll('button, input[type="button"], input[type="submit"], a');
        for (var b = 0; b < buttons.length; b++) {
            var btnText = (buttons[b].textContent || buttons[b].value || '').trim();
            if (btnText === '提交' || btnText === '确 定' || btnText === '确定') {
                buttons[b].addEventListener('click', function () {
                    console.log('[费用备注] 提交按钮点击,执行最终抓取...');
                    delete document.body.dataset.tmPriceCaptured;
                    initPriceConfirmPage();
                }, true); // capture 模式,在其他 handler 之前执行
            }
        }

        // 定时复查总价:帆软在修改项目金额后可能自动重算「总价」且不触发 input 事件,
        // 用 3 秒轮询兜底,确保总价随项目金额变化而重新抓取并同步到合同。
        var lastTotal = computeTotalPrice() || 0;
        var totalTimer = setInterval(function () {
            var v = computeTotalPrice();
            if (v != null && v > 0 && Math.abs(v - lastTotal) > 0.005) {
                lastTotal = v;
                var autoRows = [];
                try { autoRows = (JSON.parse(localStorage.getItem(AUTO_PREFIX + quoteNo) || '{}').rows) || []; } catch (e) { /* 忽略 */ }
                try {
                    localStorage.setItem(AUTO_PREFIX + quoteNo, JSON.stringify({
                        rows: autoRows, totalAmount: v, totalAmountSource: 'price_confirm', capturedAt: Date.now()
                    }));
                } catch (e) { /* 忽略 */ }
                try {
                    var cn = localStorage.getItem(QUOTE_MAP_PREFIX + quoteNo);
                    if (cn) {
                        var cdr = normalizeData(JSON.parse(localStorage.getItem(STORAGE_PREFIX + cn) || 'null'));
                        cdr.totalAmount = v;
                        localStorage.setItem(STORAGE_PREFIX + cn, JSON.stringify(cdr));
                    }
                } catch (e) { /* 忽略 */ }
                console.log('[费用备注] 检测到总价变化,已重新抓取:' + formatAmount(v));
            }
        }, 3000);
        window.addEventListener('beforeunload', function () { clearInterval(totalTimer); });
    }

    // ---------- 启动 ----------

    function tryInject() {
        if (injectPanel()) {
            stopObserver();
            return;
        }
        retryCount++;
        if (retryCount < MAX_RETRY) {
            setTimeout(tryInject, 800);
        } else {
            // 重试耗尽仍未注入:输出一次诊断,说明失败原因(不刷屏)
            if (!document.getElementById(PANEL_ID)) {
                if (isModalContext()) {
                    console.log('[费用备注] 未能注入:当前为弹窗/对话框上下文,已跳过');
                } else {
                    var cn = getContractNo();
                    console.log('[费用备注] 未能注入:合同号=' + (cn || '(空)') +
                        ',CONTRACT_RE通过=' + (cn ? CONTRACT_RE.test(cn) : false) +
                        ',找到项目表=' + !!findProjectTable() +
                        ',找到项目信息标题=' + !!findProjectSectionHeader());
                }
            }
            stopObserver();
        }
    }

    tryInject();

    // 列表页:扫描合同列表并绑定悬停气泡(无重试,列表渲染完成后立刻生效一次)
    initListPage();

    // 报价提交页:记录报价单编号(带重试,等待费用表渲染完成)
    tryCaptureSubmission();

    // 价格确认页:自动抓取费用信息(无重试,渲染完成后跑一次即可)
    initPriceConfirmPage();

    // 帆软 SPA 页签切换 / iframe 内报表异步渲染时兜底注入;注入成功后自动停止监听
    // 同时再次校验:仅当仍在目标页(白名单通过 + 无模态弹窗覆盖)才执行,
    // 避免仪表板/任务完成弹窗等无关 DOM 变化时误跑 init 干扰原生点击。
    function shouldRun() {
        if (!decideTargetPage()) return false;
        // 出现模态/dialog/mask 时跳过(FineReport 弹窗、Element UI、Ant Design Vue 等)
        if (document.querySelector('.fr-message-box, .fr-dialog, .ant-modal-mask, .ant-modal-wrap, .el-dialog__wrapper, .fui-modal-mask, [role="dialog"], [class*="modal"][class*="mask"]')) {
            return false;
        }
        return true;
    }
    var observeTimer = null;
    observer = new MutationObserver(function () {
        if (!shouldRun()) return;
        if (document.getElementById(PANEL_ID)) {
            // 详情页面板已注入,列表页仍可能需要重新扫描
            initListPage();
            initQuoteSubmissionPage();
            initPriceConfirmPage();
            return;
        }
        clearTimeout(observeTimer);
        observeTimer = setTimeout(function () {
            if (!shouldRun()) return;
            if (injectPanel()) {
                stopObserver();
            } else {
                initListPage();
                initQuoteSubmissionPage();
                initPriceConfirmPage();
            }
        }, 300);
    });
    observer.observe(document.body, { childList: true, subtree: true });
})();