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,66 @@
@props([
'statusCounts' => [],
'statusColumns' => [],
'totalCount' => 0,
'expandOnHover' => true,
'size' => 'md'
])
@php
// Create segments for ALL status columns (even with 0 count)
// This ensures JavaScript can update any segment when tickets move
$segments = [];
foreach ($statusColumns as $statusId => $label) {
$count = $statusCounts[$statusId] ?? 0;
$percentage = ($totalCount > 0 && $count > 0) ? ($count / $totalCount) * 100 : 0;
$segments[] = [
'id' => $statusId,
'count' => $count,
'percentage' => round($percentage, 1),
'label' => is_array($label) ? ($label['name'] ?? $label['label'] ?? "Status {$statusId}") : $label,
];
}
$heights = [
'collapsed' => ['sm' => '4px', 'md' => '5px', 'lg' => '6px'],
'expanded' => ['sm' => '18px', 'md' => '22px', 'lg' => '26px']
];
$collapsedHeight = $heights['collapsed'][$size] ?? '5px';
$expandedHeight = $heights['expanded'][$size] ?? '22px';
@endphp
<div {{ $attributes->merge(['class' => 'micro-progress-bar']) }}
role="progressbar"
aria-label="Status breakdown"
style="position: relative; width: 100%;"
onmouseenter="this.querySelector('.progress-segments').style.height='{{ $expandedHeight }}'; this.querySelector('.progress-segments').style.borderRadius='4px';"
onmouseleave="this.querySelector('.progress-segments').style.height='{{ $collapsedHeight }}'; this.querySelector('.progress-segments').style.borderRadius='2.5px';">
<div class="progress-segments"
style="display: flex; align-items: stretch; height: {{ $collapsedHeight }}; border-radius: 2.5px; overflow: hidden; background-color: #D4D4D4; width: 100%; transition: height 0.2s ease, border-radius 0.2s ease; cursor: {{ $expandOnHover && $totalCount > 0 ? 'pointer' : 'default' }};">
@foreach($segments as $segment)
{{-- Render ALL segments (including empty ones) so JavaScript can update them after card moves --}}
<div class="status-segment status-{{ $segment['id'] }}"
style="flex: {{ $segment['percentage'] }} 1 0%; overflow: hidden;"
data-tippy-content="{{ $segment['count'] > 0 ? $segment['label'] . ': ' . $segment['count'] : '' }}">
<span class="segment-count">{{ $segment['count'] > 0 ? $segment['count'] : '' }}</span>
</div>
@endforeach
</div>
@if($totalCount > 0)
<!-- Screen reader summary -->
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
@foreach($segments as $segment)
@if($segment['count'] > 0)
{{ $segment['label'] }}: {{ $segment['count'] }}.
@endif
@endforeach
</span>
@else
<!-- Empty state for screen readers -->
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
No tasks in this group.
</span>
@endif
</div>

View File

@@ -0,0 +1,15 @@
@props([
'label' => '',
'size' => 'md'
])
@php
$sizes = ['sm' => '14px', 'md' => '16px', 'lg' => '20px'];
$fontSize = $sizes[$size] ?? '16px';
@endphp
<span {{ $attributes->merge(['class' => 'milestone-icon']) }}
style="font-size: {{ $fontSize }}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 24px;"
data-tippy-content="Milestone: {{ $label }}"
role="img"
aria-label="Milestone: {{ $label }}">🎯</span>

View File

@@ -0,0 +1,15 @@
@props([
'label' => '',
'size' => 'md'
])
@php
$sizes = ['sm' => '14px', 'md' => '16px', 'lg' => '20px'];
$fontSize = $sizes[$size] ?? '16px';
@endphp
<span {{ $attributes->merge(['class' => 'sprint-icon']) }}
style="font-size: {{ $fontSize }}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 24px;"
data-tippy-content="Sprint: {{ $label }}"
role="img"
aria-label="Sprint: {{ $label }}">🏃</span>

View File

