OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,499 @@
@extends($layout)
@section('content')
@php
$bom = $detail['bom'] ?? [];
$columns = $detail['columns'] ?? [];
$items = $detail['items'] ?? [];
$sources = $detail['sources'] ?? [];
$hiddenColumns = $detail['hiddenColumns'] ?? [];
$imageColumns = $detail['imageColumns'] ?? [];
$fileColumns = $detail['fileColumns'] ?? [];
$bomId = (int)($bom['id'] ?? 0);
@endphp
<div class="pageheader" style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<div style="display:flex; align-items:center; gap:10px;">
<div class="pageicon"><span class="fa fa-fw fa-list-check"></span></div>
<div class="pagetitle" style="margin:0;">
<h1 style="margin:0;">{{ $bom['productName'] ?? $bom['bomNo'] ?? __('menu.bom') }}</h1>
<p style="margin:0;">{{ __('text.bom_subtitle', 'BOM 明细') }} {{ $bom['bomNo'] ?? '' }}</p>
</div>
</div>
<div style="white-space:nowrap;">
<button type="button" class="btn btn-primary" id="btnEdit"><i class="fa fa-pencil"></i> 编辑</button>
<button type="button" class="btn btn-success" id="btnSave"><i class="fa fa-save"></i> 保存</button>
{{-- 插入图片/附件 --}}
<div class="btn-group" style="position:relative;display:inline-block;">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-paperclip"></i> 插入 <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="javascript:void(0)" id="btnInsertImage"><i class="fa fa-image"></i> 插入图片</a></li>
<li><a href="javascript:void(0)" id="btnInsertAttach"><i class="fa fa-file"></i> 插入附件</a></li>
</ul>
</div>
{{-- 一级菜单:导入导出 --}}
<div class="btn-group" style="position:relative;display:inline-block;">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-exchange"></i> 导入导出 <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li class="dropdown-header"><i class="fa fa-cloud-download"></i> Teable 导入</li>
<li><a href="javascript:void(0)" id="btnTeableImport"><i class="fa fa-refresh"></i> 从数据源导入</a></li>
<li><a href="javascript:void(0)" id="btnTeablePaste"><i class="fa fa-paste"></i> 粘贴 API 导入</a></li>
<li><a href="javascript:void(0)" id="btnSourceManager"><i class="fa fa-database"></i> 数据源管理</a></li>
<li class="divider"></li>
<li class="dropdown-header"><i class="fa fa-upload"></i> Excel 导入</li>
<li><a href="javascript:void(0)" id="btnExcelImport"><i class="fa fa-upload"></i> 导入数据</a></li>
<li><a href="javascript:void(0)" id="btnExcelTemplate"><i class="fa fa-download"></i> 模板导出</a></li>
<li class="divider"></li>
<li class="dropdown-header"><i class="fa fa-file-export"></i> 数据导出</li>
<li><a href="javascript:void(0)" id="btnExcelExport"><i class="fa fa-file-excel-o"></i> 导出数据 (xlsx)</a></li>
<li><a href="javascript:void(0)" id="btnExcelExportCsv"><i class="fa fa-file"></i> 导出数据 (csv)</a></li>
</ul>
</div>
<button type="button" class="btn btn-default" id="btnFullscreen"><i class="fa fa-expand"></i> 全屏</button>
<a href="{{ BASE_URL }}/bom/show" class="btn btn-default"><i class="fa fa-arrow-left"></i> {{ __('links.back', '返回') }}</a>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
<div id="bomAlert" class="alert" style="display:none;"></div>
<div id="bomError" class="alert alert-danger" style="display:none;"></div>
{{-- Univer 表格容器(动态铺满屏幕,自适应缩放) --}}
<div id="bomHot" style="width:100%;height:calc(100vh - 200px);min-height:400px;border:1px solid #e5e5e5;"></div>
</div>
</div>
{{-- Teable API 粘贴导入弹窗 --}}
<div id="teablePasteModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.45);z-index:9999;overflow:auto;">
<div style="max-width:760px;margin:6% auto;background:#fff;border-radius:4px;padding:18px;">
<h4 style="margin-top:0;"><i class="fa fa-paste"></i> 粘贴 Teable API 文档</h4>
<p class="text-muted">直接粘贴整段 API 文档或 curl 命令,自动解析连接信息;解析不全时可手动补齐下方字段。</p>
<textarea id="teablePasteText" style="width:100%;height:180px;font-family:monospace;font-size:12px;" placeholder="# Table: UAGP350B&#10;&#10;curl -X GET &quot;https://table.universal-onebot.com/api/table/tblXXX/record?fieldKeyType=name&quot; \&#10; -H &quot;Authorization: Bearer teable_...&quot;"></textarea>
<div style="margin-top:10px;">
<div class="row">
<div class="col-md-12"><label style="font-size:12px;">表名/别名</label><input type="text" id="pasteName" class="form-control"></div>
</div>
<div class="row" style="margin-top:6px;">
<div class="col-md-12"><label style="font-size:12px;">Base URL</label><input type="text" id="pasteBaseUrl" class="form-control" placeholder="https://table.universal-onebot.com"></div>
</div>
<div class="row" style="margin-top:6px;">
<div class="col-md-5"><label style="font-size:12px;">Table ID</label><input type="text" id="pasteTableId" class="form-control" placeholder="tbl..."></div>
<div class="col-md-7"><label style="font-size:12px;">Token</label><input type="text" id="pasteToken" class="form-control" placeholder="teable_acc..."></div>
</div>
</div>
<div id="teableParseResult" style="display:none;margin:10px 0;padding:10px;background:#f5f5f5;border:1px solid #ddd;border-radius:4px;font-size:13px;"></div>
<div style="text-align:right;margin-top:12px;">
<button type="button" class="btn btn-default" id="btnTeableParse"><i class="fa fa-search"></i> 解析</button>
<button type="button" class="btn btn-primary" id="btnTeablePasteGo"><i class="fa fa-cloud-download"></i> 导入</button>
<button type="button" class="btn btn-default" id="btnTeablePasteClose">取消</button>
</div>
</div>
</div>
{{-- 数据源管理弹窗 --}}
<div id="sourceManagerModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.45);z-index:9999;overflow:auto;">
<div style="max-width:640px;margin:6% auto;background:#fff;border-radius:4px;padding:18px;">
<h4 style="margin-top:0;"><i class="fa fa-database"></i> Teable 数据源管理</h4>
<div id="sourceList" style="margin-bottom:10px;">
@forelse ($sources as $s)
<span class="label label-info" style="margin-right:6px;">
{{ $s['name'] ?: $s['tableId'] }}
<a href="javascript:void(0)" class="btnDelSource" data-id="{{ $s['id'] }}" style="color:#fff;">&times;</a>
</span>
@empty
<em class="text-muted">{{ __('text.no_sources', '未配置数据源') }}</em>
@endforelse
</div>
<div class="row">
<div class="col-md-3"><input type="text" id="srcName" class="form-control" placeholder="{{ __('label.source_name', '表名/别名') }}"></div>
<div class="col-md-3"><input type="text" id="srcBaseUrl" class="form-control" placeholder="https://table.universal-onebot.com"></div>
<div class="col-md-2"><input type="text" id="srcTableId" class="form-control" placeholder="tbl..."></div>
<div class="col-md-3"><input type="text" id="srcToken" class="form-control" placeholder="teable_acc..."></div>
<div class="col-md-1"><button type="button" class="btn btn-default" id="btnAddSource">{{ __('links.add', '添加') }}</button></div>
</div>
<div style="text-align:right;margin-top:12px;">
<button type="button" class="btn btn-default" id="btnSourceManagerClose">关闭</button>
</div>
</div>
</div>
{{-- 隐藏文件输入 --}}
<input type="file" id="excelFile" accept=".xlsx,.csv" style="display:none;">
<input type="file" id="attachFile" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt,.csv" style="display:none;">
<script src="{{ BASE_URL }}/dist/js/compiled-univer-component.3.9.8.min.js"></script>
@once
@push('scripts')
<script>
jQuery(document).ready(function () {
var BOM_ID = {{ $bomId }};
var BASE = '{{ BASE_URL }}';
var FIXED = {!! json_encode(array_keys($fixedColumns)) !!};
var FIXED_LABELS = {!! json_encode($fixedColumns) !!};
var COLUMNS = {!! json_encode(array_map(function($c){ return ['key'=>$c['key'],'label'=>$c['label']]; }, $columns)) !!};
var IMAGE_COLS = {!! json_encode(array_values($imageColumns)) !!};
var FILE_COLS = {!! json_encode(array_values($fileColumns)) !!};
var RAW_ITEMS = {!! json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) !!};
var ALL_KEYS = FIXED.concat(COLUMNS.map(function (c) { return c.key; }));
var csrf = function () { return jQuery('meta[name="csrf-token"]').attr('content') || ''; };
function showAlert(msg, type) {
var el = jQuery('#bomAlert');
el.attr('class', 'alert alert-' + (type || 'info')).text(msg).show();
}
function showError(msg) { jQuery('#bomError').text(msg).show(); }
function api(method, url, data, cb) {
var opts = { url: url, method: method, headers: { 'X-CSRF-TOKEN': csrf() }, dataType: 'json' };
if (data !== undefined) { opts.contentType = 'application/json'; opts.data = JSON.stringify(data); }
return jQuery.ajax(opts).done(function (res) { cb && cb(res); }).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
}
// 数据附件列已在后端转成显示文本image.png 等),直接用
var data = RAW_ITEMS.map(function (it) {
var o = { _id: it.id || null };
ALL_KEYS.forEach(function (k) { o[k] = (it[k] === undefined || it[k] === null) ? '' : it[k]; });
return o;
});
var dirtyRows = {}; // 0-based 数据行索引 -> true
// 初始化 Univer
var host = document.getElementById('bomHot');
var inst;
try {
inst = window.createOneBotSheet({
container: host,
data: data,
columns: ALL_KEYS,
fixedLabels: FIXED_LABELS,
imageColumns: IMAGE_COLS,
fileColumns: FILE_COLS,
onCellChange: function (row, col, value) {
// 全量对比方案:事件回调不做任何 data 修改(否则 data 基线被污染,对比永远相等)
// 只做行标记,供潜在 UI 提示用
if (row >= 0 && row < data.length) {
dirtyRows[row] = true;
}
},
onCellClick: function (row, col) {
handleAttachmentClick(row, col);
}
});
} catch (e) {
showError('Univer 初始化失败: ' + e.message);
return;
}
var sheet = inst.sheet;
// 读取某 sheet 行0-based 数据行索引)为 payload
function buildPayload(dataRowIndex) {
var payload = {};
ALL_KEYS.forEach(function (k, c) {
var v = sheet.getRange(dataRowIndex + 1, c).getDisplayValue();
payload[k] = (v === undefined || v === null) ? '' : String(v);
});
return payload;
}
function saveRow(dataRowIndex) {
var payload = buildPayload(dataRowIndex);
// 已有 data 条目(有 _id则更新否则新建
if (data[dataRowIndex] && data[dataRowIndex]._id) {
payload.itemId = data[dataRowIndex]._id;
}
api('POST', BASE + '/bom/api/' + BOM_ID + '/item', payload, function (res) {
if (res.itemId && (!data[dataRowIndex] || !data[dataRowIndex]._id)) {
if (!data[dataRowIndex]) { data[dataRowIndex] = {}; }
data[dataRowIndex]._id = res.itemId;
}
});
}
// 判断某 sheet 行是否有任何非空值
function rowHasValue(dataRowIndex) {
for (var c = 0; c < ALL_KEYS.length; c++) {
var v = sheet.getRange(dataRowIndex + 1, c).getDisplayValue();
if (v !== undefined && v !== null && String(v).trim() !== '') { return true; }
}
return false;
}
// 编辑 / 保存
jQuery('#btnEdit').on('click', function () {
showAlert('表格已解锁,可编辑数据;完成后点「保存」提交', 'info');
});
jQuery('#btnSave').on('click', function () {
// 先结束编辑态,让编辑器把值提交进 sheet
var endPromise = (inst.endEditing && typeof inst.endEditing === 'function')
? inst.endEditing().catch(function () { return true; })
: Promise.resolve(true);
endPromise.then(function () {
setTimeout(function () {
// 遍历 sheet 实际数据行(非 data 数组),空表/新行都能保存
// 注意FWorksheet facade 没有 getRowCount(),必须用 inst.getRowCount()(内部走 getMaxRows
var sheetRowCount = inst.getRowCount ? inst.getRowCount() : 0;
var saved = 0;
for (var r = 0; r < sheetRowCount - 1; r++) {
if (rowHasValue(r)) { saveRow(r); saved++; }
}
if (saved > 0) {
showAlert('已保存 ' + saved + ' 行', 'success');
} else {
showAlert('没有需要保存的改动', 'info');
}
}, 500);
});
});
// ---- 全屏切换 ----
jQuery('#btnFullscreen').on('click', function () {
var el = document.getElementById('bomHot');
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (el.requestFullscreen) {
el.requestFullscreen();
}
});
document.addEventListener('fullscreenchange', function () {
var btn = jQuery('#btnFullscreen');
if (document.fullscreenElement) {
btn.html('<i class="fa fa-compress"></i> 退出全屏');
} else {
btn.html('<i class="fa fa-expand"></i> 全屏');
}
});
// ---- 表格自适应布满屏幕 ----
function resizeBomHot() {
var host = document.getElementById('bomHot');
if (!host) { return; }
// 非全屏时:高度 = 视口高 - 顶部工具栏约 200px全屏时铺满
if (document.fullscreenElement === host) {
host.style.height = '100vh';
host.style.width = '100vw';
} else {
host.style.height = 'calc(100vh - 200px)';
host.style.width = '100%';
}
// 容器尺寸变化后,让表格缩放适配宽度
if (inst && typeof inst.fitToContainer === 'function') {
setTimeout(function () { inst.fitToContainer(); }, 50);
}
}
jQuery(window).on('resize', resizeBomHot);
resizeBomHot();
// ---- 插入图片/附件(上传到 /bom/api/{id}/attachment图片插浮动缩略图附件写单元格链接 ----
var attachKind = 'image'; // 'image' | 'file'
function doInsertAttach() {
var cell = inst.getActiveCell ? inst.getActiveCell() : null;
var row = cell ? cell.getRow() : 0;
var col = cell ? cell.getColumn() : 0;
// 表头行(第 0 行)不允许插附件,回退到第一数据行
if (row < 1) { row = 1; }
jQuery('#attachFile').off('change').on('change', function () {
var file = this.files[0];
if (!file) { return; }
var fd = new FormData();
fd.append('file', file);
jQuery.ajax({
url: BASE + '/bom/api/' + BOM_ID + '/attachment',
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
data: fd, processData: false, contentType: false, dataType: 'json'
}).done(function (res) {
var d = (res && res.data) || {};
if (!d.url) { showAlert('上传失败:未返回地址', 'danger'); return; }
var isImage = (attachKind === 'image' && d.image);
// 图片:插入浮动缩略图
if (isImage) {
try { inst.insertImageAt(row, col, d.url, 'URL', 0); } catch (e) {}
}
// 持久化:把 {url,name,image} 追加到当前单元格的附件 JSON 数组,并保存(刷新后可重渲染,避免黑圈/丢失)
var existing = [];
try {
var cur = sheet.getRange(row, col).getDisplayValue();
if (cur) { existing = JSON.parse(cur); }
} catch (e) { existing = []; }
if (!Array.isArray(existing)) { existing = []; }
existing.push({ url: d.url, name: d.name, image: isImage });
try { sheet.getRange(row, col).setValue(JSON.stringify(existing)); } catch (e) {}
saveRow(row - 1);
showAlert((isImage ? '图片' : '附件') + '已插入并保存:' + d.name, 'success');
}).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
jQuery(this).val('');
});
jQuery('#attachFile').click();
}
jQuery('#btnInsertImage').on('click', function () { attachKind = 'image'; doInsertAttach(); });
jQuery('#btnInsertAttach').on('click', function () { attachKind = 'file'; doInsertAttach(); });
// ---- 附件点击PDF 弹窗预览,图片新窗口放大,其它下载 ----
function isExt(url, exts) {
var m = /\.([a-zA-Z0-9]+)(?:[?#].*)?$/.exec((url || '').split('?')[0]);
if (!m) { return false; }
return exts.indexOf(m[1].toLowerCase()) >= 0;
}
function handleAttachmentClick(row, col) {
// row 是 0-based sheet 行号;第 0 行表头不处理
if (row < 1 || col < 0 || col >= ALL_KEYS.length) { return; }
var key = ALL_KEYS[col];
var isFileCol = FILE_COLS.indexOf(key) >= 0;
var isImageCol = IMAGE_COLS.indexOf(key) >= 0;
if (!isFileCol && !isImageCol) { return; }
var v = null;
try { v = sheet.getRange(row, col).getDisplayValue(); } catch (e) {}
if (!v || typeof v !== 'string') { return; }
var arr = null;
try { arr = JSON.parse(v); } catch (e) { arr = null; }
if (!Array.isArray(arr) || arr.length === 0) {
// 单元格直接存了 URL 文本(插入附件写入的「名称 + URL」
var mUrl = /https?:\/\/[^\s]+/.exec(v);
if (mUrl) { arr = [{ url: mUrl[0], name: v.replace(mUrl[0], '').trim() || '附件' }]; }
else { return; }
}
var item = arr[0];
var url = item && item.url;
if (!url) { return; }
var name = item.name || '附件';
if (isExt(url, ['pdf'])) {
// PDF 弹窗预览
if (window.__onebotOpenPdf) { window.__onebotOpenPdf(url, name); }
else { window.open(url, '_blank'); }
} else if (isImageCol || /^image\//i.test(item.mime || '') || isExt(url, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'])) {
// 图片:新窗口放大查看
window.open(url, '_blank');
} else {
// 其它附件:触发下载
var a = document.createElement('a');
a.href = url;
a.download = name || '';
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}
// ---- Teable 源管理 ----
jQuery('#btnSourceManager').on('click', function () { jQuery('#sourceManagerModal').show(); });
jQuery('#btnSourceManagerClose').on('click', function () { jQuery('#sourceManagerModal').hide(); });
jQuery('#btnAddSource').on('click', function () {
api('POST', BASE + '/bom/api/' + BOM_ID + '/source', {
name: jQuery('#srcName').val(),
baseUrl: jQuery('#srcBaseUrl').val(),
tableId: jQuery('#srcTableId').val(),
token: jQuery('#srcToken').val()
}, function () { location.reload(); });
});
jQuery('#sourceList').on('click', '.btnDelSource', function () {
if (!confirm("{{ __('text.confirm_delete') }}")) { return; }
api('DELETE', BASE + '/bom/api/source/' + jQuery(this).data('id'), null, function () { location.reload(); });
});
// ---- Teable 从数据源导入 ----
jQuery('#btnTeableImport').on('click', function () {
if (!confirm("{{ __('text.teable_import_confirm', '从 Teable 拉取最新数据并导入为明细?') }}")) { return; }
api('POST', BASE + '/bom/api/' + BOM_ID + '/import/teable', {}, function (res) {
showAlert("{{ __('text.imported', '已导入') }} " + (res.imported || 0) + " {{ __('label.rows', '行') }}", 'success');
setTimeout(function () { location.reload(); }, 800);
});
});
// ---- Teable API 粘贴导入 ----
jQuery('#btnTeablePaste').on('click', function () {
jQuery('#teablePasteText').val('');
jQuery('#pasteName').val(''); jQuery('#pasteBaseUrl').val(''); jQuery('#pasteTableId').val(''); jQuery('#pasteToken').val('');
jQuery('#teableParseResult').hide().empty();
jQuery('#teablePasteModal').show();
jQuery('#teablePasteText').trigger('focus');
});
jQuery('#btnTeablePasteClose').on('click', function () { jQuery('#teablePasteModal').hide(); });
jQuery('#btnTeableParse').on('click', function () {
var text = jQuery('#teablePasteText').val();
jQuery('#btnTeableParse').prop('disabled', true);
api('POST', BASE + '/bom/api/teable/parse', { text: text }, function (res) {
var d = res.data || {};
if (d.baseUrl) { jQuery('#pasteBaseUrl').val(d.baseUrl); }
if (d.tableId) { jQuery('#pasteTableId').val(d.tableId); }
if (d.token) { jQuery('#pasteToken').val(d.token); }
if (d.name) { jQuery('#pasteName').val(d.name); }
jQuery('#teableParseResult').html('<strong>解析完成</strong>,已回填到上方输入框,可手动修改后导入。').show();
}).always(function () { jQuery('#btnTeableParse').prop('disabled', false); });
});
jQuery('#btnTeablePasteGo').on('click', function () {
var baseUrl = jQuery('#pasteBaseUrl').val().trim();
var tableId = jQuery('#pasteTableId').val().trim();
var token = jQuery('#pasteToken').val().trim();
var name = jQuery('#pasteName').val().trim();
if (!baseUrl || !tableId || !token) { showAlert('请先点「解析」,或手动填写 Base URL / Table ID / Token', 'warning'); return; }
var text = jQuery('#teablePasteText').val() ||
('# Table: ' + (name || tableId) + '\n' +
'curl "' + baseUrl + '/api/table/' + tableId + '/record?fieldKeyType=name" \\\n' +
' -H "Authorization: Bearer ' + token + '"');
jQuery('#btnTeablePasteGo').prop('disabled', true);
api('POST', BASE + '/bom/api/' + BOM_ID + '/import/teable-paste', { text: text }, function (res) {
jQuery('#teablePasteModal').hide();
showAlert('已导入 ' + (res.imported || 0) + ' 行', 'success');
setTimeout(function () { location.reload(); }, 800);
}).always(function () { jQuery('#btnTeablePasteGo').prop('disabled', false); });
});
// ---- Excel ----
jQuery('#btnExcelTemplate').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export-template'; });
jQuery('#btnExcelImport').on('click', function () { jQuery('#excelFile').click(); });
jQuery('#excelFile').on('change', function () {
var file = this.files[0];
if (!file) { return; }
var fd = new FormData();
fd.append('file', file);
jQuery.ajax({
url: BASE + '/bom/api/' + BOM_ID + '/import/excel',
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
data: fd, processData: false, contentType: false, dataType: 'json'
}).done(function (res) {
showAlert("{{ __('text.imported', '已导入') }} " + (res.imported || 0) + " {{ __('label.rows', '行') }}", 'success');
setTimeout(function () { location.reload(); }, 800);
}).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
jQuery(this).val('');
});
jQuery('#btnExcelExport').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export?format=xlsx'; });
jQuery('#btnExcelExportCsv').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export?format=csv'; });
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,469 @@
@extends($layout)
@section('content')
@php
$bom = $detail['bom'] ?? [];
$columns = $detail['columns'] ?? [];
$items = $detail['items'] ?? [];
$sources = $detail['sources'] ?? [];
$hiddenColumns = $detail['hiddenColumns'] ?? [];
$filledKeys = $detail['filledKeys'] ?? [];
$imageColumns = $detail['imageColumns'] ?? [];
$fileColumns = $detail['fileColumns'] ?? [];
$bomId = (int)($bom['id'] ?? 0);
@endphp
<link rel="stylesheet" href="{{ BASE_URL }}/dist/handsontable/handsontable.full.min.css">
<div class="pageheader">
<div class="pageicon"><span class="fa fa-fw fa-list-check"></span></div>
<div class="pagetitle">
<h1>{{ $bom['productName'] ?? $bom['bomNo'] ?? __('menu.bom') }}</h1>
<p>{{ __('text.bom_subtitle', 'BOM 明细') }} — {{ $bom['bomNo'] ?? '' }}</p>
</div>
<div class="maincontent" style="margin-top:16px;">
<a href="{{ BASE_URL }}/bom/show" class="btn btn-default"><i class="fa fa-arrow-left"></i> {{ __('links.back', '返回') }}</a>
<button type="button" class="btn btn-primary" id="btnAddRow"><i class="fa fa-plus"></i> {{ __('label.add_row', '新增行') }}</button>
<button type="button" class="btn btn-default" id="btnAddColumn"><i class="fa fa-columns"></i> {{ __('label.add_column', '增加列') }}</button>
<button type="button" class="btn btn-default" id="btnColumnSettings"><i class="fa fa-eye"></i> 列设置</button>
{{-- Teable 导入下拉 --}}
<div class="btn-group" style="position:relative;display:inline-block;">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-cloud-download"></i> Teable 导入 <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="javascript:void(0)" id="btnTeableImport"><i class="fa fa-refresh"></i> 从数据源导入</a></li>
<li><a href="javascript:void(0)" id="btnTeablePaste"><i class="fa fa-paste"></i> 粘贴 API 导入</a></li>
<li class="divider"></li>
<li><a href="javascript:void(0)" id="btnSourceManager"><i class="fa fa-database"></i> 数据源管理</a></li>
</ul>
</div>
{{-- Excel 下拉 --}}
<div class="btn-group" style="position:relative;display:inline-block;">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-file-excel"></i> Excel <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="javascript:void(0)" id="btnExcelTemplate"><i class="fa fa-download"></i> 模板导出</a></li>
<li><a href="javascript:void(0)" id="btnExcelImport"><i class="fa fa-upload"></i> 导入数据</a></li>
<li class="divider"></li>
<li><a href="javascript:void(0)" id="btnExcelExport"><i class="fa fa-file-excel-o"></i> 导出数据 (xlsx)</a></li>
<li><a href="javascript:void(0)" id="btnExcelExportCsv"><i class="fa fa-file"></i> 导出数据 (csv)</a></li>
</ul>
</div>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
{{-- Teable 源配置区已移至「数据源管理」弹窗 --}}
<div id="bomAlert" class="alert" style="display:none;"></div>
{{-- Handsontable 容器JS 动态铺满剩余视口) --}}
<div id="bomHot" style="width:100%;height:600px;overflow:hidden;"></div>
</div>
</div>
{{-- Teable API 粘贴导入弹窗 --}}
<div id="teablePasteModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.45);z-index:9999;overflow:auto;">
<div style="max-width:760px;margin:6% auto;background:#fff;border-radius:4px;padding:18px;">
<h4 style="margin-top:0;"><i class="fa fa-paste"></i> 粘贴 Teable API 文档</h4>
<p class="text-muted">直接粘贴整段 API 文档或 curl 命令,自动解析连接信息;解析不全时可手动补齐下方字段。</p>
<textarea id="teablePasteText" style="width:100%;height:180px;font-family:monospace;font-size:12px;" placeholder="# Table: UAGP350B&#10;&#10;curl -X GET &quot;https://table.universal-onebot.com/api/table/tblXXX/record?fieldKeyType=name&quot; \&#10; -H &quot;Authorization: Bearer teable_...&quot;"></textarea>
<div style="margin-top:10px;">
<div class="row">
<div class="col-md-12"><label style="font-size:12px;">表名/别名</label><input type="text" id="pasteName" class="form-control"></div>
</div>
<div class="row" style="margin-top:6px;">
<div class="col-md-12"><label style="font-size:12px;">Base URL</label><input type="text" id="pasteBaseUrl" class="form-control" placeholder="https://table.universal-onebot.com"></div>
</div>
<div class="row" style="margin-top:6px;">
<div class="col-md-5"><label style="font-size:12px;">Table ID</label><input type="text" id="pasteTableId" class="form-control" placeholder="tbl..."></div>
<div class="col-md-7"><label style="font-size:12px;">Token</label><input type="text" id="pasteToken" class="form-control" placeholder="teable_acc..."></div>
</div>
</div>
<div id="teableParseResult" style="display:none;margin:10px 0;padding:10px;background:#f5f5f5;border:1px solid #ddd;border-radius:4px;font-size:13px;"></div>
<div style="text-align:right;margin-top:12px;">
<button type="button" class="btn btn-default" id="btnTeableParse"><i class="fa fa-search"></i> 解析</button>
<button type="button" class="btn btn-primary" id="btnTeablePasteGo"><i class="fa fa-cloud-download"></i> 导入</button>
<button type="button" class="btn btn-default" id="btnTeablePasteClose">取消</button>
</div>
</div>
</div>
{{-- 列设置弹窗 --}}
<div id="columnSettingsModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.45);z-index:9999;overflow:auto;">
<div style="max-width:520px;margin:6% auto;background:#fff;border-radius:4px;padding:18px;">
<h4 style="margin-top:0;"><i class="fa fa-eye"></i> 列设置</h4>
<p class="text-muted">勾选需要显示的列;未勾选的列将被隐藏。</p>
<div id="columnChecklist" style="max-height:360px;overflow-y:auto;border:1px solid #ddd;padding:10px;border-radius:4px;"></div>
<div style="margin-top:10px;">
<button type="button" class="btn btn-default btn-xs" id="btnHideEmpty"><i class="fa fa-eye-slash"></i> 一键隐藏空列</button>
<button type="button" class="btn btn-default btn-xs" id="btnShowAll"><i class="fa fa-eye"></i> 全部显示</button>
</div>
<div style="text-align:right;margin-top:12px;">
<button type="button" class="btn btn-primary" id="btnColumnSettingsSave">确定</button>
<button type="button" class="btn btn-default" id="btnColumnSettingsClose">取消</button>
</div>
</div>
</div>
{{-- 数据源管理弹窗 --}}
<div id="sourceManagerModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.45);z-index:9999;overflow:auto;">
<div style="max-width:640px;margin:6% auto;background:#fff;border-radius:4px;padding:18px;">
<h4 style="margin-top:0;"><i class="fa fa-database"></i> Teable 数据源管理</h4>
<div id="sourceList" style="margin-bottom:10px;">
@forelse ($sources as $s)
<span class="label label-info" style="margin-right:6px;">
{{ $s['name'] ?: $s['tableId'] }}
<a href="javascript:void(0)" class="btnDelSource" data-id="{{ $s['id'] }}" style="color:#fff;">&times;</a>
</span>
@empty
<em class="text-muted">{{ __('text.no_sources', '未配置数据源') }}</em>
@endforelse
</div>
<div class="row">
<div class="col-md-3"><input type="text" id="srcName" class="form-control" placeholder="{{ __('label.source_name', '表名/别名') }}"></div>
<div class="col-md-3"><input type="text" id="srcBaseUrl" class="form-control" placeholder="https://table.universal-onebot.com"></div>
<div class="col-md-2"><input type="text" id="srcTableId" class="form-control" placeholder="tbl..."></div>
<div class="col-md-3"><input type="text" id="srcToken" class="form-control" placeholder="teable_acc..."></div>
<div class="col-md-1"><button type="button" class="btn btn-default" id="btnAddSource">{{ __('links.add', '添加') }}</button></div>
</div>
<div style="text-align:right;margin-top:12px;">
<button type="button" class="btn btn-default" id="btnSourceManagerClose">关闭</button>
</div>
</div>
</div>
{{-- 隐藏文件输入 --}}
<input type="file" id="excelFile" accept=".xlsx,.csv" style="display:none;">
<script src="{{ BASE_URL }}/dist/handsontable/handsontable.full.min.js"></script>
@once
@push('scripts')
<script>
jQuery(document).ready(function () {
var BOM_ID = {{ $bomId }};
var BASE = '{{ BASE_URL }}';
var FIXED = {!! json_encode(array_keys($fixedColumns)) !!};
var FIXED_LABELS = {!! json_encode($fixedColumns) !!};
var HIDDEN = {!! json_encode(array_values($hiddenColumns)) !!};
var FILLED = {!! json_encode(array_values($filledKeys)) !!};
var COLUMNS = {!! json_encode(array_map(function($c){ return ['key'=>$c['key'],'label'=>$c['label']]; }, $columns)) !!};
var IMAGE_COLS = {!! json_encode(array_values($imageColumns)) !!};
var FILE_COLS = {!! json_encode(array_values($fileColumns)) !!};
var RAW_ITEMS = {!! json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) !!};
var ALL_KEYS = FIXED.concat(COLUMNS.map(function (c) { return c.key; }));
var csrf = function () { return jQuery('meta[name="csrf-token"]').attr('content') || ''; };
function showAlert(msg, type) {
var el = jQuery('#bomAlert');
el.attr('class', 'alert alert-' + (type || 'info')).text(msg).show();
}
function api(method, url, data, cb) {
var opts = { url: url, method: method, headers: { 'X-CSRF-TOKEN': csrf() }, dataType: 'json' };
if (data !== undefined) { opts.contentType = 'application/json'; opts.data = JSON.stringify(data); }
return jQuery.ajax(opts).done(function (res) { cb && cb(res); }).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
}
// ---- 数据映射:后端 items -> Handsontable data ----
var data = RAW_ITEMS.map(function (it) {
var o = { _id: it.id || null };
ALL_KEYS.forEach(function (k) { o[k] = (it[k] === undefined || it[k] === null) ? '' : it[k]; });
return o;
});
// ---- renderer ----
function parseAttach(value) {
if (Array.isArray(value)) { return value; }
if (typeof value === 'string' && value.charAt(0) === '[') {
try { var a = JSON.parse(value); return Array.isArray(a) ? a : []; } catch (e) { return []; }
}
return [];
}
function imageRenderer(instance, td, row, col, prop, value) {
td.innerHTML = '';
td.style.verticalAlign = 'middle';
parseAttach(value).forEach(function (it) {
if (!it || !it.url) { return; }
var a = document.createElement('a');
a.href = it.url; a.target = '_blank'; a.title = it.name || '';
var img = document.createElement('img');
img.src = it.url; img.alt = it.name || '';
img.style.height = '42px'; img.style.width = 'auto'; img.style.margin = '2px'; img.style.borderRadius = '2px';
a.appendChild(img); td.appendChild(a);
});
return td;
}
function fileRenderer(instance, td, row, col, prop, value) {
td.innerHTML = '';
parseAttach(value).forEach(function (it) {
if (!it || !it.url) { return; }
var a = document.createElement('a');
a.href = it.url; a.target = '_blank'; a.textContent = it.name || '下载';
a.style.display = 'block'; a.style.margin = '2px 0';
td.appendChild(a);
});
return td;
}
function delRenderer(instance, td, row) {
td.innerHTML = '';
td.style.textAlign = 'center';
var btn = document.createElement('button');
btn.type = 'button'; btn.className = 'btn btn-xs btn-danger';
btn.textContent = '删';
btn.onclick = function () { deleteRow(row); };
td.appendChild(btn);
return td;
}
// ---- 列定义 ----
var colDefs = [];
FIXED.forEach(function (key) {
colDefs.push({ data: key, title: FIXED_LABELS[key] || key, width: 120 });
});
COLUMNS.forEach(function (col) {
colDefs.push({ data: col.key, title: col.label || col.key, width: 140 });
});
IMAGE_COLS.forEach(function (key) {
var d = colDefs.filter(function (c) { return c.data === key; })[0];
if (d) { d.renderer = imageRenderer; d.readOnly = true; d.width = 120; }
});
FILE_COLS.forEach(function (key) {
var d = colDefs.filter(function (c) { return c.data === key; })[0];
if (d) { d.renderer = fileRenderer; d.readOnly = true; d.width = 160; }
});
// 操作列
colDefs.push({ data: '_delete', title: '操作', width: 60, readOnly: true, renderer: delRenderer });
// 初始隐藏列索引
var hiddenIndexes = HIDDEN.map(function (k) { return ALL_KEYS.indexOf(k); }).filter(function (i) { return i >= 0; });
// ---- 初始化 Handsontable ----
var hotContainer = document.getElementById('bomHot');
function calcHeight() {
var top = jQuery(hotContainer).offset().top;
var winH = jQuery(window).height();
var h = winH - top - 24;
return h < 300 ? 300 : h;
}
var hot = new Handsontable(hotContainer, {
data: data,
columns: colDefs,
colHeaders: true,
rowHeaders: false,
width: '100%',
height: calcHeight(),
licenseKey: 'non-commercial-and-evaluation',
stretchH: 'all',
manualColumnResize: true,
manualRowResize: true,
filters: true,
dropdownMenu: true,
search: true,
contextMenu: ['row_above', 'row_below', 'remove_row', '---------', 'copy', 'cut', 'paste', '---------', 'undo', 'redo'],
copyPaste: true,
undo: true,
hiddenColumns: { columns: hiddenIndexes, indicators: true },
afterChange: function (changes, source) {
if (!changes || source === 'loadData' || source === 'updateData' || source === 'alter') { return; }
var rows = {};
changes.forEach(function (ch) {
if (ch[2] === ch[3]) { return; }
rows[ch[0]] = true;
});
Object.keys(rows).forEach(function (r) { saveRow(parseInt(r, 10)); });
}
});
// 表格铺满屏幕:窗口尺寸变化时重算高度
jQuery(window).on('resize', function () { hot.updateSettings({ height: calcHeight() }); });
// ---- 保存行 ----
function saveRow(row) {
var d = hot.getSourceDataAtRow(row);
if (!d) { return; }
var payload = {};
ALL_KEYS.forEach(function (k) { payload[k] = d[k] || ''; });
if (d._id) { payload.itemId = d._id; }
api('POST', BASE + '/bom/api/' + BOM_ID + '/item', payload, function (res) {
if (res.itemId && !d._id) { d._id = res.itemId; }
});
}
// ---- 删除行 ----
function deleteRow(row) {
if (!confirm("{{ __('text.confirm_delete') }}")) { return; }
var d = hot.getSourceDataAtRow(row);
if (!d._id) { hot.alter('remove_row', row); return; }
api('DELETE', BASE + '/bom/api/item/' + d._id, null, function () { hot.alter('remove_row', row); });
}
// ---- 新增行 ----
jQuery('#btnAddRow').on('click', function () {
var empty = { _id: null };
ALL_KEYS.forEach(function (k) { empty[k] = ''; });
data.push(empty);
hot.updateData(data);
hot.selectCell(data.length - 1, 0);
});
// ---- 列显隐 ----
function openColumnSettings() {
var html = '';
FIXED.forEach(function (key) {
html += '<label style="display:block;font-weight:normal;margin-bottom:4px;"><input type="checkbox" class="col-check" data-key="' + key + '"' + (HIDDEN.indexOf(key) === -1 ? ' checked' : '') + '> ' + (FIXED_LABELS[key] || key) + '</label>';
});
COLUMNS.forEach(function (col) {
html += '<label style="display:block;font-weight:normal;margin-bottom:4px;"><input type="checkbox" class="col-check" data-key="' + col.key + '"' + (HIDDEN.indexOf(col.key) === -1 ? ' checked' : '') + '> ' + (col.label || col.key) + '</label>';
});
jQuery('#columnChecklist').html(html || '<em class="text-muted">暂无列</em>');
jQuery('#columnSettingsModal').show();
}
jQuery('#btnColumnSettings').on('click', openColumnSettings);
jQuery('#btnColumnSettingsClose').on('click', function () { jQuery('#columnSettingsModal').hide(); });
jQuery('#btnHideEmpty').on('click', function () {
jQuery('#columnChecklist .col-check').each(function () {
var key = jQuery(this).data('key');
if (FILLED.indexOf(key) === -1) { jQuery(this).prop('checked', false); }
});
});
jQuery('#btnShowAll').on('click', function () {
jQuery('#columnChecklist .col-check').prop('checked', true);
});
jQuery('#btnColumnSettingsSave').on('click', function () {
var hiddenKeys = [];
jQuery('#columnChecklist .col-check').each(function () {
if (!jQuery(this).prop('checked')) { hiddenKeys.push(jQuery(this).data('key')); }
});
var idx = hiddenKeys.map(function (k) { return ALL_KEYS.indexOf(k); }).filter(function (i) { return i >= 0; });
hot.updateSettings({ hiddenColumns: { columns: idx, indicators: true } });
api('POST', BASE + '/bom/api/' + BOM_ID + '/column-visibility', { keys: hiddenKeys }, function () {
jQuery('#columnSettingsModal').hide();
HIDDEN = hiddenKeys;
});
});
// ---- 增加列 ----
jQuery('#btnAddColumn').on('click', function () {
var key = prompt("{{ __('label.column_key', '列标识(英文或中文,用于存储)') }}", "");
if (!key) { return; }
var label = prompt("{{ __('label.column_label', '列显示名') }}", key);
if (label === null) { return; }
api('POST', BASE + '/bom/api/' + BOM_ID + '/column', { key: key, label: label }, function () { location.reload(); });
});
// ---- Teable 源管理(弹窗) ----
jQuery('#btnSourceManager').on('click', function () { jQuery('#sourceManagerModal').show(); });
jQuery('#btnSourceManagerClose').on('click', function () { jQuery('#sourceManagerModal').hide(); });
jQuery('#btnAddSource').on('click', function () {
api('POST', BASE + '/bom/api/' + BOM_ID + '/source', {
name: jQuery('#srcName').val(),
baseUrl: jQuery('#srcBaseUrl').val(),
tableId: jQuery('#srcTableId').val(),
token: jQuery('#srcToken').val()
}, function () { location.reload(); });
});
jQuery('#sourceList').on('click', '.btnDelSource', function () {
if (!confirm("{{ __('text.confirm_delete') }}")) { return; }
api('DELETE', BASE + '/bom/api/source/' + jQuery(this).data('id'), null, function () { location.reload(); });
});
// ---- Teable 从数据源导入 ----
jQuery('#btnTeableImport').on('click', function () {
if (!confirm("{{ __('text.teable_import_confirm', '从 Teable 拉取最新数据并导入为明细?') }}")) { return; }
api('POST', BASE + '/bom/api/' + BOM_ID + '/import/teable', {}, function (res) {
showAlert("{{ __('text.imported', '已导入') }} " + (res.imported || 0) + " {{ __('label.rows', '行') }}", 'success');
setTimeout(function () { location.reload(); }, 800);
});
});
// ---- Teable API 粘贴导入 ----
jQuery('#btnTeablePaste').on('click', function () {
jQuery('#teablePasteText').val('');
jQuery('#pasteName').val(''); jQuery('#pasteBaseUrl').val(''); jQuery('#pasteTableId').val(''); jQuery('#pasteToken').val('');
jQuery('#teableParseResult').hide().empty();
jQuery('#teablePasteModal').show();
jQuery('#teablePasteText').trigger('focus');
});
jQuery('#btnTeablePasteClose').on('click', function () { jQuery('#teablePasteModal').hide(); });
jQuery('#btnTeableParse').on('click', function () {
var text = jQuery('#teablePasteText').val();
jQuery('#btnTeableParse').prop('disabled', true);
api('POST', BASE + '/bom/api/teable/parse', { text: text }, function (res) {
var d = res.data || {};
if (d.baseUrl) { jQuery('#pasteBaseUrl').val(d.baseUrl); }
if (d.tableId) { jQuery('#pasteTableId').val(d.tableId); }
if (d.token) { jQuery('#pasteToken').val(d.token); }
if (d.name) { jQuery('#pasteName').val(d.name); }
jQuery('#teableParseResult').html('<strong>解析完成</strong>,已回填到上方输入框,可手动修改后导入。').show();
}).always(function () { jQuery('#btnTeableParse').prop('disabled', false); });
});
jQuery('#btnTeablePasteGo').on('click', function () {
var baseUrl = jQuery('#pasteBaseUrl').val().trim();
var tableId = jQuery('#pasteTableId').val().trim();
var token = jQuery('#pasteToken').val().trim();
var name = jQuery('#pasteName').val().trim();
if (!baseUrl || !tableId || !token) { showAlert('请先点「解析」,或手动填写 Base URL / Table ID / Token', 'warning'); return; }
var text = jQuery('#teablePasteText').val() ||
('# Table: ' + (name || tableId) + '\n' +
'curl "' + baseUrl + '/api/table/' + tableId + '/record?fieldKeyType=name" \\\n' +
' -H "Authorization: Bearer ' + token + '"');
jQuery('#btnTeablePasteGo').prop('disabled', true);
api('POST', BASE + '/bom/api/' + BOM_ID + '/import/teable-paste', { text: text }, function (res) {
jQuery('#teablePasteModal').hide();
showAlert('已导入 ' + (res.imported || 0) + ' 行', 'success');
setTimeout(function () { location.reload(); }, 800);
}).always(function () { jQuery('#btnTeablePasteGo').prop('disabled', false); });
});
// ---- Excel ----
jQuery('#btnExcelTemplate').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export-template'; });
jQuery('#btnExcelImport').on('click', function () { jQuery('#excelFile').click(); });
jQuery('#excelFile').on('change', function () {
var file = this.files[0];
if (!file) { return; }
var fd = new FormData();
fd.append('file', file);
jQuery.ajax({
url: BASE + '/bom/api/' + BOM_ID + '/import/excel',
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
data: fd, processData: false, contentType: false, dataType: 'json'
}).done(function (res) {
showAlert("{{ __('text.imported', '已导入') }} " + (res.imported || 0) + " {{ __('label.rows', '行') }}", 'success');
setTimeout(function () { location.reload(); }, 800);
}).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
jQuery(this).val('');
});
jQuery('#btnExcelExport').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export?format=xlsx'; });
jQuery('#btnExcelExportCsv').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export?format=csv'; });
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,648 @@
@extends($layout)
@section('content')
@php
$bom = $detail['bom'] ?? [];
$columns = $detail['columns'] ?? [];
$items = $detail['items'] ?? [];
$sources = $detail['sources'] ?? [];
$hiddenColumns = $detail['hiddenColumns'] ?? [];
$filledKeys = $detail['filledKeys'] ?? [];
$imageColumns = $detail['imageColumns'] ?? [];
$fileColumns = $detail['fileColumns'] ?? [];
$bomId = (int)($bom['id'] ?? 0);
@endphp
<link rel="stylesheet" href="{{ BASE_URL }}/dist/spreadjs/gc.spread.sheets.excel2013white.12.0.0.css">
<div class="pageheader" style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<div style="display:flex; align-items:center; gap:10px;">
<div class="pageicon"><span class="fa fa-fw fa-list-check"></span></div>
<div class="pagetitle" style="margin:0;">
<h1 style="margin:0;">{{ $bom['productName'] ?? $bom['bomNo'] ?? __('menu.bom') }}</h1>
<p style="margin:0;">{{ __('text.bom_subtitle', 'BOM 明细') }} — {{ $bom['bomNo'] ?? '' }}</p>
</div>
</div>
<div style="white-space:nowrap;">
<button type="button" class="btn btn-primary" id="btnEdit"><i class="fa fa-pencil"></i> 编辑</button>
<button type="button" class="btn btn-success" id="btnSave"><i class="fa fa-save"></i> 保存</button>
{{-- 一级菜单:导入导出 --}}
<div class="btn-group" style="position:relative;display:inline-block;">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-exchange"></i> 导入导出 <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li class="dropdown-header"><i class="fa fa-cloud-download"></i> Teable 导入</li>
<li><a href="javascript:void(0)" id="btnTeableImport"><i class="fa fa-refresh"></i> 从数据源导入</a></li>
<li><a href="javascript:void(0)" id="btnTeablePaste"><i class="fa fa-paste"></i> 粘贴 API 导入</a></li>
<li><a href="javascript:void(0)" id="btnSourceManager"><i class="fa fa-database"></i> 数据源管理</a></li>
<li class="divider"></li>
<li class="dropdown-header"><i class="fa fa-upload"></i> Excel 导入</li>
<li><a href="javascript:void(0)" id="btnExcelImport"><i class="fa fa-upload"></i> 导入数据</a></li>
<li><a href="javascript:void(0)" id="btnExcelTemplate"><i class="fa fa-download"></i> 模板导出</a></li>
<li class="divider"></li>
<li class="dropdown-header"><i class="fa fa-file-export"></i> 数据导出</li>
<li><a href="javascript:void(0)" id="btnExcelExport"><i class="fa fa-file-excel-o"></i> 导出数据 (xlsx)</a></li>
<li><a href="javascript:void(0)" id="btnExcelExportCsv"><i class="fa fa-file"></i> 导出数据 (csv)</a></li>
</ul>
</div>
<a href="{{ BASE_URL }}/bom/show" class="btn btn-default"><i class="fa fa-arrow-left"></i> {{ __('links.back', '返回') }}</a>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
<div id="bomAlert" class="alert" style="display:none;"></div>
<div id="bomError" class="alert alert-danger" style="display:none;"></div>
{{-- SpreadJS 容器 --}}
<div id="bomHot" style="width:100%;height:600px;border:1px solid #e5e5e5;"></div>
</div>
</div>
{{-- 附件页内预览弹窗(图片 <img> / PDF <iframe> / 其它下载链接) --}}
<div id="bomAttachModal" style="display:none;position:fixed;inset:0;background:rgba(15,23,42,0.66);z-index:99999;align-items:center;justify-content:center;padding:24px;">
<div style="position:relative;width:min(1100px,96vw);height:min(820px,92vh);background:#fff;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,0.4);display:flex;flex-direction:column;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid #e5e7eb;background:#f8fafc;flex:none;">
<span id="bomAttachTitle" style="font-size:14px;font-weight:600;color:#0f172a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">附件预览</span>
<a id="bomAttachClose" role="button" aria-label="Close" style="flex:none;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:pointer;color:#475569;font-size:18px;line-height:1;text-decoration:none;"><i class="fa fa-times"></i></a>
</div>
<div id="bomAttachBody" style="flex:1;width:100%;overflow:hidden;background:#fff;display:flex;flex-direction:column;"></div>
</div>
</div>
{{-- Teable API 粘贴导入弹窗 --}}
<div id="teablePasteModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.45);z-index:9999;overflow:auto;">
<div style="max-width:760px;margin:6% auto;background:#fff;border-radius:4px;padding:18px;">
<h4 style="margin-top:0;"><i class="fa fa-paste"></i> 粘贴 Teable API 文档</h4>
<p class="text-muted">直接粘贴整段 API 文档或 curl 命令,自动解析连接信息;解析不全时可手动补齐下方字段。</p>
<textarea id="teablePasteText" style="width:100%;height:180px;font-family:monospace;font-size:12px;" placeholder="# Table: UAGP350B&#10;&#10;curl -X GET &quot;https://table.universal-onebot.com/api/table/tblXXX/record?fieldKeyType=name&quot; \&#10; -H &quot;Authorization: Bearer teable_...&quot;"></textarea>
<div style="margin-top:10px;">
<div class="row">
<div class="col-md-12"><label style="font-size:12px;">表名/别名</label><input type="text" id="pasteName" class="form-control"></div>
</div>
<div class="row" style="margin-top:6px;">
<div class="col-md-12"><label style="font-size:12px;">Base URL</label><input type="text" id="pasteBaseUrl" class="form-control" placeholder="https://table.universal-onebot.com"></div>
</div>
<div class="row" style="margin-top:6px;">
<div class="col-md-5"><label style="font-size:12px;">Table ID</label><input type="text" id="pasteTableId" class="form-control" placeholder="tbl..."></div>
<div class="col-md-7"><label style="font-size:12px;">Token</label><input type="text" id="pasteToken" class="form-control" placeholder="teable_acc..."></div>
</div>
</div>
<div id="teableParseResult" style="display:none;margin:10px 0;padding:10px;background:#f5f5f5;border:1px solid #ddd;border-radius:4px;font-size:13px;"></div>
<div style="text-align:right;margin-top:12px;">
<button type="button" class="btn btn-default" id="btnTeableParse"><i class="fa fa-search"></i> 解析</button>
<button type="button" class="btn btn-primary" id="btnTeablePasteGo"><i class="fa fa-cloud-download"></i> 导入</button>
<button type="button" class="btn btn-default" id="btnTeablePasteClose">取消</button>
</div>
</div>
</div>
{{-- 数据源管理弹窗 --}}
<div id="sourceManagerModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.45);z-index:9999;overflow:auto;">
<div style="max-width:640px;margin:6% auto;background:#fff;border-radius:4px;padding:18px;">
<h4 style="margin-top:0;"><i class="fa fa-database"></i> Teable 数据源管理</h4>
<div id="sourceList" style="margin-bottom:10px;">
@forelse ($sources as $s)
<span class="label label-info" style="margin-right:6px;">
{{ $s['name'] ?: $s['tableId'] }}
<a href="javascript:void(0)" class="btnDelSource" data-id="{{ $s['id'] }}" style="color:#fff;">&times;</a>
</span>
@empty
<em class="text-muted">{{ __('text.no_sources', '未配置数据源') }}</em>
@endforelse
</div>
<div class="row">
<div class="col-md-3"><input type="text" id="srcName" class="form-control" placeholder="{{ __('label.source_name', '表名/别名') }}"></div>
<div class="col-md-3"><input type="text" id="srcBaseUrl" class="form-control" placeholder="https://table.universal-onebot.com"></div>
<div class="col-md-2"><input type="text" id="srcTableId" class="form-control" placeholder="tbl..."></div>
<div class="col-md-3"><input type="text" id="srcToken" class="form-control" placeholder="teable_acc..."></div>
<div class="col-md-1"><button type="button" class="btn btn-default" id="btnAddSource">{{ __('links.add', '添加') }}</button></div>
</div>
<div style="text-align:right;margin-top:12px;">
<button type="button" class="btn btn-default" id="btnSourceManagerClose">关闭</button>
</div>
</div>
</div>
{{-- 隐藏文件输入 --}}
<input type="file" id="excelFile" accept=".xlsx,.csv" style="display:none;">
<script src="{{ BASE_URL }}/dist/spreadjs/gc.spread.sheets.all.12.0.0.min.js"></script>
<script src="{{ BASE_URL }}/dist/spreadjs/gc.spread.excelio.12.0.0.min.js"></script>
@once
@push('scripts')
<script>
jQuery(document).ready(function () {
var BOM_ID = {{ $bomId }};
var BASE = '{{ BASE_URL }}';
var FIXED = {!! json_encode(array_keys($fixedColumns)) !!};
var FIXED_LABELS = {!! json_encode($fixedColumns) !!};
var HIDDEN = {!! json_encode(array_values($hiddenColumns)) !!};
var FILLED = {!! json_encode(array_values($filledKeys)) !!};
var COLUMNS = {!! json_encode(array_map(function($c){ return ['key'=>$c['key'],'label'=>$c['label']]; }, $columns)) !!};
var IMAGE_COLS = {!! json_encode(array_values($imageColumns)) !!};
var FILE_COLS = {!! json_encode(array_values($fileColumns)) !!};
var RAW_ITEMS = {!! json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) !!};
var ALL_KEYS = FIXED.concat(COLUMNS.map(function (c) { return c.key; }));
var DEL_COL = ALL_KEYS.length;
var csrf = function () { return jQuery('meta[name="csrf-token"]').attr('content') || ''; };
function showAlert(msg, type) {
var el = jQuery('#bomAlert');
el.attr('class', 'alert alert-' + (type || 'info')).text(msg).show();
}
function showError(msg) {
jQuery('#bomError').text(msg).show();
}
// ---- 附件页内预览弹窗 ----
var attachModal = {
$el: null,
$body: null,
$title: null,
close: null,
open: function (content, title) {
if (!attachModal.$el) {
attachModal.$el = jQuery('#bomAttachModal');
attachModal.$body = jQuery('#bomAttachBody');
attachModal.$title = jQuery('#bomAttachTitle');
}
attachModal.$body.empty().append(content);
attachModal.$title.text(title || '附件预览');
attachModal.$el.css('display', 'flex');
},
hide: function () {
if (attachModal.$el) { attachModal.$el.hide(); }
if (attachModal.$body) { attachModal.$body.empty(); }
}
};
jQuery(document).on('click', '#bomAttachClose', function () { attachModal.hide(); });
jQuery(document).on('click', '#bomAttachModal', function (e) {
if (e.target === this) { attachModal.hide(); }
});
jQuery(document).on('keydown', function (e) {
if (e.key === 'Escape' && attachModal.$el && attachModal.$el.is(':visible')) { attachModal.hide(); }
});
// 判断附件类型image -> 图片预览pdf -> iframe其它 -> 下载链接
function detectAttachKind(item) {
if (!item) { return 'file'; }
var name = (item.name || '').toLowerCase();
var url = (item.url || '').toLowerCase();
if (item.image === true) { return 'image'; }
if (/\.pdf($|\?|#)/.test(name) || /\.pdf($|\?|#)/.test(url)) { return 'pdf'; }
if (/\.(png|jpe?g|gif|webp|bmp|svg)($|\?|#)/.test(name)) { return 'image'; }
return 'file';
}
function openAttachPreview(item) {
var url = item.url || '';
var name = item.name || '';
if (!url) { return; }
var kind = detectAttachKind(item);
if (kind === 'image') {
var img = jQuery('<img>', { src: url, alt: name, style: 'max-width:100%;max-height:100%;object-fit:contain;margin:auto;padding:12px;' });
attachModal.open(img, name || '图片预览');
} else if (kind === 'pdf') {
var frame = jQuery('<iframe>', {
src: url,
title: name || 'PDF',
style: 'flex:1;width:100%;border:0;background:#fff;'
});
attachModal.open(frame, name || 'PDF 预览');
} else {
// 其它文件:弹窗内提供下载链接
var box = jQuery('<div>', { style: 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:16px;padding:24px;text-align:center;' });
var icon = jQuery('<div>', { style: 'font-size:48px;color:#94a3b8;' }).html('<i class="fa fa-file"></i>');
var nm = jQuery('<div>', { style: 'font-size:14px;color:#334155;word-break:break-all;max-width:80%;' }).text(name || url);
var dl = jQuery('<a>', {
href: url,
target: '_blank',
rel: 'noopener',
class: 'btn btn-primary',
style: 'display:inline-block;'
}).html('<i class="fa fa-download"></i> 下载 / 打开文件');
box.append(icon, nm, dl);
attachModal.open(box, name || '文件');
}
}
// 全局错误捕获:任何 JS 异常都在页面上显式显示,不再静默失效
window.onerror = function (msg, src, line) {
try { jQuery('#bomError').text('JS 错误: ' + msg + ' (第 ' + line + ' 行)').show(); } catch (e) {}
return false;
};
function api(method, url, data, cb) {
var opts = { url: url, method: method, headers: { 'X-CSRF-TOKEN': csrf() }, dataType: 'json' };
if (data !== undefined) { opts.contentType = 'application/json'; opts.data = JSON.stringify(data); }
return jQuery.ajax(opts).done(function (res) { cb && cb(res); }).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
}
// ---- 按 demo 模式license key 内联(去掉 license.js 引入) ----
try {
GC.Spread.Sheets.LicenseKey = "GrapeCity-Internal-Use-Only,*.grapecity.com|*.componentone.com,E347353251763156#B0MtLULRHcBd5ZrY5QrMXRalHW5Ejc75Wb58EV4E4LoV6aDJldEBjaZRGUXNXNhJjZ0plSyMnR8YHZI54a7oUMsFTNYVUMOhFOPx6UKtib9J7KZdzVs5GMxIlM5olVj3USyMHUVlEThtkdk5GdRB7dIhlV6MHZz3GNWdVbCdmZpt4NGtSYZV6Ywcme6kEdItydTVjUGF4Z4RmSZdUbQNkW93ybC3ySSdmd6onQ0dlYOFWanxWMwsWczdUMv2yRi3yQRtEcL9WT4N4VSRnNUlnbhF5UTFHMOV6YGJ7R42Ge5VjUuNnNoRkboNGZVJ5V58mdzYlc7g6cGRlI0IyUiwiIBJDRCZDOzEjI0ICSiwSOzcjM4ITOyEjM0IicfJye35XX3JSSGljQiojIDJCLiITMuYHITpEIkFWZyB7UiojIOJyebpjIkJHUiwiIyITOxATMgYTMwEDOxAjMiojI4J7QiwiIt36YuUmbvRnbl96bw56bj9iKs46bj9Se4l6YlBXYydmLqIiOiMXbEJCLikHdpNUZwFmcHJiOiEmTDJCLlVnc4pjIsZXRiwiI6UTMzYzNxUjMzUzM7QzMiojIklkIs4XZzxWYmpjIyNHZisnOiwmbBJye0ICRiwiI34zdI5kWS9WR79mdYJ7LRRENGNmZUp6TBN4LiJ4Sxd7Rr4UOyp7bIJzR0RFTzNHOlxmRBFGanV7SIVVWoNWbhVFS5QHcS96SvonNrEnWwklWZ9UQ9UTVQ3iSC5kYJhxe";
} catch (e) { showError('License 设置失败: ' + e.message); }
// ---- 附件解析/显示 ----
function parseAttach(value) {
if (Array.isArray(value)) { return value; }
if (typeof value === 'string' && value.charAt(0) === '[') {
try { var a = JSON.parse(value); return Array.isArray(a) ? a : []; } catch (e) { return []; }
}
return [];
}
// 图片缓存url -> Image异步加载后触发重绘
var attachCache = {};
// 图片列 cellType画缩略图加载失败/未就绪时显示文件名
function ImageCellType() {}
try { ImageCellType.prototype = new GC.Spread.Sheets.CellTypes.Base(); } catch (e) {}
ImageCellType.prototype.paint = function (ctx, value, x, y, w, h, style, context) {
var arr = parseAttach(value);
var first = arr.length > 0 ? arr[0] : null;
if (first && first.url) {
var img = attachCache[first.url];
if (!img) {
img = new Image();
img.onload = function () { try { if (spread) { spread.repaint(); } } catch (e) {} };
img.onerror = function () {};
img.src = first.url;
attachCache[first.url] = img;
}
if (img.complete && img.naturalWidth > 0) {
try {
var scale = (h - 4) / img.naturalHeight;
var dw = img.naturalWidth * scale;
var dh = h - 4;
ctx.drawImage(img, x + 2, y + 2, dw, dh);
return;
} catch (e) {}
}
}
// 兜底:画文本(不依赖 Base.prototype.paint
ctx.save();
ctx.fillStyle = '#333';
ctx.textBaseline = 'middle';
try { ctx.font = (style && style.font) ? style.font : '12px Arial'; } catch (e) {}
ctx.fillText(first ? (first.name || '') : (value || ''), x + 2, y + h / 2);
ctx.restore();
};
// 文件列 cellType显示可点击的文件名蓝色链接样式
function LinkCellType() {}
try { LinkCellType.prototype = new GC.Spread.Sheets.CellTypes.Base(); } catch (e) {}
LinkCellType.prototype.paint = function (ctx, value, x, y, w, h, style, context) {
var arr = parseAttach(value);
var names = arr.map(function (it) { return it && it.name ? it.name : ''; }).filter(Boolean).join(', ');
ctx.save();
ctx.fillStyle = '#1a73e8';
ctx.textBaseline = 'middle';
try { ctx.font = (style && style.font) ? style.font : '12px Arial'; } catch (e) {}
ctx.fillText(names || (value || ''), x + 2, y + h / 2);
ctx.restore();
};
// ---- 数据 ----
var data = RAW_ITEMS.map(function (it) {
var o = { _id: it.id || null };
ALL_KEYS.forEach(function (k) { o[k] = (it[k] === undefined || it[k] === null) ? '' : it[k]; });
return o;
});
// ---- 初始化 SpreadJS ----
var host = document.getElementById('bomHot');
function calcHeight() {
var rect = host.getBoundingClientRect();
var h = window.innerHeight - rect.top - 20;
return h < 500 ? 500 : h;
}
// SpreadJS 需要明确的 px 宽高:宽度从父容器读取,否则 100% 未解析会导致 canvas 宽度为 0显示不全
function setHostSize() {
var pw = host.parentElement ? host.parentElement.clientWidth : 0;
if (pw > 0) { host.style.width = pw + 'px'; }
host.style.height = calcHeight() + 'px';
}
setHostSize();
var spread;
try {
spread = new GC.Spread.Sheets.Workbook(host, { sheetCount: 1 });
} catch (e) {
showError('SpreadJS 初始化失败: ' + e.message);
return;
}
var sheet = spread.getActiveSheet();
sheet.options.colHeaderVisible = true;
// 默认只读:用 editable 标志 + EditStarting 拦截(不用 isProtected避免其解锁不生效的坑
var editable = false;
// 横向/纵向滚动条显式开启,支持左右拖动阅读(列多时横向滚动)
// 注意12.0.0 无 scrollByPixel逐条 try 避免一个失败导致后续全部失效
try { spread.options.showHorizontalScrollbar = true; } catch (e) {}
try { spread.options.showVerticalScrollbar = true; } catch (e) {}
try { spread.options.scrollbarMaxAlign = true; } catch (e) {}
try { spread.options.scrollbarShowMax = true; } catch (e) {}
sheet.setColumnCount(ALL_KEYS.length + 1);
sheet.setRowCount(Math.max(data.length, 1));
// 表头
FIXED.forEach(function (key, i) { sheet.setValue(0, i, FIXED_LABELS[key] || key, GC.Spread.Sheets.SheetArea.colHeader); });
COLUMNS.forEach(function (col, i) { sheet.setValue(0, FIXED.length + i, col.label || col.key, GC.Spread.Sheets.SheetArea.colHeader); });
sheet.setValue(0, DEL_COL, '操作', GC.Spread.Sheets.SheetArea.colHeader);
// 列宽
FIXED.forEach(function (_, i) { sheet.setColumnWidth(i, 120); });
COLUMNS.forEach(function (_, i) { sheet.setColumnWidth(FIXED.length + i, 140); });
sheet.setColumnWidth(DEL_COL, 60);
// 删除按钮 cell type用对象配置避免链式调用报错
var delBtn;
try {
delBtn = new GC.Spread.Sheets.CellTypes.Button();
delBtn.text('删');
delBtn.buttonBackColor('#d9534f');
delBtn.textColor('#ffffff');
} catch (e) {
delBtn = null;
}
// ---- 加载数据 ----
var imageCellType = new ImageCellType();
var linkCellType = new LinkCellType();
var loading = false; // setArray 会触发 CellChanged加载期间禁止保存
function loadData() {
try {
loading = true;
// 数据区保留原始 JSON附件列由 cellType 渲染图片/链接
var arr = data.map(function (rowData) {
return ALL_KEYS.map(function (k) {
var v = rowData[k];
return (v === undefined || v === null) ? '' : v;
});
});
if (arr.length > 0) {
sheet.setRowCount(arr.length);
sheet.setArray(0, 0, arr);
}
// 图片列 -> ImageCellType文件列 -> LinkCellType
data.forEach(function (_, r) {
ALL_KEYS.forEach(function (k, c) {
if (IMAGE_COLS.indexOf(k) !== -1) { sheet.getCell(r, c).cellType(imageCellType); }
else if (FILE_COLS.indexOf(k) !== -1) { sheet.getCell(r, c).cellType(linkCellType); }
});
if (delBtn) { sheet.getCell(r, DEL_COL).cellType(delBtn); }
});
} catch (e) {
showError('loadData 异常: ' + e.message);
} finally {
loading = false;
}
}
loadData();
// ---- 列显隐 ----
function applyHidden() {
ALL_KEYS.forEach(function (k, c) {
if (HIDDEN.indexOf(k) !== -1) { sheet.hideColumn(c); } else { sheet.showColumn(c); }
});
}
applyHidden();
// ---- 强制刷新resize 是官方重算布局并重绘整个工作簿的关键方法,含数据区 canvas ----
function refreshSpread() {
try { spread.resize(); } catch (e) {}
try { spread.invalidateLayout(); } catch (e) {}
try { spread.repaint(); } catch (e) {}
try { spread.refresh(); } catch (e) {}
}
// rAF 在浏览器完成布局后触发,比 setTimeout 可靠;连续多帧确保列头渲染
requestAnimationFrame(function () {
setHostSize();
refreshSpread();
requestAnimationFrame(refreshSpread);
});
setTimeout(refreshSpread, 100);
setTimeout(refreshSpread, 300);
jQuery(window).on('load', function () {
setHostSize();
refreshSpread();
});
jQuery(window).on('resize', function () {
setHostSize();
refreshSpread();
});
// 容器尺寸变化时自动重绘(最可靠:任何布局抖动都会触发)
if (typeof ResizeObserver !== 'undefined') {
var ro = new ResizeObserver(function () {
refreshSpread();
});
ro.observe(host);
}
// ---- 保存行 ----
function getRowData(row) {
var payload = {};
ALL_KEYS.forEach(function (k, c) {
var v = sheet.getValue(row, c);
payload[k] = (v === undefined || v === null) ? '' : v;
});
return payload;
}
function saveRow(row) {
if (row < 0 || row >= data.length) { return; }
var payload = getRowData(row);
if (data[row]._id) { payload.itemId = data[row]._id; }
api('POST', BASE + '/bom/api/' + BOM_ID + '/item', payload, function (res) {
if (res.itemId && !data[row]._id) { data[row]._id = res.itemId; }
});
}
// ---- 编辑 / 保存 按钮:默认只读防误操作 ----
jQuery('#btnEdit').on('click', function () {
editable = true; // 解锁
showAlert('表格已解锁,可编辑数据;完成后点「保存」提交', 'info');
});
jQuery('#btnSave').on('click', function () {
var saved = 0;
// 遍历所有行,对比 sheet 当前值与原始数据,有差异才保存
data.forEach(function (rowData, r) {
var changed = false;
ALL_KEYS.forEach(function (k, c) {
var v = sheet.getValue(r, c);
var sv = (v === undefined || v === null) ? '' : String(v);
var orig = (rowData[k] === undefined || rowData[k] === null) ? '' : String(rowData[k]);
if (sv !== orig) { changed = true; }
});
if (changed) { saveRow(r); saved++; }
});
editable = false; // 重新锁定
if (saved > 0) {
// 保存后刷新本地原始数据,避免下次重复提交
data.forEach(function (rowData, r) {
ALL_KEYS.forEach(function (k, c) {
var v = sheet.getValue(r, c);
rowData[k] = (v === undefined || v === null) ? '' : v;
});
});
showAlert('已保存 ' + saved + ' 行,表格已重新锁定(只读)', 'success');
} else {
showAlert('没有需要保存的改动,表格已重新锁定(只读)', 'info');
}
});
// 拦截编辑未解锁时禁止进入编辑态args.cancel 才是正确参数)
try {
spread.bind(GC.Spread.Sheets.Events.EditStarting, function (sender, args) {
if (!editable) {
showAlert('表格当前为只读,请先点「编辑」按钮', 'warning');
args.cancel = true;
}
});
} catch (e) {}
// 删除按钮
spread.bind(GC.Spread.Sheets.Events.ButtonClicked, function (e, args) {
if (args.col === DEL_COL && args.row >= 0) { deleteRow(args.row); }
});
// 附件列(图片/文件)点击 → 页内弹窗预览(图片 <img>、PDF <iframe>、其它下载链接)
spread.bind(GC.Spread.Sheets.Events.CellClick, function (e, info) {
if (info.sheetArea !== GC.Spread.Sheets.SheetArea.viewport) { return; }
var key = ALL_KEYS[info.col];
if (IMAGE_COLS.indexOf(key) === -1 && FILE_COLS.indexOf(key) === -1) { return; }
var val = sheet.getValue(info.row, info.col);
var arr = parseAttach(val);
if (!arr || arr.length === 0) { return; }
// 多附件:只点文件名打开第一个;如需逐一查看可后续扩展成列表
var first = arr[0];
if (first && first.url) {
openAttachPreview(first);
}
});
function deleteRow(row) {
if (!confirm("{{ __('text.confirm_delete') }}")) { return; }
if (row < 0 || row >= data.length) { return; }
var d = data[row];
if (!d._id) {
sheet.deleteRows(row, 1);
data.splice(row, 1);
return;
}
api('DELETE', BASE + '/bom/api/item/' + d._id, null, function () {
sheet.deleteRows(row, 1);
data.splice(row, 1);
});
}
// ---- Teable 源管理 ----
jQuery('#btnSourceManager').on('click', function () { jQuery('#sourceManagerModal').show(); });
jQuery('#btnSourceManagerClose').on('click', function () { jQuery('#sourceManagerModal').hide(); });
jQuery('#btnAddSource').on('click', function () {
api('POST', BASE + '/bom/api/' + BOM_ID + '/source', {
name: jQuery('#srcName').val(),
baseUrl: jQuery('#srcBaseUrl').val(),
tableId: jQuery('#srcTableId').val(),
token: jQuery('#srcToken').val()
}, function () { location.reload(); });
});
jQuery('#sourceList').on('click', '.btnDelSource', function () {
if (!confirm("{{ __('text.confirm_delete') }}")) { return; }
api('DELETE', BASE + '/bom/api/source/' + jQuery(this).data('id'), null, function () { location.reload(); });
});
// ---- Teable 从数据源导入 ----
jQuery('#btnTeableImport').on('click', function () {
if (!confirm("{{ __('text.teable_import_confirm', '从 Teable 拉取最新数据并导入为明细?') }}")) { return; }
api('POST', BASE + '/bom/api/' + BOM_ID + '/import/teable', {}, function (res) {
showAlert("{{ __('text.imported', '已导入') }} " + (res.imported || 0) + " {{ __('label.rows', '行') }}", 'success');
setTimeout(function () { location.reload(); }, 800);
});
});
// ---- Teable API 粘贴导入 ----
jQuery('#btnTeablePaste').on('click', function () {
jQuery('#teablePasteText').val('');
jQuery('#pasteName').val(''); jQuery('#pasteBaseUrl').val(''); jQuery('#pasteTableId').val(''); jQuery('#pasteToken').val('');
jQuery('#teableParseResult').hide().empty();
jQuery('#teablePasteModal').show();
jQuery('#teablePasteText').trigger('focus');
});
jQuery('#btnTeablePasteClose').on('click', function () { jQuery('#teablePasteModal').hide(); });
jQuery('#btnTeableParse').on('click', function () {
var text = jQuery('#teablePasteText').val();
jQuery('#btnTeableParse').prop('disabled', true);
api('POST', BASE + '/bom/api/teable/parse', { text: text }, function (res) {
var d = res.data || {};
if (d.baseUrl) { jQuery('#pasteBaseUrl').val(d.baseUrl); }
if (d.tableId) { jQuery('#pasteTableId').val(d.tableId); }
if (d.token) { jQuery('#pasteToken').val(d.token); }
if (d.name) { jQuery('#pasteName').val(d.name); }
jQuery('#teableParseResult').html('<strong>解析完成</strong>,已回填到上方输入框,可手动修改后导入。').show();
}).always(function () { jQuery('#btnTeableParse').prop('disabled', false); });
});
jQuery('#btnTeablePasteGo').on('click', function () {
var baseUrl = jQuery('#pasteBaseUrl').val().trim();
var tableId = jQuery('#pasteTableId').val().trim();
var token = jQuery('#pasteToken').val().trim();
var name = jQuery('#pasteName').val().trim();
if (!baseUrl || !tableId || !token) { showAlert('请先点「解析」,或手动填写 Base URL / Table ID / Token', 'warning'); return; }
var text = jQuery('#teablePasteText').val() ||
('# Table: ' + (name || tableId) + '\n' +
'curl "' + baseUrl + '/api/table/' + tableId + '/record?fieldKeyType=name" \\\n' +
' -H "Authorization: Bearer ' + token + '"');
jQuery('#btnTeablePasteGo').prop('disabled', true);
api('POST', BASE + '/bom/api/' + BOM_ID + '/import/teable-paste', { text: text }, function (res) {
jQuery('#teablePasteModal').hide();
showAlert('已导入 ' + (res.imported || 0) + ' 行', 'success');
setTimeout(function () { location.reload(); }, 800);
}).always(function () { jQuery('#btnTeablePasteGo').prop('disabled', false); });
});
// ---- Excel ----
jQuery('#btnExcelTemplate').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export-template'; });
jQuery('#btnExcelImport').on('click', function () { jQuery('#excelFile').click(); });
jQuery('#excelFile').on('change', function () {
var file = this.files[0];
if (!file) { return; }
var fd = new FormData();
fd.append('file', file);
jQuery.ajax({
url: BASE + '/bom/api/' + BOM_ID + '/import/excel',
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
data: fd, processData: false, contentType: false, dataType: 'json'
}).done(function (res) {
showAlert("{{ __('text.imported', '已导入') }} " + (res.imported || 0) + " {{ __('label.rows', '行') }}", 'success');
setTimeout(function () { location.reload(); }, 800);
}).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
jQuery(this).val('');
});
jQuery('#btnExcelExport').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export?format=xlsx'; });
jQuery('#btnExcelExportCsv').on('click', function () { window.location.href = BASE + '/bom/api/' + BOM_ID + '/export?format=csv'; });
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,164 @@
@extends($layout)
@section('content')
@php
$m = $master ?? [];
$masterBom = $m['bom'] ?? [];
$masterColumns = $m['columns'] ?? [];
$masterItems = $m['items'] ?? [];
$imageColumns = $m['imageColumns'] ?? [];
$fileColumns = $m['fileColumns'] ?? [];
$refId = (int)($ref['id'] ?? 0);
$typeNames = ['bom'=>'BOM','process'=>'工艺文件','tooling'=>'工具清单'];
$typeName = $typeNames[$masterBom['type'] ?? 'bom'] ?? 'BOM';
@endphp
<div class="pageheader">
<div class="pageicon"><span class="fa fa-fw fa-link"></span></div>
<div class="pagetitle">
<h1>{{ $masterBom['productName'] ?? $masterBom['bomNo'] ?? $typeName }}</h1>
<p>引用视图 {{ $typeName }}(全局数据只读,可追加项目级列)</p>
</div>
<div class="maincontent" style="margin-top:16px;">
<a href="{{ BASE_URL }}/bom/refs" class="btn btn-default"><i class="fa fa-arrow-left"></i> 返回引用列表</a>
<button type="button" class="btn btn-primary" id="btnAddRefColumn"><i class="fa fa-columns"></i> 增加项目列</button>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
<div id="refAlert" class="alert" style="display:none;"></div>
<div style="overflow-x:auto;max-width:100%;border:1px solid #e5e5e5;border-radius:4px;">
<table class="table table-bordered table-striped" id="refTable" style="margin-bottom:0;white-space:nowrap;">
<thead>
<tr>
@foreach ($fixedColumns as $key => $label)
<th data-key="{{ $key }}">{{ $label }}</th>
@endforeach
@foreach ($masterColumns as $col)
<th data-key="{{ $col['key'] }}">{{ $col['label'] ?: $col['key'] }}</th>
@endforeach
@foreach ($refColumns as $col)
<th data-ref-column-id="{{ $col['id'] }}" data-key="{{ $col['key'] }}">
{{ $col['label'] ?: $col['key'] }}
<a href="javascript:void(0)" class="btnDelRefColumn" data-id="{{ $col['id'] }}" title="删除列">&times;</a>
</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse ($masterItems as $item)
<tr data-item-id="{{ $item['id'] }}">
@foreach ($fixedColumns as $key => $label)
<td>{{ $item[$key] ?? '' }}</td>
@endforeach
@foreach ($masterColumns as $col)
<td>
@if (in_array($col['key'], $imageColumns, true))
@php $attach = json_decode((string)($item[$col['key']] ?? ''), true); @endphp
@if (is_array($attach))
@foreach ($attach as $a)
<a href="{{ $a['url'] ?? '#' }}" target="_blank"><img src="{{ $a['url'] ?? '' }}" style="height:42px;width:auto;margin:2px;border-radius:2px;"></a>
@endforeach
@endif
@elseif (in_array($col['key'], $fileColumns, true))
@php $attach = json_decode((string)($item[$col['key']] ?? ''), true); @endphp
@if (is_array($attach))
@foreach ($attach as $a)
<a href="{{ $a['url'] ?? '#' }}" target="_blank">{{ $a['name'] ?? '下载' }}</a><br>
@endforeach
@endif
@else
{{ $item[$col['key']] ?? '' }}
@endif
</td>
@endforeach
@foreach ($refColumns as $col)
@php
$cid = (int)$col['id'];
$val = $refValues[(int)$item['id']][$cid] ?? '';
@endphp
<td><input type="text" class="form-control refVal" data-column-id="{{ $cid }}" value="{{ $val }}"></td>
@endforeach
</tr>
@empty
<tr><td colspan="99" class="text-center text-muted">暂无明细</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@once
@push('scripts')
<script>
jQuery(document).ready(function () {
var REF_ID = {{ $refId }};
var BASE = '{{ BASE_URL }}';
var csrf = function () { return jQuery('meta[name="csrf-token"]').attr('content') || ''; };
function showAlert(msg, type) {
var el = jQuery('#refAlert');
el.attr('class', 'alert alert-' + (type || 'info')).text(msg).show();
}
// 保存项目级追加值blur 触发)
jQuery('#refTable').on('blur change', '.refVal', function () {
var tr = jQuery(this).closest('tr');
var itemId = tr.data('item-id');
var columnId = jQuery(this).data('column-id');
var value = jQuery(this).val();
jQuery.ajax({
url: BASE + '/bom/api/ref/' + REF_ID + '/value',
method: 'POST',
contentType: 'application/json',
headers: { 'X-CSRF-TOKEN': csrf() },
data: JSON.stringify({ columnId: columnId, itemId: itemId, value: value }),
dataType: 'json'
}).fail(function (xhr) {
var m = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : ('HTTP ' + xhr.status);
showAlert(m, 'danger');
});
});
// 增加项目级列
jQuery('#btnAddRefColumn').on('click', function () {
var key = prompt('项目列标识(如:日期、库存、采购数量)', '');
if (!key) { return; }
var label = prompt('列显示名', key);
if (label === null) { return; }
jQuery.ajax({
url: BASE + '/bom/api/ref/' + REF_ID + '/column',
method: 'POST',
contentType: 'application/json',
headers: { 'X-CSRF-TOKEN': csrf() },
data: JSON.stringify({ key: key, label: label }),
dataType: 'json',
success: function () { location.reload(); },
error: function (xhr) {
alert(xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : '失败');
}
});
});
// 删除项目级列
jQuery('#refTable').on('click', '.btnDelRefColumn', function () {
if (!confirm('确定删除该项目级列?相关数据将删除,不影响全局主数据。')) { return; }
var id = jQuery(this).data('id');
jQuery.ajax({
url: BASE + '/bom/api/ref-column/' + id,
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrf() },
success: function () { location.reload(); },
error: function () { alert('删除失败'); }
});
});
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,80 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa fa-fw fa-link"></span></div>
<div class="pagetitle">
<h1>项目引用的主数据</h1>
<p>本项目已关联的全局主数据BOM / 工艺文件 / 工具清单),可在详情中追加项目级列</p>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
<div class="row" style="margin-bottom:16px;">
<div class="col-md-12">
<a href="{{ BASE_URL }}/bom/show" class="btn btn-primary"><i class="fa fa-plus"></i> 关联主数据(去主数据列表选择)</a>
</div>
</div>
@if (empty($refs))
<div class="alert alert-info">本项目尚未关联任何主数据。点击上方按钮前往主数据列表。</div>
@else
<div class="table-responsive">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>类型</th>
<th>编号</th>
<th>名称</th>
<th>规格型号</th>
<th>版本</th>
<th style="width:160px;">操作</th>
</tr>
</thead>
<tbody>
@foreach ($refs as $ref)
@php $m = $ref['master'] ?? []; $typeNames = ['bom'=>'BOM','process'=>'工艺文件','tooling'=>'工具清单']; @endphp
<tr>
<td><span class="label label-info">{{ $typeNames[$m['type'] ?? 'bom'] ?? 'BOM' }}</span></td>
<td>{{ $m['bomNo'] ?? '' }}</td>
<td>{{ $m['productName'] ?? '' }}</td>
<td>{{ $m['specification'] ?? '' }}</td>
<td>{{ $m['version'] ?? '' }}</td>
<td>
<a class="btn btn-xs btn-default" href="{{ BASE_URL }}/bom/refs/{{ $ref['id'] }}">打开</a>
<button class="btn btn-xs btn-danger btnUnlinkRef" data-id="{{ $ref['id'] }}">取消关联</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
@once
@push('scripts')
<script>
jQuery(document).ready(function () {
var csrf = function () { return jQuery('meta[name="csrf-token"]').attr('content') || ''; };
jQuery('.btnUnlinkRef').on('click', function () {
var id = jQuery(this).data('id');
if (!confirm('确定取消关联?项目级追加的列和数据将被删除。')) { return; }
jQuery.ajax({
url: '{{ BASE_URL }}/bom/api/ref/' + id,
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrf() },
success: function () { location.reload(); },
error: function () { alert('取消失败'); }
});
});
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,129 @@
@extends($layout)
@section('content')
@php
$type = $type ?? 'bom';
$typeNames = [
'bom' => 'BOM',
'process' => '工艺文件',
'tooling' => '工具清单',
];
$typeName = $typeNames[$type] ?? 'BOM';
@endphp
<div class="pageheader">
<div class="pageicon"><span class="fa fa-fw fa-list-check"></span></div>
<div class="pagetitle">
<h1>{{ $typeName }}</h1>
<p>{{ __('text.bom_subtitle', '全局主数据管理,可被项目引用') }}</p>
</div>
</div>
<div class="maincontent">
<div class="maincontentinner">
<div class="row" style="margin-bottom:16px;">
<div class="col-md-12">
<button type="button" class="btn btn-primary" id="btnNewBom">
<i class="fa fa-plus"></i> 新建 {{ $typeName }}
</button>
</div>
</div>
@if (empty($boms))
<div class="alert alert-info">暂无 {{ $typeName }},点击「新建 {{ $typeName }}」开始。</div>
@else
<div class="table-responsive">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>编号</th>
<th>名称</th>
<th>规格型号</th>
<th>图号</th>
<th>版本</th>
<th>类型</th>
<th style="width:160px;">操作</th>
</tr>
</thead>
<tbody>
@foreach ($boms as $bom)
<tr>
<td>{{ $bom['bomNo'] }}</td>
<td><a href="{{ BASE_URL }}/bom/show/{{ $bom['id'] }}">{{ $bom['productName'] }}</a></td>
<td>{{ $bom['specification'] }}</td>
<td>{{ $bom['drawingNo'] }}</td>
<td>{{ $bom['version'] }}</td>
<td>
<span class="label {{ (int)($bom['projectId'] ?? 0) === 0 ? 'label-success' : 'label-default' }}">
{{ (int)($bom['projectId'] ?? 0) === 0 ? '全局' : '项目私有' }}
</span>
</td>
<td>
<a class="btn btn-xs btn-default" href="{{ BASE_URL }}/bom/show/{{ $bom['id'] }}">打开</a>
<button class="btn btn-xs btn-danger btnDeleteBom" data-id="{{ $bom['id'] }}">删除</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
@once
@push('scripts')
<script>
jQuery(document).ready(function () {
var csrf = function () {
return jQuery('meta[name="csrf-token"]').attr('content') || '';
};
jQuery('#btnNewBom').on('click', function () {
var bomNo = prompt("编号", "");
if (bomNo === null) { return; }
var productName = prompt("名称", "");
if (productName === null) { return; }
jQuery.ajax({
url: '{{ BASE_URL }}/bom/api',
method: 'POST',
contentType: 'application/json',
headers: { 'X-CSRF-TOKEN': csrf() },
data: JSON.stringify({ bomNo: bomNo, productName: productName, type: '{{ $type }}', projectId: 0 }),
success: function (res) {
if (res.status === 'success') {
location.href = '{{ BASE_URL }}/bom/show/' + res.id;
} else {
alert(res.message || '创建失败');
}
},
error: function (xhr) {
alert('创建失败: ' + (xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : xhr.status));
}
});
});
jQuery('.btnDeleteBom').on('click', function () {
var id = jQuery(this).data('id');
if (!confirm("{{ __('text.confirm_delete') }}")) { return; }
jQuery.ajax({
url: '{{ BASE_URL }}/bom/api/' + id,
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrf() },
success: function (res) {
location.reload();
},
error: function (xhr) {
alert('删除失败');
}
});
});
});
</script>
@endpush
@endonce
@endsection