OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
283
app/Domain/Bom/Controllers/Api.php
Normal file
283
app/Domain/Bom/Controllers/Api.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Leantime\Domain\Bom\Services\Bom as BomService;
|
||||
use Leantime\Domain\Bom\Services\MasterRef as MasterRefService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* BOM JSON API(原生 Laravel 控制器)。每个动作都在 Service 层按 BOM 所属项目自鉴权。
|
||||
*/
|
||||
class Api
|
||||
{
|
||||
public function __construct(
|
||||
private BomService $bomService,
|
||||
private MasterRefService $masterRefService,
|
||||
) {}
|
||||
|
||||
public function detail(int $id): Response
|
||||
{
|
||||
$detail = $this->bomService->getBomDetail($id);
|
||||
|
||||
return $detail === false
|
||||
? response()->json(['status' => 'error', 'message' => 'BOM not found'], 404)
|
||||
: response()->json(['status' => 'success', 'data' => $detail]);
|
||||
}
|
||||
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$projectId = (int) $request->input('projectId', session('currentProject'));
|
||||
$id = $this->bomService->createBom($request->all() + ['projectId' => $projectId]);
|
||||
|
||||
return $id > 0
|
||||
? response()->json(['status' => 'success', 'id' => $id])
|
||||
: response()->json(['status' => 'error', 'message' => 'Create failed'], 500);
|
||||
}
|
||||
|
||||
public function update(int $id, Request $request): Response
|
||||
{
|
||||
return $this->bomService->updateBom($id, $request->all())
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Update failed'], 500);
|
||||
}
|
||||
|
||||
public function destroy(int $id): Response
|
||||
{
|
||||
return $this->bomService->deleteBom($id)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Delete failed'], 500);
|
||||
}
|
||||
|
||||
public function saveItem(int $id, Request $request): Response
|
||||
{
|
||||
$itemId = $request->input('itemId');
|
||||
$itemId = $itemId !== null && $itemId !== '' ? (int) $itemId : null;
|
||||
$newId = $this->bomService->saveItem($id, $request->all(), $itemId);
|
||||
|
||||
return $newId > 0
|
||||
? response()->json(['status' => 'success', 'itemId' => $newId])
|
||||
: response()->json(['status' => 'error', 'message' => 'Save item failed'], 500);
|
||||
}
|
||||
|
||||
public function deleteItem(int $itemId): Response
|
||||
{
|
||||
return $this->bomService->deleteItem($itemId)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Delete item failed'], 500);
|
||||
}
|
||||
|
||||
public function uploadAttachment(int $id, Request $request): Response
|
||||
{
|
||||
if (! $request->hasFile('file')) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Missing file'], 400);
|
||||
}
|
||||
|
||||
$result = $this->bomService->uploadAttachment($id, $_FILES);
|
||||
|
||||
if (is_string($result)) {
|
||||
return response()->json(['status' => 'error', 'message' => $result], 500);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success', 'data' => $result]);
|
||||
}
|
||||
|
||||
public function addColumn(int $id, Request $request): Response
|
||||
{
|
||||
$key = (string) $request->input('key', '');
|
||||
$label = (string) $request->input('label', $key);
|
||||
$columnId = $this->bomService->addColumn($id, $key, $label);
|
||||
|
||||
return $columnId > 0
|
||||
? response()->json(['status' => 'success', 'columnId' => $columnId])
|
||||
: response()->json(['status' => 'error', 'message' => 'Add column failed'], 500);
|
||||
}
|
||||
|
||||
public function deleteColumn(int $columnId): Response
|
||||
{
|
||||
return $this->bomService->deleteColumn($columnId)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Delete column failed'], 500);
|
||||
}
|
||||
|
||||
public function setHiddenColumns(int $id, Request $request): Response
|
||||
{
|
||||
$keys = $request->input('keys', []);
|
||||
$keys = is_array($keys) ? $keys : [];
|
||||
|
||||
return $this->bomService->setHiddenColumns($id, $keys)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Save column visibility failed'], 500);
|
||||
}
|
||||
|
||||
public function export(int $id, Request $request): Response
|
||||
{
|
||||
$format = strtolower((string) $request->input('format', 'xlsx'));
|
||||
$result = $this->bomService->export($id, $format);
|
||||
|
||||
if ($result === false) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Export failed'], 500);
|
||||
}
|
||||
|
||||
$content = file_get_contents($result['path']);
|
||||
@unlink($result['path']);
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => $result['contentType'],
|
||||
'Content-Disposition' => 'attachment; filename="'.$result['filename'].'"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportTemplate(int $id): Response
|
||||
{
|
||||
$result = $this->bomService->exportTemplate($id);
|
||||
|
||||
if ($result === false) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Export failed'], 500);
|
||||
}
|
||||
|
||||
$content = file_get_contents($result['path']);
|
||||
@unlink($result['path']);
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => $result['contentType'],
|
||||
'Content-Disposition' => 'attachment; filename="'.$result['filename'].'"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function addSource(int $id, Request $request): Response
|
||||
{
|
||||
$sourceId = $this->bomService->addSource($id, $request->all());
|
||||
|
||||
return $sourceId > 0
|
||||
? response()->json(['status' => 'success', 'sourceId' => $sourceId])
|
||||
: response()->json(['status' => 'error', 'message' => 'Add source failed'], 500);
|
||||
}
|
||||
|
||||
public function deleteSource(int $sourceId): Response
|
||||
{
|
||||
return $this->bomService->deleteSource($sourceId)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Delete source failed'], 500);
|
||||
}
|
||||
|
||||
public function parseTeable(Request $request): Response
|
||||
{
|
||||
$text = (string) $request->input('text', '');
|
||||
$result = $this->bomService->parseTeableText($text);
|
||||
|
||||
return $result['success']
|
||||
? response()->json(['status' => 'success', 'data' => $result])
|
||||
: response()->json(['status' => 'error', 'message' => $result['message'] ?? '解析失败'], 400);
|
||||
}
|
||||
|
||||
public function importTeablePaste(int $id, Request $request): Response
|
||||
{
|
||||
$text = (string) $request->input('text', '');
|
||||
$result = $this->bomService->importFromTeableText($id, $text);
|
||||
|
||||
return $result['success']
|
||||
? response()->json(['status' => 'success', 'imported' => $result['imported'] ?? 0, 'errors' => $result['errors'] ?? [], 'source' => $result['source'] ?? null])
|
||||
: response()->json(['status' => 'error', 'message' => $result['message'] ?? '导入失败'], 500);
|
||||
}
|
||||
|
||||
public function importTeable(int $id): Response
|
||||
{
|
||||
$result = $this->bomService->importFromTeable($id);
|
||||
|
||||
return $result['success']
|
||||
? response()->json(['status' => 'success', 'imported' => $result['imported'] ?? 0, 'errors' => $result['errors'] ?? []])
|
||||
: response()->json(['status' => 'error', 'message' => $result['message'] ?? 'Import failed'], 500);
|
||||
}
|
||||
|
||||
public function importExcel(int $id, Request $request): Response
|
||||
{
|
||||
if (! $request->hasFile('file')) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Missing file'], 400);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: 'xlsx');
|
||||
// tempnam 会创建一个无扩展名的空文件;我们拼接扩展名得到目标路径,
|
||||
// 目标文件本身不存在,直接写入内容即可(避免 move 到已存在路径的兼容问题)。
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bom.');
|
||||
$target = $tmp.'.'.$ext;
|
||||
|
||||
try {
|
||||
file_put_contents($target, $file->getContent());
|
||||
$result = $this->bomService->importFromExcel($id, $target);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($tmp);
|
||||
@unlink($target);
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
// 清理临时文件(tempnam 的空文件 + 带扩展名的数据文件)
|
||||
@unlink($tmp);
|
||||
@unlink($target);
|
||||
|
||||
return $result['success']
|
||||
? response()->json(['status' => 'success', 'imported' => $result['imported'] ?? 0])
|
||||
: response()->json(['status' => 'error', 'message' => $result['message'] ?? 'Import failed'], 500);
|
||||
}
|
||||
|
||||
// ---- 全局主数据:项目引用层(单向,不写回全局) ----
|
||||
|
||||
public function linkMaster(Request $request): Response
|
||||
{
|
||||
$projectId = (int) $request->input('projectId', session('currentProject'));
|
||||
$masterId = (int) $request->input('masterId', 0);
|
||||
$refId = $this->masterRefService->link($projectId, $masterId);
|
||||
|
||||
return $refId > 0
|
||||
? response()->json(['status' => 'success', 'refId' => $refId])
|
||||
: response()->json(['status' => 'error', 'message' => 'Link failed'], 500);
|
||||
}
|
||||
|
||||
public function unlinkMaster(int $refId): Response
|
||||
{
|
||||
return $this->masterRefService->unlink($refId)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Unlink failed'], 500);
|
||||
}
|
||||
|
||||
public function refDetail(int $refId): Response
|
||||
{
|
||||
$detail = $this->masterRefService->getRefDetail($refId);
|
||||
|
||||
return $detail === false
|
||||
? response()->json(['status' => 'error', 'message' => 'Ref not found'], 404)
|
||||
: response()->json(['status' => 'success', 'data' => $detail]);
|
||||
}
|
||||
|
||||
public function addRefColumn(int $refId, Request $request): Response
|
||||
{
|
||||
$key = (string) $request->input('key', '');
|
||||
$label = (string) $request->input('label', $key);
|
||||
$columnId = $this->masterRefService->addColumn($refId, $key, $label);
|
||||
|
||||
return $columnId > 0
|
||||
? response()->json(['status' => 'success', 'columnId' => $columnId])
|
||||
: response()->json(['status' => 'error', 'message' => 'Add column failed'], 500);
|
||||
}
|
||||
|
||||
public function deleteRefColumn(int $columnId): Response
|
||||
{
|
||||
return $this->masterRefService->deleteColumn($columnId)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Delete column failed'], 500);
|
||||
}
|
||||
|
||||
public function saveRefValue(int $refId, Request $request): Response
|
||||
{
|
||||
$columnId = (int) $request->input('columnId', 0);
|
||||
$itemId = (int) $request->input('itemId', 0);
|
||||
$value = (string) $request->input('value', '');
|
||||
|
||||
return $this->masterRefService->saveValue($refId, $columnId, $itemId, $value)
|
||||
? response()->json(['status' => 'success'])
|
||||
: response()->json(['status' => 'error', 'message' => 'Save value failed'], 500);
|
||||
}
|
||||
}
|
||||
74
app/Domain/Bom/Controllers/Refs.php
Normal file
74
app/Domain/Bom/Controllers/Refs.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Bom\Permissions\BomPermissions;
|
||||
use Leantime\Domain\Bom\Services\Bom as BomService;
|
||||
use Leantime\Domain\Bom\Services\MasterRef as MasterRefService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 项目引用视图:项目里查看已引用的全局主数据(BOM/工艺文件/工具清单),
|
||||
* 并叠加项目级追加列(日期/库存/采购数量等),追加列只存引用层,不回写全局。
|
||||
*
|
||||
* 路由约定(Frontcontroller 默认 /module/action):
|
||||
* GET /bom/refs 项目已引用的主数据列表
|
||||
* GET /bom/refs/{id} 单个引用视图(主数据 + 项目级追加列)
|
||||
*/
|
||||
class Refs extends Controller
|
||||
{
|
||||
private BomService $bomService;
|
||||
private MasterRefService $masterRefService;
|
||||
|
||||
public function init(BomService $bomService, MasterRefService $masterRefService): void
|
||||
{
|
||||
$this->bomService = $bomService;
|
||||
$this->masterRefService = $masterRefService;
|
||||
}
|
||||
|
||||
#[RequiresPermission(BomPermissions::VIEW)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$projectId = (int) session('currentProject');
|
||||
$refId = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
|
||||
// 单个引用视图
|
||||
if ($refId > 0) {
|
||||
$refDetail = $this->masterRefService->getRefDetail($refId);
|
||||
if ($refDetail === false) {
|
||||
$this->tpl->setNotification($this->language->__('notification.bom_not_found', '引用不存在'), 'error');
|
||||
|
||||
return $this->tpl->display('bom.refs', 'app', 404);
|
||||
}
|
||||
|
||||
$master = $this->bomService->getBomDetail((int) $refDetail['ref']['masterId']);
|
||||
|
||||
$this->tpl->assign('ref', $refDetail['ref']);
|
||||
$this->tpl->assign('refColumns', $refDetail['columns']);
|
||||
$this->tpl->assign('refValues', $refDetail['values']);
|
||||
$this->tpl->assign('master', $master);
|
||||
$this->tpl->assign('fixedColumns', BomService::FIXED_COLUMNS);
|
||||
$this->tpl->assign('projectId', $projectId);
|
||||
|
||||
return $this->tpl->display('bom.refDetail');
|
||||
}
|
||||
|
||||
// 项目已引用列表
|
||||
$refs = $this->masterRefService->getRefs($projectId);
|
||||
$list = [];
|
||||
foreach ($refs as $ref) {
|
||||
$master = $this->bomService->getBom((int) $ref['masterId']);
|
||||
if ($master !== false) {
|
||||
$ref['master'] = $master;
|
||||
$list[] = $ref;
|
||||
}
|
||||
}
|
||||
|
||||
$this->tpl->assign('refs', $list);
|
||||
$this->tpl->assign('projectId', $projectId);
|
||||
|
||||
return $this->tpl->display('bom.refs');
|
||||
}
|
||||
}
|
||||
60
app/Domain/Bom/Controllers/Show.php
Normal file
60
app/Domain/Bom/Controllers/Show.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Controllers;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\RequiresPermission;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Bom\Permissions\BomPermissions;
|
||||
use Leantime\Domain\Bom\Services\Bom as BomService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* BOM 页面(项目清单"目标及里程碑"下方入口)。
|
||||
* 约定路由:GET /bom/show 列表
|
||||
* GET /bom/show/{id} 详情(可编辑表格)
|
||||
*/
|
||||
class Show extends Controller
|
||||
{
|
||||
private BomService $bomService;
|
||||
|
||||
public function init(BomService $bomService): void
|
||||
{
|
||||
$this->bomService = $bomService;
|
||||
}
|
||||
|
||||
#[RequiresPermission(BomPermissions::VIEW)]
|
||||
public function get(array $params): Response
|
||||
{
|
||||
$projectId = (int) session('currentProject');
|
||||
$bomId = isset($params['id']) ? (int) $params['id'] : 0;
|
||||
// ?type=bom|process|tooling(query string,经 incomingRequest 读取)
|
||||
$type = (string) $this->incomingRequest->query('type', 'bom');
|
||||
if (! in_array($type, ['bom', 'process', 'tooling'], true)) {
|
||||
$type = 'bom';
|
||||
}
|
||||
|
||||
if ($bomId > 0) {
|
||||
$detail = $this->bomService->getBomDetail($bomId);
|
||||
if ($detail === false) {
|
||||
$this->tpl->setNotification($this->language->__('notification.bom_not_found', 'BOM 不存在'), 'error');
|
||||
|
||||
return $this->tpl->display('bom.show', 'app', 404);
|
||||
}
|
||||
|
||||
$this->tpl->assign('detail', $detail);
|
||||
$this->tpl->assign('fixedColumns', BomService::FIXED_COLUMNS);
|
||||
$this->tpl->assign('projectId', $projectId);
|
||||
|
||||
return $this->tpl->display('bom.detail');
|
||||
}
|
||||
|
||||
// 三类主数据(bom/process/tooling)共用列表,type 参数切换
|
||||
$masters = $this->bomService->getMasters($type, $projectId);
|
||||
$this->tpl->assign('boms', $masters);
|
||||
$this->tpl->assign('type', $type);
|
||||
$this->tpl->assign('projectId', $projectId);
|
||||
$this->tpl->assign('fixedColumns', BomService::FIXED_COLUMNS);
|
||||
|
||||
return $this->tpl->display('bom.show');
|
||||
}
|
||||
}
|
||||
35
app/Domain/Bom/Permissions/BomPermissions.php
Normal file
35
app/Domain/Bom/Permissions/BomPermissions.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
|
||||
|
||||
/**
|
||||
* BOM(物料清单)权限词汇。BOM 是项目作用域资源,所有能力按用户在所属项目中的角色评估。
|
||||
*/
|
||||
final class BomPermissions implements ProvidesPermissions
|
||||
{
|
||||
public const VIEW = 'bom.view';
|
||||
|
||||
public const CREATE = 'bom.create';
|
||||
|
||||
public const EDIT = 'bom.edit';
|
||||
|
||||
public const DELETE = 'bom.delete';
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return 'bom';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
new Permission(self::VIEW, 'View BOM'),
|
||||
new Permission(self::CREATE, 'Create BOM'),
|
||||
new Permission(self::EDIT, 'Edit BOM'),
|
||||
new Permission(self::DELETE, 'Delete BOM'),
|
||||
];
|
||||
}
|
||||
}
|
||||
172
app/Domain/Bom/Repositories/Bom.php
Normal file
172
app/Domain/Bom/Repositories/Bom.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
|
||||
class Bom
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
public function __construct(DbCore $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
// ---- BOM 头 ----
|
||||
|
||||
public function getBoms(int $projectId): array
|
||||
{
|
||||
$rows = $this->db->table('zp_bom')
|
||||
->where('projectId', $projectId)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型列出全局主数据(bom/process/tooling)。
|
||||
* 主数据是全局的:始终只返回 projectId=0 的全局主数据,跨项目完全一致。
|
||||
*/
|
||||
public function getMasters(string $type, int $projectId = 0): array
|
||||
{
|
||||
$q = $this->db->table('zp_bom')
|
||||
->where('projectId', 0);
|
||||
if ($type !== '') {
|
||||
$q->where('type', $type);
|
||||
}
|
||||
$rows = $q->orderByDesc('id')->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
public function getBom(int $id): array|false
|
||||
{
|
||||
$r = $this->db->table('zp_bom')->where('id', $id)->first();
|
||||
|
||||
return $r ? (array) $r : false;
|
||||
}
|
||||
|
||||
public function createBom(array $v): int
|
||||
{
|
||||
return (int) $this->db->table('zp_bom')->insertGetId($v);
|
||||
}
|
||||
|
||||
public function updateBom(int $id, array $v): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom')->where('id', $id)->update($v);
|
||||
}
|
||||
|
||||
public function updateHiddenColumns(int $bomId, string $json): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom')->where('id', $bomId)->update(['hiddenColumns' => $json]);
|
||||
}
|
||||
|
||||
public function deleteBom(int $id): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom')->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
// ---- Teable 源 ----
|
||||
|
||||
public function getSources(int $bomId): array
|
||||
{
|
||||
$rows = $this->db->table('zp_bom_source')->where('bomId', $bomId)->orderBy('id')->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
public function getSource(int $id): array|false
|
||||
{
|
||||
$r = $this->db->table('zp_bom_source')->where('id', $id)->first();
|
||||
|
||||
return $r ? (array) $r : false;
|
||||
}
|
||||
|
||||
public function addSource(array $v): int
|
||||
{
|
||||
return (int) $this->db->table('zp_bom_source')->insertGetId($v);
|
||||
}
|
||||
|
||||
public function deleteSource(int $id): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom_source')->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
// ---- 动态列 ----
|
||||
|
||||
public function getColumns(int $bomId): array
|
||||
{
|
||||
$rows = $this->db->table('zp_bom_column')
|
||||
->where('bomId', $bomId)
|
||||
->orderBy('sortOrder')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
public function getColumn(int $id): array|false
|
||||
{
|
||||
$r = $this->db->table('zp_bom_column')->where('id', $id)->first();
|
||||
|
||||
return $r ? (array) $r : false;
|
||||
}
|
||||
|
||||
public function addColumn(array $v): int
|
||||
{
|
||||
return (int) $this->db->table('zp_bom_column')->insertGetId($v);
|
||||
}
|
||||
|
||||
public function deleteColumn(int $id): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom_column')->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
public function deleteColumnsByBom(int $bomId): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom_column')->where('bomId', $bomId)->delete();
|
||||
}
|
||||
|
||||
// ---- 明细 ----
|
||||
|
||||
public function getItems(int $bomId): array
|
||||
{
|
||||
$rows = $this->db->table('zp_bom_item')
|
||||
->where('bomId', $bomId)
|
||||
->orderBy('seq')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
public function getItem(int $id): array|false
|
||||
{
|
||||
$r = $this->db->table('zp_bom_item')->where('id', $id)->first();
|
||||
|
||||
return $r ? (array) $r : false;
|
||||
}
|
||||
|
||||
public function createItem(array $v): int
|
||||
{
|
||||
return (int) $this->db->table('zp_bom_item')->insertGetId($v);
|
||||
}
|
||||
|
||||
public function updateItem(int $id, array $v): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom_item')->where('id', $id)->update($v);
|
||||
}
|
||||
|
||||
public function deleteItem(int $id): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom_item')->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
public function deleteItemsByBom(int $bomId): bool
|
||||
{
|
||||
return (bool) $this->db->table('zp_bom_item')->where('bomId', $bomId)->delete();
|
||||
}
|
||||
}
|
||||
135
app/Domain/Bom/Repositories/MasterRef.php
Normal file
135
app/Domain/Bom/Repositories/MasterRef.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
|
||||
/**
|
||||
* 项目 ↔ 全局主数据(BOM/工艺文件/工具清单)引用层。
|
||||
*
|
||||
* 单向引用:项目引用主数据后,可追加项目级列(日期/库存/采购数量等)与值,
|
||||
* 全部写在本引用层,绝不回写全局主数据(zp_bom_item)。
|
||||
*/
|
||||
class MasterRef
|
||||
{
|
||||
private ConnectionInterface $db;
|
||||
|
||||
public function __construct(DbCore $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
// ---- 引用 ----
|
||||
|
||||
public function getRefsByProject(int $projectId): array
|
||||
{
|
||||
$rows = $this->db->table('zp_master_ref')
|
||||
->where('projectId', $projectId)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
public function getRef(int $id): array|false
|
||||
{
|
||||
$r = $this->db->table('zp_master_ref')->where('id', $id)->first();
|
||||
|
||||
return $r ? (array) $r : false;
|
||||
}
|
||||
|
||||
public function findRef(int $projectId, int $masterId): array|false
|
||||
{
|
||||
$r = $this->db->table('zp_master_ref')
|
||||
->where('projectId', $projectId)
|
||||
->where('masterId', $masterId)
|
||||
->first();
|
||||
|
||||
return $r ? (array) $r : false;
|
||||
}
|
||||
|
||||
public function createRef(array $v): int
|
||||
{
|
||||
return (int) $this->db->table('zp_master_ref')->insertGetId($v);
|
||||
}
|
||||
|
||||
public function deleteRef(int $id): bool
|
||||
{
|
||||
$this->db->table('zp_master_ref_value')->where('refId', $id)->delete();
|
||||
$this->db->table('zp_master_ref_column')->where('refId', $id)->delete();
|
||||
|
||||
return (bool) $this->db->table('zp_master_ref')->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
// ---- 项目级追加列 ----
|
||||
|
||||
public function getColumns(int $refId): array
|
||||
{
|
||||
$rows = $this->db->table('zp_master_ref_column')
|
||||
->where('refId', $refId)
|
||||
->orderBy('sortOrder')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按追加列 id 反查引用(用于鉴权)。
|
||||
*/
|
||||
public function getRefByColumnId(int $columnId): array|false
|
||||
{
|
||||
$col = $this->db->table('zp_master_ref_column')->where('id', $columnId)->first();
|
||||
if (! $col) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getRef((int) $col->refId);
|
||||
}
|
||||
|
||||
public function addColumn(array $v): int
|
||||
{
|
||||
return (int) $this->db->table('zp_master_ref_column')->insertGetId($v);
|
||||
}
|
||||
|
||||
public function deleteColumn(int $id): bool
|
||||
{
|
||||
$this->db->table('zp_master_ref_value')->where('columnId', $id)->delete();
|
||||
|
||||
return (bool) $this->db->table('zp_master_ref_column')->where('id', $id)->delete();
|
||||
}
|
||||
|
||||
// ---- 项目级追加值 ----
|
||||
|
||||
public function getValues(int $refId): array
|
||||
{
|
||||
$rows = $this->db->table('zp_master_ref_value')
|
||||
->where('refId', $refId)
|
||||
->get();
|
||||
|
||||
return array_map(fn ($r) => (array) $r, $rows->all());
|
||||
}
|
||||
|
||||
public function setValue(int $refId, int $columnId, int $itemId, string $value): bool
|
||||
{
|
||||
$existing = $this->db->table('zp_master_ref_value')
|
||||
->where('refId', $refId)
|
||||
->where('columnId', $columnId)
|
||||
->where('itemId', $itemId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return (bool) $this->db->table('zp_master_ref_value')
|
||||
->where('id', $existing->id)
|
||||
->update(['value' => $value]);
|
||||
}
|
||||
|
||||
return (bool) $this->db->table('zp_master_ref_value')->insert([
|
||||
'refId' => $refId,
|
||||
'columnId' => $columnId,
|
||||
'itemId' => $itemId,
|
||||
'value' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
888
app/Domain/Bom/Services/Bom.php
Normal file
888
app/Domain/Bom/Services/Bom.php
Normal file
@@ -0,0 +1,888 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Core\Files\FileManager;
|
||||
use Leantime\Domain\Bom\Permissions\BomPermissions;
|
||||
use Leantime\Domain\Bom\Repositories\Bom as BomRepository;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class Bom extends BaseService
|
||||
{
|
||||
/** BOM 明细的固定列(Excel 导入/表头展示的基础列) */
|
||||
public const FIXED_COLUMNS = [
|
||||
'seq' => '序号',
|
||||
'partNo' => '零件编号',
|
||||
'partName' => '零件名称',
|
||||
'partDrawingNo' => '零件图号',
|
||||
'material' => '材质',
|
||||
'spec' => '零件规格',
|
||||
'qtyPerUnit' => '单件用量',
|
||||
'unit' => '单位',
|
||||
'process' => '工序',
|
||||
'remark' => '备注',
|
||||
];
|
||||
|
||||
/** Teable/Excel 常见中文表头 -> 固定列 key 的别名映射(导入时自动对齐) */
|
||||
private const FIELD_ALIASES = [
|
||||
'零件代号' => 'partNo',
|
||||
'零件编号' => 'partNo',
|
||||
'零件名称' => 'partName',
|
||||
'描述' => 'partName',
|
||||
'零件图号' => 'partDrawingNo',
|
||||
'材质' => 'material',
|
||||
'材料' => 'material',
|
||||
'零件规格' => 'spec',
|
||||
'规格' => 'spec',
|
||||
'单件用量' => 'qtyPerUnit',
|
||||
'数量' => 'qtyPerUnit',
|
||||
'单位' => 'unit',
|
||||
'单位数量' => 'unit',
|
||||
'工序' => 'process',
|
||||
'备注' => 'remark',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected BomRepository $repo,
|
||||
protected Teable $teable,
|
||||
protected Excel $excel,
|
||||
protected FileManager $fileManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 鉴权:主数据是全局资源(projectId=0)时按公司全局角色评估,
|
||||
* 否则(历史项目私有数据 projectId>0)按项目角色评估。
|
||||
*/
|
||||
private function authorizeMaster(string $permission, ?int $projectId): void
|
||||
{
|
||||
$isGlobal = $projectId === null || $projectId <= 0;
|
||||
$this->authorize(
|
||||
$permission,
|
||||
$isGlobal ? null : $projectId,
|
||||
$isGlobal ? true : null,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- BOM 头 ----
|
||||
|
||||
public function getBoms(int $projectId): array
|
||||
{
|
||||
return $this->repo->getBoms($projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型列出全局主数据(bom/process/tooling)。
|
||||
*/
|
||||
public function getMasters(string $type, int $projectId = 0): array
|
||||
{
|
||||
return $this->repo->getMasters($type, $projectId);
|
||||
}
|
||||
|
||||
public function getBom(int $id): array|false
|
||||
{
|
||||
return $this->repo->getBom($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 BOM 完整详情(鉴权后),含动态列、明细、Teable 源。
|
||||
*/
|
||||
public function getBomDetail(int $id): array|false
|
||||
{
|
||||
$bom = $this->repo->getBom($id);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::VIEW, (int) $bom['projectId']);
|
||||
|
||||
$items = $this->repo->getItems($id);
|
||||
// 展开 extra JSON 到行数据;同时把附件/数组等复杂值格式化为友好文本(原始 JSON 保留在 _raw_$key)
|
||||
foreach ($items as &$item) {
|
||||
$extra = json_decode($item['extra'] ?? '', true);
|
||||
if (is_array($extra)) {
|
||||
$item = array_merge($item, $extra);
|
||||
}
|
||||
unset($item['extra']);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
$columns = $this->repo->getColumns($id);
|
||||
|
||||
// 计算每列是否有数据(用于"隐藏空列"与列设置面板)
|
||||
$filledKeys = [];
|
||||
foreach ($items as $item) {
|
||||
foreach ($item as $k => $v) {
|
||||
if (is_string($v) && trim($v) !== '') {
|
||||
$filledKeys[$k] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($columns as &$col) {
|
||||
$col['hasData'] = ! empty($filledKeys[$col['key']]);
|
||||
// 规范化显示:BOM_表结构 -> BOM 表结构(存储 key 因空格被替换为 _)
|
||||
$col['label'] = $col['label'] ?: $col['key'];
|
||||
}
|
||||
unset($col);
|
||||
|
||||
$sources = $this->repo->getSources($id);
|
||||
$baseUrl = '';
|
||||
foreach ($sources as $s) {
|
||||
if (! empty($s['baseUrl'])) {
|
||||
$baseUrl = rtrim((string) $s['baseUrl'], '/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 附件字段:把 Teable 附件 JSON 转成可访问 URL 列表,并标记图片/文件列
|
||||
$imageColumns = [];
|
||||
$fileColumns = [];
|
||||
foreach ($items as &$item) {
|
||||
foreach ($item as $k => $v) {
|
||||
if (! is_string($v) || $v === '' || $v[0] !== '[') {
|
||||
continue;
|
||||
}
|
||||
$parsed = $this->parseAttachmentValue($v, $baseUrl);
|
||||
if ($parsed === null) {
|
||||
continue;
|
||||
}
|
||||
// 附件列值 → JSON 数组 [{url,name,image}],前端据此渲染 <img> 或链接
|
||||
$item[$k] = json_encode($parsed['items'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($parsed['image']) {
|
||||
$imageColumns[$k] = true;
|
||||
} else {
|
||||
$fileColumns[$k] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return [
|
||||
'bom' => $bom,
|
||||
'columns' => $columns,
|
||||
'items' => $items,
|
||||
'sources' => $sources,
|
||||
'hiddenColumns' => $this->getHiddenColumns($id),
|
||||
'filledKeys' => array_keys($filledKeys),
|
||||
'imageColumns' => array_keys($imageColumns),
|
||||
'fileColumns' => array_keys($fileColumns),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- 列显隐 ----
|
||||
|
||||
public function getHiddenColumns(int $bomId): array
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
$arr = $bom === false ? [] : json_decode($bom['hiddenColumns'] ?? '', true);
|
||||
|
||||
return is_array($arr) ? array_values(array_unique(array_map('strval', $arr))) : [];
|
||||
}
|
||||
|
||||
public function setHiddenColumns(int $bomId, array $keys): bool
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
$keys = array_values(array_unique(array_map('strval', $keys)));
|
||||
|
||||
return $this->repo->updateHiddenColumns($bomId, json_encode($keys, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public function createBom(array $values): int
|
||||
{
|
||||
// 主数据是全局资源,强制 projectId=0(与具体项目无关,跨项目共享)
|
||||
$this->authorizeMaster(BomPermissions::CREATE, 0);
|
||||
|
||||
$row = [
|
||||
'type' => (string) ($values['type'] ?? 'bom'),
|
||||
'bomNo' => trim((string) ($values['bomNo'] ?? '')),
|
||||
'productName' => trim((string) ($values['productName'] ?? '')),
|
||||
'specification' => trim((string) ($values['specification'] ?? '')),
|
||||
'drawingNo' => trim((string) ($values['drawingNo'] ?? '')),
|
||||
'version' => trim((string) ($values['version'] ?? '')),
|
||||
'status' => (string) ($values['status'] ?? '0'),
|
||||
'projectId' => 0,
|
||||
'remark' => trim((string) ($values['remark'] ?? '')),
|
||||
'createdOn' => now(),
|
||||
'modifiedOn' => now(),
|
||||
];
|
||||
|
||||
return $this->repo->createBom($row);
|
||||
}
|
||||
|
||||
public function updateBom(int $id, array $values): bool
|
||||
{
|
||||
$bom = $this->repo->getBom($id);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
$row = [];
|
||||
foreach (['bomNo', 'productName', 'specification', 'drawingNo', 'version', 'status', 'remark'] as $f) {
|
||||
if (array_key_exists($f, $values)) {
|
||||
$row[$f] = trim((string) $values[$f]);
|
||||
}
|
||||
}
|
||||
$row['modifiedOn'] = now();
|
||||
|
||||
return $this->repo->updateBom($id, $row);
|
||||
}
|
||||
|
||||
public function deleteBom(int $id): bool
|
||||
{
|
||||
$bom = $this->repo->getBom($id);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::DELETE, (int) $bom['projectId']);
|
||||
|
||||
$this->repo->deleteItemsByBom($id);
|
||||
$this->repo->deleteColumnsByBom($id);
|
||||
// 删除源
|
||||
foreach ($this->repo->getSources($id) as $s) {
|
||||
$this->repo->deleteSource((int) $s['id']);
|
||||
}
|
||||
|
||||
return $this->repo->deleteBom($id);
|
||||
}
|
||||
|
||||
// ---- 动态列 ----
|
||||
|
||||
public function getColumns(int $bomId): array
|
||||
{
|
||||
return $this->repo->getColumns($bomId);
|
||||
}
|
||||
|
||||
public function addColumn(int $bomId, string $key, string $label): int
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return 0;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
$key = $this->normalizeKey($key);
|
||||
if ($key === '' || $this->isFixedColumn($key)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 已存在则返回
|
||||
foreach ($this->repo->getColumns($bomId) as $col) {
|
||||
if ($col['key'] === $key) {
|
||||
return (int) $col['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$maxSort = 0;
|
||||
foreach ($this->repo->getColumns($bomId) as $col) {
|
||||
$maxSort = max($maxSort, (int) $col['sortOrder']);
|
||||
}
|
||||
|
||||
return $this->repo->addColumn([
|
||||
'bomId' => $bomId,
|
||||
'key' => $key,
|
||||
'label' => trim($label) ?: $key,
|
||||
'sortOrder' => $maxSort + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteColumn(int $columnId): bool
|
||||
{
|
||||
$col = $this->repo->getColumn($columnId);
|
||||
if ($col === false) {
|
||||
return false;
|
||||
}
|
||||
$bom = $this->repo->getBom((int) $col['bomId']);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
return $this->repo->deleteColumn($columnId);
|
||||
}
|
||||
|
||||
// ---- 明细 ----
|
||||
|
||||
public function getItems(int $bomId): array
|
||||
{
|
||||
return $this->repo->getItems($bomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/更新明细行。$values 可含任意固定列 + 动态列(key 为准)。
|
||||
*/
|
||||
public function saveItem(int $bomId, array $values, ?int $itemId = null): int
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return 0;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
$columns = $this->repo->getColumns($bomId);
|
||||
$dynamicKeys = array_column($columns, 'key');
|
||||
$extra = [];
|
||||
|
||||
foreach ($values as $k => $v) {
|
||||
if ($this->isFixedColumn($k)) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($k, $dynamicKeys, true)) {
|
||||
$extra[$k] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
$row = [
|
||||
'bomId' => $bomId,
|
||||
'seq' => (int) ($values['seq'] ?? 0),
|
||||
'partNo' => (string) ($values['partNo'] ?? ''),
|
||||
'partName' => (string) ($values['partName'] ?? ''),
|
||||
'partDrawingNo' => (string) ($values['partDrawingNo'] ?? ''),
|
||||
'material' => (string) ($values['material'] ?? ''),
|
||||
'spec' => (string) ($values['spec'] ?? ''),
|
||||
'qtyPerUnit' => (string) ($values['qtyPerUnit'] ?? ''),
|
||||
'unit' => (string) ($values['unit'] ?? ''),
|
||||
'process' => (string) ($values['process'] ?? ''),
|
||||
'remark' => (string) ($values['remark'] ?? ''),
|
||||
'extra' => empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
|
||||
if ($itemId !== null) {
|
||||
$this->repo->updateItem($itemId, $row);
|
||||
|
||||
return $itemId;
|
||||
}
|
||||
|
||||
return $this->repo->createItem($row);
|
||||
}
|
||||
|
||||
public function deleteItem(int $itemId): bool
|
||||
{
|
||||
$item = $this->repo->getItem($itemId);
|
||||
if ($item !== false) {
|
||||
$bom = $this->repo->getBom((int) $item['bomId']);
|
||||
if ($bom !== false) {
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->repo->deleteItem($itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 BOM 明细的图片/附件(保存到 public 磁盘,返回可直连的 URL)。
|
||||
* 附件作为独立文件存到 public/userfiles/bom/,返回给前端塞进单元格或浮动图片。
|
||||
*
|
||||
* @return array{url:string,name:string,mime:string,size:int,image:bool}|string 成功返回元数据数组,失败返回错误文案
|
||||
*/
|
||||
public function uploadAttachment(int $bomId, array $file): array|string
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return 'BOM 不存在';
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
if (! isset($file['file']) || ! is_array($file['file'])) {
|
||||
return '未收到文件';
|
||||
}
|
||||
|
||||
// 大小上限(沿用全局文件上传上限)
|
||||
if (isset($file['file']['size']) && $file['file']['size'] > FileManager::getMaximumFileUploadSize()) {
|
||||
return '文件超过大小限制';
|
||||
}
|
||||
|
||||
$tmp = $file['file']['tmp_name'] ?? '';
|
||||
if ($tmp === '' || ! is_file($tmp)) {
|
||||
return '上传文件无效';
|
||||
}
|
||||
|
||||
$origName = basename((string) ($file['file']['name'] ?? 'file'));
|
||||
$mime = (string) ($file['file']['type'] ?? '');
|
||||
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
|
||||
// 仅允许常见图片/文档/压缩包,阻断可执行文件(svg 由 FileManager 统一拦截防 XSS,这里不放行)
|
||||
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp',
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv', 'txt',
|
||||
'zip', 'rar', '7z', 'step', 'stp', 'dwg', 'dxf', 'igs', 'iges'];
|
||||
if ($ext === '' || ! in_array($ext, $allowed, true)) {
|
||||
return '不支持的文件类型';
|
||||
}
|
||||
|
||||
// 用 FileManager 的 UploadedFile 校验/清理流程,落到 public 磁盘(可 web 直连)
|
||||
$uploaded = new UploadedFile(
|
||||
$tmp,
|
||||
$origName,
|
||||
$mime,
|
||||
$file['file']['error'] ?? 0,
|
||||
true
|
||||
);
|
||||
|
||||
$meta = $this->fileManager->upload($uploaded, 'public');
|
||||
if ($meta === false || ! is_array($meta)) {
|
||||
return '文件保存失败';
|
||||
}
|
||||
|
||||
$fileName = (string) ($meta['fileName'] ?? $meta['newPath'] ?? '');
|
||||
$url = $this->fileManager->getFileUrl($fileName, 'public');
|
||||
if ($url === false) {
|
||||
$base = defined('BASE_URL') ? BASE_URL : '';
|
||||
$url = $base.'/userfiles/'.$fileName;
|
||||
}
|
||||
|
||||
$isImage = str_starts_with($mime, 'image/');
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'name' => (string) ($meta['realName'] ?? $origName),
|
||||
'mime' => $mime,
|
||||
'size' => (int) ($file['file']['size'] ?? 0),
|
||||
'image' => $isImage,
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Teable ----
|
||||
|
||||
public function getSources(int $bomId): array
|
||||
{
|
||||
return $this->repo->getSources($bomId);
|
||||
}
|
||||
|
||||
public function addSource(int $bomId, array $values): int
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return 0;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
return $this->repo->addSource([
|
||||
'bomId' => $bomId,
|
||||
'name' => trim((string) ($values['name'] ?? '')),
|
||||
'baseUrl' => trim((string) ($values['baseUrl'] ?? '')),
|
||||
'tableId' => trim((string) ($values['tableId'] ?? '')),
|
||||
'token' => (string) ($values['token'] ?? ''),
|
||||
'remark' => trim((string) ($values['remark'] ?? '')),
|
||||
'createdOn' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteSource(int $sourceId): bool
|
||||
{
|
||||
$s = $this->repo->getSource($sourceId);
|
||||
if ($s === false) {
|
||||
return false;
|
||||
}
|
||||
$bom = $this->repo->getBom((int) $s['bomId']);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
return $this->repo->deleteSource($sourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Teable 拉取最新数据并导入为 BOM 明细。
|
||||
*
|
||||
* 动态列策略:将 Teable 字段名映射为动态列(跳过无法作为 key 的字段),
|
||||
* 记录值写入明细的 extra JSON。
|
||||
*/
|
||||
public function importFromTeable(int $bomId): array
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return ['success' => false, 'message' => 'BOM 不存在'];
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
$sources = $this->repo->getSources($bomId);
|
||||
if (empty($sources)) {
|
||||
return ['success' => false, 'message' => '尚未配置 Teable 数据源'];
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($sources as $src) {
|
||||
$res = $this->importSource($bomId, $src);
|
||||
$imported += $res['imported'];
|
||||
$errors = array_merge($errors, $res['errors']);
|
||||
}
|
||||
|
||||
if ($imported === 0 && ! empty($errors)) {
|
||||
return ['success' => false, 'message' => implode(';', $errors)];
|
||||
}
|
||||
|
||||
return ['success' => true, 'imported' => $imported, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析粘贴的 Teable API 文档 / curl 文本(不落库,用于前端预览)。
|
||||
*/
|
||||
public function parseTeableText(string $text): array
|
||||
{
|
||||
return $this->teable->parseConfig($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 粘贴整段 Teable API 文档直接导入:解析连接信息 -> 保存数据源 -> 拉取并导入。
|
||||
*/
|
||||
public function importFromTeableText(int $bomId, string $text): array
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return ['success' => false, 'message' => 'BOM 不存在'];
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
$parsed = $this->teable->parseConfig($text);
|
||||
if (empty($parsed['success'])) {
|
||||
return ['success' => false, 'message' => $parsed['message'] ?? '解析失败'];
|
||||
}
|
||||
|
||||
// 保存为数据源(同 tableId 已存在则不重复保存)
|
||||
$exists = false;
|
||||
foreach ($this->repo->getSources($bomId) as $s) {
|
||||
if (($s['tableId'] ?? '') === $parsed['tableId']) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $exists) {
|
||||
$this->addSource($bomId, [
|
||||
'name' => $parsed['name'] ?: $parsed['tableId'],
|
||||
'baseUrl' => $parsed['baseUrl'],
|
||||
'tableId' => $parsed['tableId'],
|
||||
'token' => $parsed['token'],
|
||||
'remark' => 'API 粘贴导入',
|
||||
]);
|
||||
}
|
||||
|
||||
$res = $this->importSource($bomId, [
|
||||
'name' => $parsed['name'] ?: $parsed['tableId'],
|
||||
'baseUrl' => $parsed['baseUrl'],
|
||||
'tableId' => $parsed['tableId'],
|
||||
'token' => $parsed['token'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'imported' => $res['imported'],
|
||||
'errors' => $res['errors'],
|
||||
'source' => [
|
||||
'name' => $parsed['name'],
|
||||
'baseUrl' => $parsed['baseUrl'],
|
||||
'tableId' => $parsed['tableId'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取单个 Teable 源并导入为明细行(importFromTeable / importFromTeableText 共用)。
|
||||
*
|
||||
* @return array{imported:int,errors:array}
|
||||
*/
|
||||
private function importSource(int $bomId, array $src): array
|
||||
{
|
||||
$data = $this->teable->fetchTable($src);
|
||||
if (empty($data['success'])) {
|
||||
return ['imported' => 0, 'errors' => [($src['name'] ?: $src['tableId']).': '.($data['message'] ?? '拉取失败')]];
|
||||
}
|
||||
|
||||
$fields = $data['fields'] ?? [];
|
||||
$fixedByLabel = array_flip(self::FIXED_COLUMNS);
|
||||
// 字段名 -> 目标 key(优先别名映射到固定列,其次跳过固定列,其余建动态列)
|
||||
$fieldMap = []; // Teable 字段名 -> 存储 key
|
||||
foreach ($fields as $field) {
|
||||
$name = $field['name'] ?? ($field['fieldName'] ?? '');
|
||||
if (! is_string($name) || $name === '') {
|
||||
continue;
|
||||
}
|
||||
if (isset(self::FIELD_ALIASES[$name])) {
|
||||
$fieldMap[$name] = self::FIELD_ALIASES[$name];
|
||||
} elseif (isset(self::FIXED_COLUMNS[$name]) || isset($fixedByLabel[$name])) {
|
||||
$fieldMap[$name] = isset(self::FIXED_COLUMNS[$name]) ? $name : $fixedByLabel[$name];
|
||||
} else {
|
||||
$this->addColumn($bomId, $name, $name);
|
||||
$fieldMap[$name] = $this->normalizeKey($name);
|
||||
}
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
foreach ($data['records'] ?? [] as $record) {
|
||||
$fieldsArr = is_array($record) && isset($record['fields']) && is_array($record['fields'])
|
||||
? $record['fields']
|
||||
: $record;
|
||||
|
||||
$row = [
|
||||
'seq' => 0,
|
||||
'partNo' => '',
|
||||
'partName' => '',
|
||||
'partDrawingNo' => '',
|
||||
'material' => '',
|
||||
'spec' => '',
|
||||
'qtyPerUnit' => '',
|
||||
'unit' => '',
|
||||
'process' => '',
|
||||
'remark' => '',
|
||||
];
|
||||
|
||||
foreach ($fieldsArr as $k => $v) {
|
||||
$key = $fieldMap[$k] ?? (isset(self::FIELD_ALIASES[$k])
|
||||
? self::FIELD_ALIASES[$k]
|
||||
: (isset(self::FIXED_COLUMNS[$k]) || isset($fixedByLabel[$k])
|
||||
? (isset(self::FIXED_COLUMNS[$k]) ? $k : $fixedByLabel[$k])
|
||||
: $this->normalizeKey($k)));
|
||||
$val = $this->stringifyTeableValue($v);
|
||||
$row[$key] = $val;
|
||||
}
|
||||
|
||||
$this->saveItem($bomId, $row);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return ['imported' => $imported, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Teable 多维值 -> 字符串:
|
||||
* - 附件数组(含 presignedUrl/path/mimetype):保留完整 JSON,供读取时解析出图片 URL
|
||||
* - 普通数组:提取 name/title 用逗号连接
|
||||
* - 标量:直接转字符串
|
||||
*/
|
||||
private function stringifyTeableValue(mixed $v): string
|
||||
{
|
||||
if (is_array($v)) {
|
||||
$isAttachment = false;
|
||||
foreach ($v as $el) {
|
||||
if (is_array($el) && (isset($el['presignedUrl']) || isset($el['path']) || isset($el['mimetype']))) {
|
||||
$isAttachment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($isAttachment) {
|
||||
return json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($v as $el) {
|
||||
if (is_array($el)) {
|
||||
$parts[] = (string) ($el['name'] ?? $el['title'] ?? ($el['id'] ?? ''));
|
||||
} else {
|
||||
$parts[] = (string) $el;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
return (string) $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Teable 附件 JSON 字符串 -> 可访问 URL 列表。
|
||||
*
|
||||
* @return array{items:array<array{url:string,name:string,image:bool}>,image:bool}|null
|
||||
*/
|
||||
private function parseAttachmentValue(string $raw, string $baseUrl): ?array
|
||||
{
|
||||
if ($raw === '' || $raw[0] !== '[') {
|
||||
return null;
|
||||
}
|
||||
$arr = json_decode($raw, true);
|
||||
if (! is_array($arr)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$allImage = true;
|
||||
foreach ($arr as $el) {
|
||||
if (! is_array($el)) {
|
||||
continue;
|
||||
}
|
||||
// 兼容两种格式:
|
||||
// 1) Teable 导入:mimetype / presignedUrl / path / smThumbnailUrl / lgThumbnailUrl
|
||||
// 2) 前端「插入图片/附件」写入:url / name / image(bool)
|
||||
$mime = (string) ($el['mimetype'] ?? $el['mime'] ?? '');
|
||||
if (array_key_exists('image', $el)) {
|
||||
$isImg = (bool) $el['image'];
|
||||
} else {
|
||||
$isImg = str_starts_with($mime, 'image/');
|
||||
}
|
||||
if (! $isImg) {
|
||||
$allImage = false;
|
||||
}
|
||||
$rel = (string) ($el['url'] ?? $el['smThumbnailUrl'] ?? $el['lgThumbnailUrl'] ?? $el['presignedUrl'] ?? $el['path'] ?? '');
|
||||
if ($rel === '') {
|
||||
continue;
|
||||
}
|
||||
$url = preg_match('#^https?://#', $rel) ? $rel : $baseUrl.$rel;
|
||||
$items[] = [
|
||||
'url' => $url,
|
||||
'name' => (string) ($el['name'] ?? ''),
|
||||
'image' => $isImg,
|
||||
];
|
||||
}
|
||||
if (empty($items)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['items' => $items, 'image' => $allImage];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Excel/CSV 导入 BOM 明细。
|
||||
*/
|
||||
public function importFromExcel(int $bomId, string $path): array
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return ['success' => false, 'message' => 'BOM 不存在'];
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::EDIT, (int) $bom['projectId']);
|
||||
|
||||
$parsed = $this->excel->parse($path);
|
||||
$header = $parsed['header'];
|
||||
if (empty($header)) {
|
||||
return ['success' => false, 'message' => 'Excel 为空或缺少表头'];
|
||||
}
|
||||
|
||||
// 表头 -> 固定列 key 映射(支持中文表头或英文 key 表头)
|
||||
$map = [];
|
||||
$fixedByLabel = array_flip(self::FIXED_COLUMNS);
|
||||
foreach ($header as $i => $h) {
|
||||
$h = trim((string) $h);
|
||||
if ($h === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $h;
|
||||
if (isset(self::FIXED_COLUMNS[$h])) {
|
||||
$key = $h;
|
||||
} elseif (isset($fixedByLabel[$h])) {
|
||||
$key = $fixedByLabel[$h];
|
||||
} else {
|
||||
// 非固定列 → 动态列(key 规范化,与 addColumn 存储一致)
|
||||
$this->addColumn($bomId, $h, $h);
|
||||
$key = $this->normalizeKey($h);
|
||||
}
|
||||
$map[$i] = $key;
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
foreach ($parsed['rows'] as $rowArr) {
|
||||
$row = ['seq' => 0];
|
||||
foreach ($map as $i => $key) {
|
||||
$val = $rowArr[$i] ?? '';
|
||||
$row[$key] = is_string($val) ? trim($val) : (string) $val;
|
||||
}
|
||||
$this->saveItem($bomId, $row);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return ['success' => true, 'imported' => $imported];
|
||||
}
|
||||
|
||||
// ---- 导出 ----
|
||||
|
||||
/**
|
||||
* 导出 BOM 明细(返回 [path, filename, contentType])。
|
||||
* @return array{path:string,filename:string,contentType:string}|false
|
||||
*/
|
||||
public function export(int $bomId, string $format = 'xlsx'): array|false
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::VIEW, (int) $bom['projectId']);
|
||||
|
||||
$detail = $this->getBomDetail($bomId);
|
||||
if ($detail === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hidden = $this->getHiddenColumns($bomId);
|
||||
|
||||
// 组装表头:固定列(跳过隐藏)+ 动态列(跳过隐藏)
|
||||
$header = [];
|
||||
$keys = [];
|
||||
foreach (self::FIXED_COLUMNS as $key => $label) {
|
||||
if (in_array($key, $hidden, true)) {
|
||||
continue;
|
||||
}
|
||||
$header[] = $label;
|
||||
$keys[] = $key;
|
||||
}
|
||||
foreach ($detail['columns'] as $col) {
|
||||
if (in_array($col['key'], $hidden, true)) {
|
||||
continue;
|
||||
}
|
||||
$header[] = $col['label'] ?: $col['key'];
|
||||
$keys[] = $col['key'];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($detail['items'] as $item) {
|
||||
$line = [];
|
||||
foreach ($keys as $k) {
|
||||
$line[] = (string) ($item[$k] ?? '');
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
|
||||
$name = ($bom['bomNo'] ?: ($bom['productName'] ?: 'BOM')).'-'.date('Ymd-His');
|
||||
$format = strtolower($format);
|
||||
|
||||
if ($format === 'csv') {
|
||||
$path = $this->excel->exportCsv($header, $rows);
|
||||
|
||||
return ['path' => $path, 'filename' => $name.'.csv', 'contentType' => 'text/csv; charset=UTF-8'];
|
||||
}
|
||||
|
||||
$path = $this->excel->exportXlsx($name, $header, $rows);
|
||||
|
||||
return ['path' => $path, 'filename' => $name.'.xlsx', 'contentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 BOM 导入模板(仅表头:固定列 + 现有动态列,不含数据行)。
|
||||
* @return array{path:string,filename:string,contentType:string}|false
|
||||
*/
|
||||
public function exportTemplate(int $bomId): array|false
|
||||
{
|
||||
$bom = $this->repo->getBom($bomId);
|
||||
if ($bom === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizeMaster(BomPermissions::VIEW, (int) $bom['projectId']);
|
||||
|
||||
$header = array_values(self::FIXED_COLUMNS);
|
||||
foreach ($this->repo->getColumns($bomId) as $col) {
|
||||
$header[] = $col['label'] ?: $col['key'];
|
||||
}
|
||||
|
||||
$path = $this->excel->exportXlsx('BOM导入模板', $header, []);
|
||||
|
||||
return ['path' => $path, 'filename' => 'BOM导入模板.xlsx', 'contentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private function isFixedColumn(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, self::FIXED_COLUMNS);
|
||||
}
|
||||
|
||||
private function normalizeKey(string $key): string
|
||||
{
|
||||
// 允许中文字段名作为 key;仅清理首尾空白和危险字符
|
||||
$key = trim($key);
|
||||
$key = str_replace([' ', '/', '\\'], '_', $key);
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
269
app/Domain/Bom/Services/Excel.php
Normal file
269
app/Domain/Bom/Services/Excel.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
/**
|
||||
* 轻量 Excel/CSV 解析器,不依赖 PhpSpreadsheet。
|
||||
*
|
||||
* 支持:
|
||||
* - .xlsx(解压 sharedStrings.xml + sheet1.xml,按列字母映射单元格)
|
||||
* - .csv(fgetcsv,自动探测 BOM)
|
||||
* - .xls 老格式不支持,报错提示另存为 .xlsx 或 .csv
|
||||
*
|
||||
* 返回:首行为表头,后续行为数据行。
|
||||
*/
|
||||
class Excel
|
||||
{
|
||||
/**
|
||||
* @return array{header:array<int,string>,rows:array<int,array<int,string>>}
|
||||
*/
|
||||
public function parse(string $path): array
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return match ($ext) {
|
||||
'xlsx' => $this->parseXlsx($path),
|
||||
'csv' => $this->parseCsv($path),
|
||||
default => throw new \InvalidArgumentException('仅支持 .xlsx 或 .csv 文件'),
|
||||
};
|
||||
}
|
||||
|
||||
private function parseCsv(string $path): array
|
||||
{
|
||||
$handle = fopen($path, 'r');
|
||||
if ($handle === false) {
|
||||
throw new \InvalidArgumentException('无法打开 CSV 文件');
|
||||
}
|
||||
|
||||
// 去掉 UTF-8 BOM
|
||||
$first = fgets($handle);
|
||||
if ($first === false) {
|
||||
fclose($handle);
|
||||
|
||||
return ['header' => [], 'rows' => []];
|
||||
}
|
||||
if (str_starts_with($first, "\xEF\xBB\xBF")) {
|
||||
$first = substr($first, 3);
|
||||
}
|
||||
|
||||
$header = str_getcsv($first);
|
||||
$rows = [];
|
||||
while (($line = fgetcsv($handle)) !== false) {
|
||||
// 跳过空行
|
||||
if (count($line) === 1 && trim($line[0]) === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return ['header' => $header, 'rows' => $rows];
|
||||
}
|
||||
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new \InvalidArgumentException('无法打开 .xlsx 文件');
|
||||
}
|
||||
|
||||
// sharedStrings
|
||||
$shared = [];
|
||||
$ss = $zip->getFromName('xl/sharedStrings.xml');
|
||||
if ($ss !== false) {
|
||||
$xml = new \SimpleXMLElement($ss);
|
||||
foreach ($xml->si as $si) {
|
||||
$text = '';
|
||||
foreach ($si->t ?? [] as $t) {
|
||||
$text .= (string) $t;
|
||||
}
|
||||
$shared[] = $text;
|
||||
}
|
||||
}
|
||||
|
||||
// 第一个工作表
|
||||
$sheet = $zip->getFromName('xl/worksheets/sheet1.xml');
|
||||
if ($sheet === false) {
|
||||
$zip->close();
|
||||
throw new \InvalidArgumentException('未找到工作表');
|
||||
}
|
||||
|
||||
$xml = new \SimpleXMLElement($sheet);
|
||||
$rows = [];
|
||||
$header = [];
|
||||
|
||||
foreach ($xml->sheetData->row as $rowEl) {
|
||||
$cells = [];
|
||||
foreach ($rowEl->c as $c) {
|
||||
$ref = (string) $c['r']; // 如 "A1", "B2"
|
||||
$col = $this->colIndex($ref);
|
||||
$type = (string) $c['t'];
|
||||
|
||||
if ($type === 's') {
|
||||
// 共享字符串:v 是 sharedStrings 索引
|
||||
$v = (string) ($c->v ?? '');
|
||||
$cells[$col] = $shared[(int) $v] ?? '';
|
||||
} elseif ($type === 'inlineStr') {
|
||||
// 内联字符串(现代 Excel/WPS 常用):值在 <is><t> 里,富文本可能有多个 <t>
|
||||
$text = '';
|
||||
foreach ($c->is->t ?? [] as $t) {
|
||||
$text .= (string) $t;
|
||||
}
|
||||
$cells[$col] = $text;
|
||||
} else {
|
||||
// 数字/布尔/公式结果等
|
||||
$cells[$col] = (string) ($c->v ?? '');
|
||||
}
|
||||
}
|
||||
$line = [];
|
||||
$max = empty($cells) ? 0 : max(array_keys($cells));
|
||||
for ($i = 0; $i <= $max; $i++) {
|
||||
$line[$i] = $cells[$i] ?? '';
|
||||
}
|
||||
$rows[] = $line;
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
if (empty($rows)) {
|
||||
return ['header' => [], 'rows' => []];
|
||||
}
|
||||
|
||||
$header = array_shift($rows);
|
||||
|
||||
// 规范化表头:去空白
|
||||
$header = array_map(fn ($h) => trim((string) $h), $header);
|
||||
|
||||
return ['header' => $header, 'rows' => $rows];
|
||||
}
|
||||
|
||||
/** "A"->0, "B"->1, "AA"->26 ... */
|
||||
private function colIndex(string $ref): int
|
||||
{
|
||||
$letters = preg_replace('/\d/', '', $ref);
|
||||
$idx = 0;
|
||||
foreach (str_split($letters) as $ch) {
|
||||
$idx = $idx * 26 + (ord($ch) - ord('A') + 1);
|
||||
}
|
||||
|
||||
return $idx - 1;
|
||||
}
|
||||
|
||||
// ---- 导出(生成真正的 .xlsx,不依赖 PhpSpreadsheet) ----
|
||||
|
||||
/**
|
||||
* 生成 .xlsx 文件,返回临时文件路径。
|
||||
*
|
||||
* @param array<int,string> $header 表头
|
||||
* @param array<int,array<int,string>> $rows 数据行
|
||||
*/
|
||||
public function exportXlsx(string $sheetName, array $header, array $rows): string
|
||||
{
|
||||
$sheetName = mb_substr(preg_replace('/[\\/?*\[\]:]/', ' ', $sheetName) ?: 'Sheet1', 0, 31) ?: 'Sheet1';
|
||||
|
||||
$rowsXml = '';
|
||||
$rowNum = 1;
|
||||
|
||||
$rowsXml .= $this->buildRow($rowNum++, $header);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$cells = [];
|
||||
foreach ($row as $i => $v) {
|
||||
$cells[$i] = is_string($v) ? $v : (string) $v;
|
||||
}
|
||||
$rowsXml .= $this->buildRow($rowNum++, $cells);
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bomexp');
|
||||
@unlink($tmp);
|
||||
$path = $tmp.'.xlsx';
|
||||
if ($zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('无法创建导出文件');
|
||||
}
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
.'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
.'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
.'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
.'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
.'</Types>');
|
||||
|
||||
$zip->addFromString('_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
.'</Relationships>');
|
||||
|
||||
$zip->addFromString('xl/workbook.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
.'<sheets><sheet name="'.$this->xml($sheetName).'" sheetId="1" r:id="rId1"/></sheets>'
|
||||
.'</workbook>');
|
||||
|
||||
$zip->addFromString('xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||
.'</Relationships>');
|
||||
|
||||
$zip->addFromString('xl/worksheets/sheet1.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>'
|
||||
.$rowsXml
|
||||
.'</sheetData></worksheet>');
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 .csv 文件,返回临时文件路径(带 UTF-8 BOM,Excel 打开不乱码)。
|
||||
*/
|
||||
public function exportCsv(array $header, array $rows): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bomexp');
|
||||
$path = $tmp.'.csv';
|
||||
$fh = fopen($path, 'w');
|
||||
if ($fh === false) {
|
||||
throw new \RuntimeException('无法创建导出文件');
|
||||
}
|
||||
fwrite($fh, "\xEF\xBB\xBF");
|
||||
fputcsv($fh, $header);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($fh, array_map(fn ($v) => (string) $v, $row));
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function buildRow(int $rowNum, array $cells): string
|
||||
{
|
||||
$xml = '<row r="'.$rowNum.'">';
|
||||
$col = 0;
|
||||
foreach ($cells as $v) {
|
||||
$ref = $this->colLetter($col).$rowNum;
|
||||
$xml .= '<c r="'.$ref.'" t="inlineStr"><is><t>'.$this->xml((string) $v).'</t></is></c>';
|
||||
$col++;
|
||||
}
|
||||
$xml .= '</row>';
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function colLetter(int $idx): string
|
||||
{
|
||||
$s = '';
|
||||
$idx++;
|
||||
while ($idx > 0) {
|
||||
$idx--;
|
||||
$s = chr(ord('A') + ($idx % 26)).$s;
|
||||
$idx = intdiv($idx, 26);
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
private function xml(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
152
app/Domain/Bom/Services/MasterRef.php
Normal file
152
app/Domain/Bom/Services/MasterRef.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
use Leantime\Core\Domains\BaseService;
|
||||
use Leantime\Domain\Bom\Permissions\BomPermissions;
|
||||
use Leantime\Domain\Bom\Repositories\Bom as BomRepository;
|
||||
use Leantime\Domain\Bom\Repositories\MasterRef as MasterRefRepository;
|
||||
|
||||
/**
|
||||
* 项目 ↔ 全局主数据(BOM/工艺文件/工具清单)引用服务。
|
||||
*
|
||||
* 单向引用:项目引用主数据后追加的项目级列/值只存引用层,绝不写回全局主数据。
|
||||
*/
|
||||
class MasterRef extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MasterRefRepository $repo,
|
||||
protected BomRepository $bomRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 项目已引用的主数据列表。
|
||||
*/
|
||||
public function getRefs(int $projectId): array
|
||||
{
|
||||
return $this->repo->getRefsByProject($projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目引用一个主数据(幂等:已引用则返回已有 refId)。
|
||||
*/
|
||||
public function link(int $projectId, int $masterId): int
|
||||
{
|
||||
$this->authorize(BomPermissions::EDIT, $projectId);
|
||||
|
||||
$master = $this->bomRepo->getBom($masterId);
|
||||
if ($master === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$existing = $this->repo->findRef($projectId, $masterId);
|
||||
if ($existing !== false) {
|
||||
return (int) $existing['id'];
|
||||
}
|
||||
|
||||
return $this->repo->createRef([
|
||||
'projectId' => $projectId,
|
||||
'masterId' => $masterId,
|
||||
'createdOn' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unlink(int $refId): bool
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
return $this->repo->deleteRef($refId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目级引用详情:主数据明细 + 项目级追加列 + 项目级追加值。
|
||||
*/
|
||||
public function getRefDetail(int $refId): array|false
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::VIEW, (int) $ref['projectId']);
|
||||
|
||||
$columns = $this->repo->getColumns($refId);
|
||||
$values = $this->repo->getValues($refId);
|
||||
|
||||
// 值按 itemId -> [columnId -> value] 组织
|
||||
$valueMap = [];
|
||||
foreach ($values as $v) {
|
||||
$valueMap[(int) $v['itemId']][(int) $v['columnId']] = (string) $v['value'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ref' => $ref,
|
||||
'columns' => $columns,
|
||||
'values' => $valueMap,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目级追加列。
|
||||
*/
|
||||
public function addColumn(int $refId, string $key, string $label): int
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return 0;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
$key = trim(str_replace([' ', '/', '\\'], '_', $key));
|
||||
if ($key === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($this->repo->getColumns($refId) as $col) {
|
||||
if ($col['key'] === $key) {
|
||||
return (int) $col['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$maxSort = 0;
|
||||
foreach ($this->repo->getColumns($refId) as $col) {
|
||||
$maxSort = max($maxSort, (int) $col['sortOrder']);
|
||||
}
|
||||
|
||||
return $this->repo->addColumn([
|
||||
'refId' => $refId,
|
||||
'key' => $key,
|
||||
'label' => trim($label) ?: $key,
|
||||
'sortOrder' => $maxSort + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteColumn(int $columnId): bool
|
||||
{
|
||||
// 按 columnId 找 refId 以完成鉴权
|
||||
$ref = $this->repo->getRefByColumnId($columnId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
return $this->repo->deleteColumn($columnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目级追加值(不影响全局主数据)。
|
||||
*/
|
||||
public function saveValue(int $refId, int $columnId, int $itemId, string $value): bool
|
||||
{
|
||||
$ref = $this->repo->getRef($refId);
|
||||
if ($ref === false) {
|
||||
return false;
|
||||
}
|
||||
$this->authorize(BomPermissions::EDIT, (int) $ref['projectId']);
|
||||
|
||||
return $this->repo->setValue($refId, $columnId, $itemId, $value);
|
||||
}
|
||||
}
|
||||
179
app/Domain/Bom/Services/Teable.php
Normal file
179
app/Domain/Bom/Services/Teable.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Teable OpenAPI 客户端。
|
||||
*
|
||||
* 把 Teable 作为外部数据库:实时拉取表的字段元数据 + 全量记录。
|
||||
* GET {base}/api/table/{tableId}/field 字段元数据
|
||||
* GET {base}/api/table/{tableId}/record 记录(take/skip 分页)
|
||||
* 统一 fieldKeyType=name,字段名(中文亦可)直接作为 JSON key。
|
||||
*/
|
||||
class Teable
|
||||
{
|
||||
private const DEFAULT_BASE = 'https://table.universal-onebot.com';
|
||||
|
||||
/**
|
||||
* @return array{success:bool,fields:array,records:array,message?:string}
|
||||
*/
|
||||
public function fetchTable(array $source): array
|
||||
{
|
||||
$base = rtrim(trim($source['baseUrl'] ?? '') ?: self::DEFAULT_BASE, '/');
|
||||
$tableId = $source['tableId'] ?? '';
|
||||
$token = $source['token'] ?? '';
|
||||
|
||||
if ($tableId === '' || $token === '') {
|
||||
return ['success' => false, 'message' => '缺少 tableId 或 token', 'fields' => [], 'records' => []];
|
||||
}
|
||||
|
||||
try {
|
||||
$fieldResp = Http::withToken($token)
|
||||
->timeout(60)
|
||||
->get("$base/api/table/$tableId/field");
|
||||
|
||||
if (! $fieldResp->successful()) {
|
||||
return ['success' => false, 'message' => "字段接口 HTTP {$fieldResp->status()}", 'fields' => [], 'records' => []];
|
||||
}
|
||||
|
||||
$fields = $fieldResp->json() ?? [];
|
||||
|
||||
$records = [];
|
||||
$skip = 0;
|
||||
$take = 1000;
|
||||
|
||||
while (true) {
|
||||
$recResp = Http::withToken($token)
|
||||
->timeout(60)
|
||||
->get("$base/api/table/$tableId/record", [
|
||||
'fieldKeyType' => 'name',
|
||||
'take' => $take,
|
||||
'skip' => $skip,
|
||||
]);
|
||||
|
||||
if (! $recResp->successful()) {
|
||||
return ['success' => false, 'message' => "记录接口 HTTP {$recResp->status()}", 'fields' => $fields, 'records' => []];
|
||||
}
|
||||
|
||||
$body = $recResp->json() ?? [];
|
||||
$page = $body['records'] ?? [];
|
||||
$records = array_merge($records, $page);
|
||||
|
||||
if (count($page) < $take) {
|
||||
break;
|
||||
}
|
||||
$skip += $take;
|
||||
}
|
||||
|
||||
return ['success' => true, 'fields' => $fields, 'records' => $records];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage(), 'fields' => [], 'records' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户粘贴的整段 API 文档 / curl 命令中解析出连接信息。
|
||||
*
|
||||
* 容错策略:先清洗零宽字符/markdown 符号,再按 URL → Token → 表名顺序多模式兜底,
|
||||
* 解析不完整时返回 missing 清单与已找到的部分,供前端手动补齐。
|
||||
*
|
||||
* @return array{success:bool,baseUrl:string,tableId:string,token:string,name:string,missing:array,message?:string}
|
||||
*/
|
||||
public function parseConfig(string $text): array
|
||||
{
|
||||
// 粘贴文本可能带零宽字符(U+200B 等)或 markdown 装饰,先做无害化清洗
|
||||
$clean = (string) preg_replace('/[\x{200B}-\x{200D}\x{2060}\x{FEFF}\x{00AD}]/u', '', (string) $text);
|
||||
$clean = str_replace(["\r\n", "\r"], "\n", $clean);
|
||||
$clean = str_replace(['`', '**'], ['', ''], $clean);
|
||||
|
||||
$base = '';
|
||||
$tableId = '';
|
||||
$token = '';
|
||||
$name = '';
|
||||
|
||||
// 1) 表名:# Table: xxx(兼容中英文冒号)
|
||||
if (preg_match('/^#+\s*Table\s*[::]\s*(.+)$/mi', $clean, $m)) {
|
||||
$name = trim($m[1]);
|
||||
}
|
||||
|
||||
// 2) 收集所有 URL,优先取含 /api/table/ 或 /table/ 的完整地址
|
||||
$urls = [];
|
||||
if (preg_match_all('#https?://[^\s"\'<>()\[\]]+#', $clean, $mm)) {
|
||||
$urls = $mm[0];
|
||||
}
|
||||
foreach ($urls as $u) {
|
||||
if ($tableId === '' && preg_match('#/(?:api/)?table/([A-Za-z0-9_-]+)#', $u, $m)) {
|
||||
$tableId = $m[1];
|
||||
if (preg_match('#^(https?://[^/\s]+)#', $u, $bm)) {
|
||||
$base = $bm[1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Token:Bearer 前缀 → Token:/API Key: 行 → 任意位置 teable_ 开头串
|
||||
$tokPatterns = [
|
||||
'#(?:Bearer|bearer)\s+([A-Za-z0-9_.+/=~%-]+)#',
|
||||
'#(?:Token|token|API\s*Key|api\s*key)\s*[::=]\s*([A-Za-z0-9_.+/=~%-]+)#',
|
||||
'#(teable_[A-Za-z0-9_.+/=~%-]+)#',
|
||||
];
|
||||
foreach ($tokPatterns as $p) {
|
||||
if (preg_match($p, $clean, $m)) {
|
||||
$token = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 表 ID 兜底:任意 tbl 开头长串
|
||||
if ($tableId === '' && preg_match('#\btbl[A-Za-z0-9_-]{8,}#i', $clean, $m)) {
|
||||
$tableId = $m[0];
|
||||
}
|
||||
|
||||
// 5) Base URL 兜底:Base URL 行(值可在下一行)→ 任意 URL 的 host
|
||||
if ($base === '') {
|
||||
if (preg_match('#(?:Base\s*URL|baseUrl|基础\s*URL)\s*[::]\s*(https?://[^\s"\'<>()\[\]]+)#i', $clean, $m)
|
||||
|| preg_match('#(?:Base\s*URL|baseUrl|基础\s*URL)\s*[::]?\s*\n\s*(https?://[^\s"\'<>()\[\]]+)#i', $clean, $m)) {
|
||||
$base = rtrim($m[1], '/');
|
||||
} elseif (! empty($urls)) {
|
||||
if (preg_match('#^(https?://[^/\s]+)#', $urls[0], $bm)) {
|
||||
$base = $bm[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
if ($base === '') {
|
||||
$missing[] = 'Base URL';
|
||||
}
|
||||
if ($tableId === '') {
|
||||
$missing[] = 'Table ID';
|
||||
}
|
||||
if ($token === '') {
|
||||
$missing[] = 'Token';
|
||||
}
|
||||
|
||||
if (! empty($missing)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '缺少:'.implode('、', $missing).'。请确认粘贴内容包含表地址(https://…/api/table/tbl…)和 Token(teable_…),或直接在下方输入框手动补齐',
|
||||
'baseUrl' => $base,
|
||||
'tableId' => $tableId,
|
||||
'token' => $token,
|
||||
'name' => $name,
|
||||
'missing' => $missing,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '解析成功',
|
||||
'baseUrl' => $base,
|
||||
'tableId' => $tableId,
|
||||
'token' => $token,
|
||||
'name' => $name,
|
||||
'missing' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
499
app/Domain/Bom/Templates/detail.blade.php
Normal file
499
app/Domain/Bom/Templates/detail.blade.php
Normal 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 curl -X GET "https://table.universal-onebot.com/api/table/tblXXX/record?fieldKeyType=name" \ -H "Authorization: Bearer teable_...""></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;">×</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
|
||||
469
app/Domain/Bom/Templates/detail.blade.php.bak-handsontable
Normal file
469
app/Domain/Bom/Templates/detail.blade.php.bak-handsontable
Normal 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 curl -X GET "https://table.universal-onebot.com/api/table/tblXXX/record?fieldKeyType=name" \ -H "Authorization: Bearer teable_...""></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;">×</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
|
||||
648
app/Domain/Bom/Templates/detail.blade.php.bak-pre-designer
Normal file
648
app/Domain/Bom/Templates/detail.blade.php.bak-pre-designer
Normal 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 curl -X GET "https://table.universal-onebot.com/api/table/tblXXX/record?fieldKeyType=name" \ -H "Authorization: Bearer teable_...""></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;">×</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
|
||||
164
app/Domain/Bom/Templates/refDetail.blade.php
Normal file
164
app/Domain/Bom/Templates/refDetail.blade.php
Normal 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="删除列">×</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
|
||||
80
app/Domain/Bom/Templates/refs.blade.php
Normal file
80
app/Domain/Bom/Templates/refs.blade.php
Normal 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
|
||||
129
app/Domain/Bom/Templates/show.blade.php
Normal file
129
app/Domain/Bom/Templates/show.blade.php
Normal 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
|
||||
55
app/Domain/Bom/Tools/AddMasterColumnTool.php
Normal file
55
app/Domain/Bom/Tools/AddMasterColumnTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Bom\Services\Bom;
|
||||
|
||||
/**
|
||||
* 给主数据(BOM/工艺文件/工具清单)添加一个动态列。
|
||||
*/
|
||||
class AddMasterColumnTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Bom $bomService,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'addMasterColumn';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '给主数据(BOM/工艺文件/工具清单)添加一个动态列(自定义字段)。key 为存储标识(英文或中文),label 为显示名。';
|
||||
}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('主数据 ID。')->required()
|
||||
->string('key')->description('列标识(英文或中文,用于存储)。')->required()
|
||||
->string('label')->description('列显示名,默认同 key。');
|
||||
}
|
||||
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
$key = (string) ($arguments['key'] ?? '');
|
||||
$label = (string) ($arguments['label'] ?? $key);
|
||||
|
||||
if ($key === '') {
|
||||
return ToolResult::error('key 不能为空');
|
||||
}
|
||||
|
||||
$columnId = $this->bomService->addColumn($id, $key, $label);
|
||||
|
||||
if ($columnId > 0) {
|
||||
return ToolResult::text("动态列添加成功:{$key}(columnId {$columnId})。");
|
||||
}
|
||||
|
||||
return ToolResult::error('添加列失败(可能无权限、key 冲突固定列、或主数据不存在)。');
|
||||
}
|
||||
}
|
||||
83
app/Domain/Bom/Tools/AddMasterItemTool.php
Normal file
83
app/Domain/Bom/Tools/AddMasterItemTool.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Bom\Services\Bom;
|
||||
|
||||
/**
|
||||
* 给主数据(BOM/工艺文件/工具清单)添加一条明细行。
|
||||
*/
|
||||
class AddMasterItemTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Bom $bomService,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'addMasterItem';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '给主数据(BOM/工艺文件/工具清单)添加一条明细行。固定列:seq(序号)、partNo(零件编号)、partName(零件名称)、partDrawingNo(零件图号)、material(材质)、spec(零件规格)、qtyPerUnit(单件用量)、unit(单位)、process(工序)、remark(备注);动态列用 key 传值。';
|
||||
}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('主数据 ID。')->required()
|
||||
->integer('seq')->description('序号。')
|
||||
->string('partNo')->description('零件编号。')
|
||||
->string('partName')->description('零件名称。')
|
||||
->string('partDrawingNo')->description('零件图号。')
|
||||
->string('material')->description('材质。')
|
||||
->string('spec')->description('零件规格。')
|
||||
->string('qtyPerUnit')->description('单件用量。')
|
||||
->string('unit')->description('单位。')
|
||||
->string('process')->description('工序。')
|
||||
->string('remark')->description('备注。')
|
||||
->raw('dynamicColumns', [
|
||||
'type' => 'object',
|
||||
'description' => '动态列的值,键为列 key,值为字符串。',
|
||||
'additionalProperties' => ['type' => 'string'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
|
||||
$values = [
|
||||
'seq' => (int) ($arguments['seq'] ?? 0),
|
||||
'partNo' => (string) ($arguments['partNo'] ?? ''),
|
||||
'partName' => (string) ($arguments['partName'] ?? ''),
|
||||
'partDrawingNo' => (string) ($arguments['partDrawingNo'] ?? ''),
|
||||
'material' => (string) ($arguments['material'] ?? ''),
|
||||
'spec' => (string) ($arguments['spec'] ?? ''),
|
||||
'qtyPerUnit' => (string) ($arguments['qtyPerUnit'] ?? ''),
|
||||
'unit' => (string) ($arguments['unit'] ?? ''),
|
||||
'process' => (string) ($arguments['process'] ?? ''),
|
||||
'remark' => (string) ($arguments['remark'] ?? ''),
|
||||
];
|
||||
|
||||
// 动态列
|
||||
$dynamic = $arguments['dynamicColumns'] ?? [];
|
||||
if (is_array($dynamic)) {
|
||||
foreach ($dynamic as $k => $v) {
|
||||
$values[(string) $k] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
$itemId = $this->bomService->saveItem($id, $values);
|
||||
|
||||
if ($itemId > 0) {
|
||||
return ToolResult::text("明细行添加成功:itemId {$itemId}。");
|
||||
}
|
||||
|
||||
return ToolResult::error('添加明细行失败(可能无权限或主数据不存在)。');
|
||||
}
|
||||
}
|
||||
64
app/Domain/Bom/Tools/CreateMasterTool.php
Normal file
64
app/Domain/Bom/Tools/CreateMasterTool.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Bom\Services\Bom;
|
||||
|
||||
/**
|
||||
* 创建全局主数据(BOM/工艺文件/工具清单)。
|
||||
*/
|
||||
class CreateMasterTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Bom $bomService,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'createMaster';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '创建一个全局主数据(BOM/工艺文件/工具清单)。主数据是跨项目共享的全局资源,创建后所有项目可见。';
|
||||
}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->string('type')->description('主数据类型:bom | process | tooling,默认 bom。')
|
||||
->string('bomNo')->description('编号。')->required()
|
||||
->string('productName')->description('名称/产品名。')->required()
|
||||
->string('specification')->description('规格型号。')
|
||||
->string('drawingNo')->description('图号。')
|
||||
->string('version')->description('版本。')
|
||||
->string('remark')->description('备注。');
|
||||
}
|
||||
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$type = (string) ($arguments['type'] ?? 'bom');
|
||||
if (! in_array($type, ['bom', 'process', 'tooling'], true)) {
|
||||
$type = 'bom';
|
||||
}
|
||||
|
||||
$id = $this->bomService->createBom([
|
||||
'type' => $type,
|
||||
'bomNo' => (string) ($arguments['bomNo'] ?? ''),
|
||||
'productName' => (string) ($arguments['productName'] ?? ''),
|
||||
'specification' => (string) ($arguments['specification'] ?? ''),
|
||||
'drawingNo' => (string) ($arguments['drawingNo'] ?? ''),
|
||||
'version' => (string) ($arguments['version'] ?? ''),
|
||||
'remark' => (string) ($arguments['remark'] ?? ''),
|
||||
]);
|
||||
|
||||
if ($id > 0) {
|
||||
return ToolResult::text("主数据创建成功,ID: {$id}(类型 {$type})。");
|
||||
}
|
||||
|
||||
return ToolResult::error('主数据创建失败(可能无权限或参数不完整)。');
|
||||
}
|
||||
}
|
||||
63
app/Domain/Bom/Tools/DeleteMasterTool.php
Normal file
63
app/Domain/Bom/Tools/DeleteMasterTool.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Bom\Services\Bom;
|
||||
|
||||
/**
|
||||
* 删除全局主数据(BOM/工艺文件/工具清单)——高风险写操作。
|
||||
*
|
||||
* 会级联删除该主数据的全部明细行、动态列、Teable 数据源。
|
||||
* 调用方(AI)应先通过 getMasterDetail 列出将删除的内容并征得用户确认。
|
||||
*/
|
||||
class DeleteMasterTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Bom $bomService,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'deleteMaster';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '删除一个全局主数据(BOM/工艺文件/工具清单)。高风险操作:会级联删除其全部明细行、动态列、数据源,不可恢复。调用前必须先 getMasterDetail 列出内容并征得用户明确确认。';
|
||||
}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('要删除的主数据 ID。')->required()
|
||||
->boolean('confirmed')->description('用户是否已明确确认删除。必须为 true 才执行。')->required();
|
||||
}
|
||||
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
$confirmed = (bool) ($arguments['confirmed'] ?? false);
|
||||
|
||||
if (! $confirmed) {
|
||||
return ToolResult::error('未确认删除。请先 getMasterDetail 列出将删除的内容,征得用户确认后再传 confirmed=true。');
|
||||
}
|
||||
|
||||
$detail = $this->bomService->getBomDetail($id);
|
||||
if ($detail === false) {
|
||||
return ToolResult::error("主数据不存在或无权访问:{$id}");
|
||||
}
|
||||
|
||||
$bom = $detail['bom'] ?? [];
|
||||
$itemCount = count($detail['items'] ?? []);
|
||||
$label = trim((string) ($bom['bomNo'] ?? '').' '.(string) ($bom['productName'] ?? ''));
|
||||
|
||||
if ($this->bomService->deleteBom($id)) {
|
||||
return ToolResult::text("主数据已删除:{$label}(ID {$id}),连同 {$itemCount} 行明细一并删除。");
|
||||
}
|
||||
|
||||
return ToolResult::error('删除失败(可能无权限)。');
|
||||
}
|
||||
}
|
||||
77
app/Domain/Bom/Tools/GetMasterDetailTool.php
Normal file
77
app/Domain/Bom/Tools/GetMasterDetailTool.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Bom\Services\Bom;
|
||||
|
||||
/**
|
||||
* 查看主数据(BOM/工艺文件/工具清单)详情:含固定列 + 动态列 + 明细行 + 数据源。
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetMasterDetailTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Bom $bomService,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getMasterDetail';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '查看主数据(BOM/工艺文件/工具清单)的完整明细,含固定列、动态列、明细行、Teable 数据源。';
|
||||
}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('主数据 ID。')->required()
|
||||
->integer('limit')->description('最多返回多少行明细,默认 50。');
|
||||
}
|
||||
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
$limit = (int) ($arguments['limit'] ?? 50);
|
||||
|
||||
$detail = $this->bomService->getBomDetail($id);
|
||||
if ($detail === false) {
|
||||
return ToolResult::error("主数据不存在或无权访问:{$id}");
|
||||
}
|
||||
|
||||
$bom = $detail['bom'] ?? [];
|
||||
$columns = $detail['columns'] ?? [];
|
||||
$items = $detail['items'] ?? [];
|
||||
|
||||
$result = [
|
||||
'id' => (int) ($bom['id'] ?? 0),
|
||||
'type' => (string) ($bom['type'] ?? 'bom'),
|
||||
'bomNo' => (string) ($bom['bomNo'] ?? ''),
|
||||
'productName' => Str::sanitizeForLLM((string) ($bom['productName'] ?? '')),
|
||||
'version' => (string) ($bom['version'] ?? ''),
|
||||
'columns' => array_map(fn ($c) => [
|
||||
'key' => (string) $c['key'],
|
||||
'label' => (string) ($c['label'] ?? $c['key']),
|
||||
], $columns),
|
||||
'itemCount' => count($items),
|
||||
'items' => array_slice(array_map(function ($it) {
|
||||
$row = [];
|
||||
foreach ($it as $k => $v) {
|
||||
if (is_scalar($v)) {
|
||||
$row[$k] = Str::sanitizeForLLM((string) $v);
|
||||
}
|
||||
}
|
||||
return $row;
|
||||
}, $items), 0, $limit),
|
||||
];
|
||||
|
||||
return ToolResult::json($result);
|
||||
}
|
||||
}
|
||||
65
app/Domain/Bom/Tools/ListMastersTool.php
Normal file
65
app/Domain/Bom/Tools/ListMastersTool.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Bom\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Bom\Services\Bom;
|
||||
|
||||
/**
|
||||
* 列出全局主数据(BOM / 工艺文件 / 工具清单)。
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class ListMastersTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Bom $bomService,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'listMasters';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '列出全局主数据(BOM / 工艺文件 / 工具清单)。主数据是跨项目共享的全局资源,type 取值:bom=物料清单、process=工艺文件、tooling=工具清单。';
|
||||
}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->string('type')->description('主数据类型:bom | process | tooling,默认 bom。');
|
||||
}
|
||||
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$type = (string) ($arguments['type'] ?? 'bom');
|
||||
if (! in_array($type, ['bom', 'process', 'tooling'], true)) {
|
||||
$type = 'bom';
|
||||
}
|
||||
|
||||
$masters = $this->bomService->getMasters($type, 0);
|
||||
|
||||
if (empty($masters)) {
|
||||
return ToolResult::text("无 {$type} 主数据。");
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($masters as $m) {
|
||||
$rows[] = [
|
||||
'id' => (int) $m['id'],
|
||||
'type' => (string) ($m['type'] ?? 'bom'),
|
||||
'bomNo' => (string) ($m['bomNo'] ?? ''),
|
||||
'productName' => Str::sanitizeForLLM((string) ($m['productName'] ?? '')),
|
||||
'version' => (string) ($m['version'] ?? ''),
|
||||
'specification' => Str::sanitizeForLLM((string) ($m['specification'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return ToolResult::json(['type' => $type, 'count' => count($rows), 'items' => $rows]);
|
||||
}
|
||||
}
|
||||
47
app/Domain/Bom/routes.php
Normal file
47
app/Domain/Bom/routes.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Leantime\Domain\Bom\Controllers\Api;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| BOM Domain Routes
|
||||
|--------------------------------------------------------------------------
|
||||
| JSON API for the BOM (bill-of-materials) module. All endpoints self-authorize
|
||||
| inside Bom\Services\Bom against the BOM's owning project; the CheckPermissions
|
||||
| middleware is applied to native routes by the RouteLoader.
|
||||
*/
|
||||
|
||||
Route::prefix('bom/api')->group(function () {
|
||||
Route::get('/{id}', [Api::class, 'detail']);
|
||||
Route::post('/', [Api::class, 'store']);
|
||||
Route::put('/{id}', [Api::class, 'update']);
|
||||
Route::delete('/{id}', [Api::class, 'destroy']);
|
||||
|
||||
Route::post('/{id}/item', [Api::class, 'saveItem']);
|
||||
Route::delete('/item/{itemId}', [Api::class, 'deleteItem']);
|
||||
Route::post('/{id}/attachment', [Api::class, 'uploadAttachment']);
|
||||
|
||||
Route::post('/{id}/column', [Api::class, 'addColumn']);
|
||||
Route::delete('/column/{columnId}', [Api::class, 'deleteColumn']);
|
||||
Route::post('/{id}/column-visibility', [Api::class, 'setHiddenColumns']);
|
||||
|
||||
Route::post('/{id}/source', [Api::class, 'addSource']);
|
||||
Route::delete('/source/{sourceId}', [Api::class, 'deleteSource']);
|
||||
|
||||
Route::post('/{id}/import/teable', [Api::class, 'importTeable']);
|
||||
Route::post('/{id}/import/teable-paste', [Api::class, 'importTeablePaste']);
|
||||
Route::post('/{id}/import/excel', [Api::class, 'importExcel']);
|
||||
Route::post('/teable/parse', [Api::class, 'parseTeable']);
|
||||
|
||||
Route::get('/{id}/export', [Api::class, 'export']);
|
||||
Route::get('/{id}/export-template', [Api::class, 'exportTemplate']);
|
||||
|
||||
// 全局主数据:项目引用层(单向,不写回全局)
|
||||
Route::post('/ref/link', [Api::class, 'linkMaster']);
|
||||
Route::delete('/ref/{refId}', [Api::class, 'unlinkMaster']);
|
||||
Route::get('/ref/{refId}', [Api::class, 'refDetail']);
|
||||
Route::post('/ref/{refId}/column', [Api::class, 'addRefColumn']);
|
||||
Route::delete('/ref-column/{columnId}', [Api::class, 'deleteRefColumn']);
|
||||
Route::post('/ref/{refId}/value', [Api::class, 'saveRefValue']);
|
||||
});
|
||||
Reference in New Issue
Block a user