NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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 { '&': '&', '<': '<', '>': '>' }[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形如:..."ATION_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 });
})();