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

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

View File

@@ -0,0 +1,286 @@
<?php
namespace Leantime\Domain\Tickets\Services;
use Leantime\Core\Domains\BaseService;
use Leantime\Domain\Bom\Permissions\BomPermissions;
use Leantime\Domain\Bom\Services\Bom as BomService;
use Leantime\Domain\Files\Permissions\FilesPermissions;
use Leantime\Domain\Files\Repositories\Files as FileRepository;
use Leantime\Domain\Files\Services\Files as FilesService;
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Repositories\TicketResource as TicketResourceRepository;
use Leantime\Domain\Tickets\Repositories\Tickets as TicketsRepository;
use Leantime\Domain\Wiki\Permissions\WikiPermissions;
use Leantime\Domain\Wiki\Services\Wiki as WikiService;
/**
* 待办事项(ticket/milestone) ↔ 资源关联服务。
*
* 5 类资源BOM / 工艺文件 / 工具清单(共用 zp_bomtype 区分、文件zp_file
* wikizp_canvas/zp_canvas_items。关联边落在 zp_entity_relationshipLinkedResource
* entityBType 用资源类型标识bom | process | tooling | file | wiki。
*/
class TicketResource extends BaseService
{
/** 资源类型 → 显示名 */
public const TYPE_NAMES = [
'bom' => 'BOM',
'process' => '工艺文件',
'tooling' => '工具清单',
'file' => '文件',
'wiki' => 'Wiki',
];
/** 主数据类型(对应 zp_bom.type */
public const MASTER_TYPES = ['bom', 'process', 'tooling'];
public function __construct(
protected TicketResourceRepository $repo,
protected TicketsRepository $ticketRepo,
protected BomService $bomService,
protected FilesService $filesService,
protected WikiService $wikiService,
protected FileRepository $fileRepository,
) {}
/**
* 关联一个资源到 ticket幂等
*
* @return array{success:bool,message?:string}
*/
public function link(int $ticketId, string $resourceType, int $resourceId): array
{
if ($ticketId <= 0 || $resourceId <= 0 || ! array_key_exists($resourceType, self::TYPE_NAMES)) {
return ['success' => false, 'message' => '无效的关联参数'];
}
$ticket = $this->ticketRepo->getTicket($ticketId);
if ($ticket === false) {
return ['success' => false, 'message' => '待办事项不存在'];
}
$projectId = (int) $ticket->projectId;
// 写关联需 tickets.edit读资源详情需对应资源 view 权限
$this->authorize(TicketsPermissions::EDIT, $projectId);
if (! $this->resourceExists($resourceType, $resourceId, $projectId)) {
return ['success' => false, 'message' => '资源不存在或不属于当前项目'];
}
$userId = (int) (session('userdata.id') ?? 0);
$ok = $this->repo->addLink($ticketId, $resourceType, $resourceId, $userId);
return $ok
? ['success' => true]
: ['success' => false, 'message' => '关联失败'];
}
/**
* 解除一个资源关联。
*/
public function unlink(int $ticketId, string $resourceType, int $resourceId): array
{
if ($ticketId <= 0 || $resourceId <= 0) {
return ['success' => false, 'message' => '无效参数'];
}
$ticket = $this->ticketRepo->getTicket($ticketId);
if ($ticket === false) {
return ['success' => false, 'message' => '待办事项不存在'];
}
$this->authorize(TicketsPermissions::EDIT, (int) $ticket->projectId);
return $this->repo->removeLink($ticketId, $resourceType, $resourceId)
? ['success' => true]
: ['success' => false, 'message' => '解除失败'];
}
/**
* 某 ticket 已关联的资源详情列表(供详情页 chips 渲染)。
*
* @return array<int, array{type:string,typeName:string,id:int,title:string,subtitle?:string,url?:string}>
*/
public function getLinkedResources(int $ticketId): array
{
$links = $this->repo->getLinks($ticketId);
$result = [];
foreach ($links as $link) {
$detail = $this->resolveResourceDetail($link['type'], $link['id']);
if ($detail !== null) {
$result[] = $detail + ['type' => $link['type'], 'id' => $link['id']];
}
}
return $result;
}
/**
* 候选资源列表(供「添加关联」下拉)。
*
* @return array<int, array{type:string,typeName:string,items:array<int,array{id:int,title:string,subtitle?:string}>}>
*/
public function listCandidates(int $projectId): array
{
$groups = [];
// 主数据BOM / 工艺文件 / 工具清单全局projectId=0
foreach (self::MASTER_TYPES as $type) {
$masters = $this->bomService->getMasters($type, 0);
$items = [];
foreach ($masters as $m) {
$title = trim(($m['bomNo'] ?? '').' '.($m['productName'] ?? ''));
if ($title === '') {
$title = '#'.($m['id'] ?? '');
}
$items[] = [
'id' => (int) $m['id'],
'title' => $title,
'subtitle' => ($m['version'] ?? '') !== '' ? '版本 '.$m['version'] : null,
];
}
$groups[] = ['type' => $type, 'typeName' => self::TYPE_NAMES[$type], 'items' => $items];
}
// 文件(项目文件)
try {
$files = $this->filesService->getFilesByModule('project', $projectId);
$items = [];
if (is_array($files)) {
foreach ($files as $f) {
$items[] = [
'id' => (int) $f['id'],
'title' => (string) ($f['realName'] ?? ''),
'subtitle' => ($f['firstname'] ?? '').' '.($f['lastname'] ?? ''),
];
}
}
$groups[] = ['type' => 'file', 'typeName' => self::TYPE_NAMES['file'], 'items' => $items];
} catch (\Throwable $e) {
$groups[] = ['type' => 'file', 'typeName' => self::TYPE_NAMES['file'], 'items' => []];
}
// Wiki项目 wiki 的标题作为可关联单元)
try {
$wikis = $this->wikiService->getAllProjectWikis($projectId);
$items = [];
if (is_array($wikis)) {
foreach ($wikis as $w) {
$items[] = [
'id' => (int) ($w['id'] ?? $w->id ?? 0),
'title' => (string) ($w['title'] ?? 'Wiki'),
];
}
}
$groups[] = ['type' => 'wiki', 'typeName' => self::TYPE_NAMES['wiki'], 'items' => $items];
} catch (\Throwable $e) {
$groups[] = ['type' => 'wiki', 'typeName' => self::TYPE_NAMES['wiki'], 'items' => []];
}
return $groups;
}
/**
* 校验资源是否存在(并做项目作用域兜底校验,主数据为全局)。
*/
private function resourceExists(string $resourceType, int $resourceId, int $projectId): bool
{
return match ($resourceType) {
'bom', 'process', 'tooling' => $this->bomService->getBom($resourceId) !== false,
'file' => $this->fileExists($resourceId),
'wiki' => $this->wikiExists($resourceId, $projectId),
default => false,
};
}
private function fileExists(int $fileId): bool
{
try {
$f = $this->filesService->getFileById($fileId);
return $f !== false;
} catch (\Throwable $e) {
return false;
}
}
private function wikiExists(int $wikiId, int $projectId): bool
{
try {
$wikis = $this->wikiService->getAllProjectWikis($projectId);
foreach ($wikis as $w) {
if ((int) ($w['id'] ?? $w->id ?? 0) === $wikiId) {
return true;
}
}
} catch (\Throwable $e) {
// ignore
}
return false;
}
/**
* 解析资源详情(供 chips / 弹窗)。
*
* @return array{type:string,typeName:string,id:int,title:string,subtitle?:string,url?:string}|null
*/
private function resolveResourceDetail(string $resourceType, int $resourceId): ?array
{
$typeName = self::TYPE_NAMES[$resourceType] ?? $resourceType;
if (in_array($resourceType, self::MASTER_TYPES, true)) {
$bom = $this->bomService->getBom($resourceId);
if ($bom === false) {
return null;
}
$title = trim(($bom['bomNo'] ?? '').' '.($bom['productName'] ?? ''));
if ($title === '') {
$title = '#'.$resourceId;
}
return [
'type' => $resourceType,
'typeName' => $typeName,
'id' => $resourceId,
'title' => $title,
'subtitle' => ($bom['version'] ?? '') !== '' ? '版本 '.$bom['version'] : null,
'url' => BASE_URL.'/bom/show/'.$resourceId,
];
}
if ($resourceType === 'file') {
$f = $this->fileRepository->getFile($resourceId);
if ($f === false) {
return null;
}
return [
'type' => 'file',
'typeName' => $typeName,
'id' => $resourceId,
'title' => (string) ($f['realName'] ?? ('文件 #'.$resourceId)),
'subtitle' => trim(($f['firstname'] ?? '').' '.($f['lastname'] ?? '')),
'url' => BASE_URL.'/files/get?fileId='.$resourceId,
];
}
if ($resourceType === 'wiki') {
try {
$article = $this->wikiService->getArticle($resourceId);
$title = (string) ($article->title ?? ('Wiki #'.$resourceId));
return [
'type' => 'wiki',
'typeName' => $typeName,
'id' => $resourceId,
'title' => $title,
'url' => BASE_URL.'/wiki/showArticle/'.$resourceId,
];
} catch (\Throwable $e) {
return null;
}
}
return null;
}
}