@@ -0,0 +1,150 @@
@props([
'groupBy' => 'priority',
'groupId' => null,
'label' => '',
'totalCount' => 0,
'statusCounts' => [],
'statusColumns' => [],
'expanded' => true,
'moreInfo' => null,
'timeAlert' => null
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
// Determine which icon component to use
$iconComponent = match($groupBy) {
'priority' => 'thermometer-icon',
'storypoints' => 'tshirt-icon',
'effort' => 'tshirt-icon',
'editorId' => 'user-avatar',
'milestoneid' => 'milestone-icon',
'type' => 'type-icon',
'sprint' => 'sprint-icon',
'dueDate' => null, // No icon for due date buckets - label is sufficient
default => null // Status and other groupings use FontAwesome icon below
};
// For groupBy types without a component, use FontAwesome icon
$faIcon = match($groupBy) {
'status' => 'fa-circle-dot',
'milestoneid' => null, // No icon for milestones
'dueDate' => null, // No icon for due date buckets - label is sufficient
default => 'fa-layer-group'
};
$iconProps = match($groupBy) {
'priority' => ['priority' => (int)$groupId],
'storypoints' => ['effort' => (float)$groupId],
'effort' => ['effort' => (float)$groupId],
'editorId' => ['userId' => $groupId, 'username' => $label],
'type' => ['type' => $groupId],
default => ['label' => $label]
};
// Effort groupby shows size label next to icon
$effortLabel = '';
if (in_array($groupBy, ['storypoints', 'effort'])) {
$effortLabel = TicketDesignTokens::getEffort((float)$groupId)['tshirtLabel'] ?? '';
}
// Strip existing profileImage HTML from label for editorId (we use user-avatar component instead)
if ($groupBy === 'editorId') {
$label = preg_replace('/<div class=[\'"]profileImage[\'"]>.*?<\/div>\s*/i', '', $label);
}
// Transform statusColumns for micro-progress-bar
$statusLabels = [];
foreach ($statusColumns as $statusId => $statusData) {
if (is_array($statusData)) {
$statusLabels[$statusId] = $statusData['name'] ?? $statusData['label'] ?? "Status $statusId";
} else {
$statusLabels[$statusId] = $statusData;
}
}
@endphp
{{-- PRD v2 Compliant: 150px horizontal layout with two rows --}}
{{-- Outer container stretches full height, inner content scrolls/sticks --}}
<div {{ $attributes->merge(['class' => 'kanban-swimlane-sidebar']) }}
data-swimlane-id="{{ $groupId }}"
tabindex="0"
role="button"
aria-expanded="{{ $expanded ? 'true' : 'false' }}"
aria-controls="swimlane-content-{{ $groupId }}"
aria-label="{{ strip_tags($label) }} - {{ $totalCount }} tasks - {{ $expanded ? 'Expanded' : 'Collapsed' }}"
onclick="leantime.kanbanController.toggleSwimlane('{{ $groupId }}')"
onkeydown="if(event.key === 'Enter' || event.key === ' ') { event.preventDefault(); leantime.kanbanController.toggleSwimlane('{{ $groupId }}'); }">
{{-- Inner content wrapper - this receives the transform for sticky behavior --}}
<div class="kanban-swimlane-sidebar-inner">
{{-- Row 1: Chevron + Icon + Label + Time Indicator --}}
<div class="swimlane-header-row1">
{{-- Chevron ( expanded, collapsed) --}}
<span class="kanban-lane-chevron">
<i class="fa fa-chevron-{{ $expanded ? 'down' : 'right' }}"></i>
</span>
{{-- Visual indicator (icon/avatar) --}}
@if($iconComponent)
<div class="kanban-indicator">
<x-dynamic-component
:component="'global::kanban.' . $iconComponent"
:attributes="new \Illuminate\View\ComponentAttributeBag($iconProps)"
size="md"
/>
</div>
@else
{{-- Default FontAwesome icon for status and other groupings --}}
<span class="kanban-indicator">
<i class="fa {{ $faIcon }} kanban-indicator-icon"></i>
</span>
@endif
{{-- Label - truncates with ellipsis --}}
<span class="swimlane-header-label" data-tippy-content="{{ strip_tags($label) }}">
{!! $label !!}
</span>
{{-- Time indicator ( 💤) - hidden when collapsed --}}
@if($timeAlert)
<span class="swimlane-time-indicator">
<x-global::kanban.time-indicator :type="$timeAlert" />
</span>
@endif
{{-- Count Badge (inline) - only visible when collapsed --}}
<span class="kanban-lane-count kanban-lane-count--inline" data-tippy-content="{{ $totalCount }} tasks">{{ $totalCount }}</span>
</div>
{{-- Row 2: Progress Bar + Count Badge --}}
<div class="swimlane-header-row2">
{{-- Micro Progress Bar (status breakdown) - always shown, gray when empty --}}
<div style="flex: 1; min-width: 0;">
<x-global::kanban.micro-progress-bar
:statusCounts="$statusCounts"
:statusColumns="$statusLabels"
:totalCount="$totalCount"
:expandOnHover="true"
size="lg"
/>
</div>
{{-- Count Badge --}}
<span class="kanban-lane-count" data-tippy-content="{{ $totalCount }} tasks">{{ $totalCount }}</span>
</div>
</div>{{-- .kanban-swimlane-sidebar-inner --}}
</div>
{{-- Tooltip shown on hover for long labels --}}
@if(strlen(strip_tags($label)) > 12 || $moreInfo)
<div class="kanban-sidebar-tooltip">
<div class="tooltip-label">{!! $label !!}</div>
@if($moreInfo)
<div class="tooltip-info">{!! $moreInfo !!}</div>
@endif
</div>
@endif

View File

@@ -0,0 +1,65 @@
@props([
'priority' => 3,
'size' => 'md',
'showLabel' => false
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
$token = TicketDesignTokens::getPriority($priority);
$fillPercent = $token['fill'] ?? 0.6;
$label = $token['label'] ?? 'Medium';
$color = $token['color'] ?? '#F5A623';
$sizes = [
'sm' => ['width' => 16, 'height' => 24],
'md' => ['width' => 18, 'height' => 28],
'lg' => ['width' => 22, 'height' => 34]
];
$sizeConfig = $sizes[$size] ?? $sizes['md'];
@endphp
<span {{ $attributes->merge(['class' => 'thermometer-icon']) }}
style="display: inline-flex; align-items: center; gap: 4px;"
data-tippy-content="{{ $label }} Priority">
<svg
width="{{ $sizeConfig['width'] }}"
height="{{ $sizeConfig['height'] }}"
viewBox="0 0 14 24"
style="flex-shrink: 0;">
<!-- Background/outline -->
<path
d="M7 2C5 2 3.5 3.5 3.5 5.5V14.5C1.8 15.5 1 17 1 18.5C1 21 3 23 7 23C11 23 13 21 13 18.5C13 17 12.2 15.5 10.5 14.5V5.5C10.5 3.5 9 2 7 2Z"
fill="#F5F5F0"
stroke="#D4D4D4"
stroke-width="1.5"/>
<!-- Colored fill (varies by priority) -->
<rect
x="5"
y="{{ 17 - ($fillPercent * 10) }}"
width="4"
height="{{ ($fillPercent * 10) + 1 }}"
fill="{{ $color }}"/>
<!-- Bulb (colored) -->
<circle cx="7" cy="18.5" r="3.5" fill="{{ $color }}"/>
<!-- Tick marks -->
<line x1="10.5" y1="7" x2="12" y2="7" stroke="#D4D4D4" stroke-width="1"/>
<line x1="10.5" y1="10" x2="12" y2="10" stroke="#D4D4D4" stroke-width="1"/>
<line x1="10.5" y1="13" x2="12" y2="13" stroke="#D4D4D4" stroke-width="1"/>
</svg>
@if($showLabel)
<span style="font-size: 14px; font-weight: 500; color: {{ $color }};">
{{ $label }}
</span>
@endif
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
{{ $label }} Priority
</span>
</span>

View File

@@ -0,0 +1,25 @@
@props([
'type' => null
])
@php
if (!$type) {
return;
}
$configs = [
'dueSoon' => ['icon' => '⏳', 'label' => 'Due Soon - Within 3 days'],
'overdue' => ['icon' => '⏰', 'label' => 'Overdue - Past due date'],
'stale' => ['icon' => '💤', 'label' => 'Stale - No activity for 14+ days']
];
$config = $configs[$type] ?? null;
if (!$config) {
return;
}
@endphp
<span {{ $attributes->merge(['class' => 'time-indicator']) }}
style="font-size: 18px; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;"
data-tippy-content="{{ $config['label'] }}"
aria-label="{{ $config['label'] }}">{{ $config['icon'] }}</span>

View File

@@ -0,0 +1,50 @@
@props([
'effort' => 3,
'size' => 'md',
'showLabel' => false
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
// Handle null/0/empty effort as "No Effort"
$isNoEffort = $effort === null || $effort === '' || $effort === 0 || $effort === '0';
$token = TicketDesignTokens::getEffort($effort);
$sizeLabel = $isNoEffort ? 'No Effort' : ($token['tshirtLabel'] ?? 'M');
$sizes = [
'sm' => ['width' => 20, 'height' => 18],
'md' => ['width' => 24, 'height' => 22],
'lg' => ['width' => 30, 'height' => 28]
];
$sizeConfig = $sizes[$size] ?? $sizes['md'];
$color = '#159A80'; // Brand teal from design
@endphp
<span {{ $attributes->merge(['class' => 'tshirt-icon']) }}
style="display: inline-flex; align-items: center; gap: 4px;"
data-tippy-content="{{ $sizeLabel }} Effort">
<svg
width="{{ $sizeConfig['width'] }}"
height="{{ $sizeConfig['height'] }}"
viewBox="0 0 24 22"
fill="none"
style="flex-shrink: 0;">
<!-- Plain t-shirt - NO text inside -->
<path
d="M8 1L4 1L1 5L4 7L4 21L20 21L20 7L23 5L20 1L16 1L14.5 3.5C14.5 3.5 13.5 5 12 5C10.5 5 9.5 3.5 9.5 3.5L8 1Z"
fill="{{ $color }}"
stroke="{{ $color }}"
stroke-width="1.5"
stroke-linejoin="round"/>
</svg>
@if($showLabel)
<span style="font-size: 14px; font-weight: 500;">{{ $sizeLabel }}</span>
@endif
<span style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;">
{{ $sizeLabel }} Effort
</span>
</span>

View File

@@ -0,0 +1,21 @@
@props([
'type' => 'task',
'size' => 'md'
])
@php
use Leantime\Domain\Tickets\Models\TicketDesignTokens;
$token = TicketDesignTokens::getType($type);
$icon = $token['icon'] ?? '📋';
$label = $token['label'] ?? 'Task';
$sizes = ['sm' => '14px', 'md' => '16px', 'lg' => '20px'];
$fontSize = $sizes[$size] ?? '16px';
@endphp
<span {{ $attributes->merge(['class' => 'type-icon']) }}
style="font-size: {{ $fontSize }}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 24px;"
data-tippy-content="Type: {{ $label }}"
role="img"
aria-label="Type: {{ $label }}">{{ $icon }}</span>

View File

@@ -0,0 +1,60 @@
@props([
'userId' => null,
'username' => '',
'size' => 'md'
])
@php
$sizeMap = [
'sm' => ['width' => '28px', 'height' => '28px', 'fontSize' => '13px'],
'md' => ['width' => '32px', 'height' => '32px', 'fontSize' => '14px'],
'lg' => ['width' => '40px', 'height' => '40px', 'fontSize' => '16px']
];
$sizeStyles = $sizeMap[$size] ?? $sizeMap['md'];
$initials = '';
if ($username) {
$parts = explode(' ', $username);
$initials = strtoupper(substr($parts[0], 0, 1));
if (count($parts) > 1) {
$initials .= strtoupper(substr($parts[1], 0, 1));
}
}
// Generate consistent color based on username
$colorPalette = [
['bg' => '#6B7A4D', 'text' => '#FFFFFF'], // Olive green
['bg' => '#5C8A8A', 'text' => '#FFFFFF'], // Teal
['bg' => '#8A6B5C', 'text' => '#FFFFFF'], // Brown
['bg' => '#7A6B8A', 'text' => '#FFFFFF'], // Purple
['bg' => '#6B8A7A', 'text' => '#FFFFFF'], // Sage
['bg' => '#8A7A6B', 'text' => '#FFFFFF'], // Tan
];
$defaultColor = ['bg' => '#D1D5DB', 'text' => '#6B7280']; // Gray for unassigned
if ($username && $username !== 'Unassigned') {
// Use hash to consistently assign color to same user
$hash = crc32($username);
$colorIndex = abs($hash) % count($colorPalette);
$colors = $colorPalette[$colorIndex];
} else {
$colors = $defaultColor;
}
@endphp
<div {{ $attributes->merge(['class' => 'user-avatar']) }}
style="display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; background-color: {{ $colors['bg'] }}; width: {{ $sizeStyles['width'] }}; height: {{ $sizeStyles['height'] }}; font-size: {{ $sizeStyles['fontSize'] }}; flex-shrink: 0;"
data-tippy-content="{{ $username }}">
@if($userId)
<img src="{{ BASE_URL }}/api/users?profileImage={{ $userId }}"
alt="{{ $username }}"
style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;"
onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-flex';"
loading="lazy">
<span style="font-weight: 600; color: {{ $colors['text'] }}; display: none;">{{ $initials }}</span>
@else
<span style="font-weight: 600; color: {{ $colors['text'] }};">{{ $initials }}</span>
@endif
</div>