完善系统化待办文字展示、拆分与指定序位新增 #618
@ -48,81 +48,30 @@ class SystemizationTodoWorkbookImporter
|
||||
$header = $headerInfo['columns'];
|
||||
$headerRow = $headerInfo['row'];
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use (
|
||||
$rows = $this->workbookRows(
|
||||
$reader,
|
||||
$sheetIndex,
|
||||
$sheetName,
|
||||
$header,
|
||||
$headerRow,
|
||||
$companyId,
|
||||
$userId,
|
||||
&$created,
|
||||
&$skipped
|
||||
): void {
|
||||
$category = '';
|
||||
$section = '';
|
||||
foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) {
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
$categoryValue = $this->cell($cells, $header['category'] ?? null);
|
||||
if ($categoryValue !== '') {
|
||||
$category = $categoryValue;
|
||||
}
|
||||
$sectionValue = $this->cell($cells, $header['section'] ?? null);
|
||||
if ($sectionValue !== '') {
|
||||
$section = $sectionValue;
|
||||
}
|
||||
|
||||
$title = $this->cell($cells, $header['title'] ?? null);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$owner = $this->cell($cells, $header['owner'] ?? null);
|
||||
$assignee = $this->cell($cells, $header['assignee'] ?? null);
|
||||
$sourceKey = hash(
|
||||
'sha256',
|
||||
implode('|', [$companyId, $sheetName, $rowNumber, $category, $section, $title, $owner, $assignee])
|
||||
);
|
||||
);
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use ($rows, $companyId, $userId, &$created, &$skipped): void {
|
||||
foreach ($rows as $row) {
|
||||
$exists = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('source_key', $sourceKey)
|
||||
->where('source_key', $row['data']['source_key'])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$startText = $this->cell($cells, $header['start'] ?? null);
|
||||
$finishText = $this->cell($cells, $header['finish'] ?? null);
|
||||
[$startDate, $startNote] = $this->dateParts($startText);
|
||||
[$finishDate, $finishNote] = $this->dateParts($finishText);
|
||||
$progress = $this->cell($cells, $header['progress'] ?? null);
|
||||
$status = $this->status($progress, $startText, $finishText);
|
||||
$completedAt = $status === SystemizationTodo::STATUS_COMPLETED
|
||||
? ($finishDate ?? now()->toDateString())
|
||||
: null;
|
||||
|
||||
SystemizationTodo::withoutGlobalScopes()->create([
|
||||
...$row['data'],
|
||||
'company_id' => $companyId,
|
||||
'category' => $category !== '' ? $category : null,
|
||||
'section' => $section !== '' ? $section : null,
|
||||
'title' => $title,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'assignee' => $assignee !== '' ? $assignee : null,
|
||||
'start_date' => $startDate,
|
||||
'start_note' => $startNote !== '' ? $startNote : null,
|
||||
'due_date' => $finishDate,
|
||||
'due_note' => $finishNote !== '' ? $finishNote : null,
|
||||
'status' => $status,
|
||||
'completed_at' => $completedAt,
|
||||
'progress' => $progress !== '' ? $progress : null,
|
||||
'notes' => ($notes = $this->cell($cells, $header['notes'] ?? null)) !== '' ? $notes : null,
|
||||
'sort_order' => $rowNumber,
|
||||
'source_key' => $sourceKey,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
@ -181,7 +130,17 @@ class SystemizationTodoWorkbookImporter
|
||||
$matchedIds = array_values($matches);
|
||||
$retained = $existing
|
||||
->filter(static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $matchedIds, true))
|
||||
->sortBy(static fn (SystemizationTodo $todo): string =>
|
||||
sprintf('%010d-%010d', (int) $todo->sort_order, (int) $todo->id)
|
||||
)
|
||||
->values();
|
||||
$retainedSortOrder = max(
|
||||
(int) $existing->max('sort_order'),
|
||||
...array_map(
|
||||
static fn (array $row): int => (int) $row['data']['sort_order'],
|
||||
$rows,
|
||||
),
|
||||
) + 1;
|
||||
$oldSourceKeys = $existing->pluck('source_key', 'id')->all();
|
||||
$updated = 0;
|
||||
foreach ($matches as $rowIndex => $todoId) {
|
||||
@ -209,6 +168,7 @@ class SystemizationTodoWorkbookImporter
|
||||
$existing,
|
||||
$retained,
|
||||
$oldSourceKeys,
|
||||
$retainedSortOrder,
|
||||
$companyId,
|
||||
$userId,
|
||||
&$created,
|
||||
@ -262,11 +222,12 @@ class SystemizationTodoWorkbookImporter
|
||||
}
|
||||
|
||||
foreach ($retained as $todo) {
|
||||
if (($oldSourceKeys[$todo->id] ?? null) !== null) {
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->whereKey($todo->id)
|
||||
->update(['source_key' => $oldSourceKeys[$todo->id]]);
|
||||
}
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->whereKey($todo->id)
|
||||
->update([
|
||||
'sort_order' => $retainedSortOrder++,
|
||||
'source_key' => $oldSourceKeys[$todo->id] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -285,7 +246,16 @@ class SystemizationTodoWorkbookImporter
|
||||
|
||||
/**
|
||||
* @param array<int,int> $columns
|
||||
* @return array<int,array{row:int,category:string,section:string,title:string,data:array<string,mixed>}>
|
||||
* @return array<int,array{
|
||||
* row:int,
|
||||
* title_index:int,
|
||||
* title_count:int,
|
||||
* source_title:string,
|
||||
* category:string,
|
||||
* section:string,
|
||||
* title:string,
|
||||
* data:array<string,mixed>
|
||||
* }>
|
||||
*/
|
||||
private function workbookRows(
|
||||
PayablesWorkbookStreamReader $reader,
|
||||
@ -311,10 +281,12 @@ class SystemizationTodoWorkbookImporter
|
||||
$section = $sectionValue;
|
||||
}
|
||||
|
||||
$title = $this->cell($cells, $columns['title'] ?? null);
|
||||
if ($title === '') {
|
||||
$sourceTitle = $this->cell($cells, $columns['title'] ?? null);
|
||||
if ($sourceTitle === '') {
|
||||
continue;
|
||||
}
|
||||
$titles = $this->titleSegments($sourceTitle);
|
||||
$titleCount = count($titles);
|
||||
$owner = $this->cell($cells, $columns['owner'] ?? null);
|
||||
$assignee = $this->cell($cells, $columns['assignee'] ?? null);
|
||||
$startText = $this->cell($cells, $columns['start'] ?? null);
|
||||
@ -325,50 +297,108 @@ class SystemizationTodoWorkbookImporter
|
||||
$notes = $this->cell($cells, $columns['notes'] ?? null);
|
||||
$status = $this->status($progress, $startText, $finishText);
|
||||
|
||||
$rows[] = [
|
||||
'row' => $rowNumber,
|
||||
'category' => $category,
|
||||
'section' => $section,
|
||||
'title' => $title,
|
||||
'data' => [
|
||||
'category' => $category !== '' ? $category : null,
|
||||
'section' => $section !== '' ? $section : null,
|
||||
foreach ($titles as $offset => $title) {
|
||||
$titleIndex = $offset + 1;
|
||||
$sortOrder = ($rowNumber * 100) + $titleIndex;
|
||||
$rows[] = [
|
||||
'row' => $rowNumber,
|
||||
'title_index' => $titleIndex,
|
||||
'title_count' => $titleCount,
|
||||
'source_title' => $sourceTitle,
|
||||
'category' => $category,
|
||||
'section' => $section,
|
||||
'title' => $title,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'assignee' => $assignee !== '' ? $assignee : null,
|
||||
'start_date' => $startDate,
|
||||
'start_note' => $startNote !== '' ? $startNote : null,
|
||||
'due_date' => $finishDate,
|
||||
'due_note' => $finishNote !== '' ? $finishNote : null,
|
||||
'status' => $status,
|
||||
'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED
|
||||
? ($finishDate ?? now()->toDateString())
|
||||
: null,
|
||||
'progress' => $progress !== '' ? $progress : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'sort_order' => $rowNumber,
|
||||
'source_key' => hash(
|
||||
'sha256',
|
||||
implode('|', [
|
||||
$companyId,
|
||||
$sheetName,
|
||||
$rowNumber,
|
||||
$category,
|
||||
$section,
|
||||
$title,
|
||||
$owner,
|
||||
$assignee,
|
||||
])
|
||||
),
|
||||
],
|
||||
];
|
||||
'data' => [
|
||||
'category' => $category !== '' ? $category : null,
|
||||
'section' => $section !== '' ? $section : null,
|
||||
'title' => $title,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'assignee' => $assignee !== '' ? $assignee : null,
|
||||
'start_date' => $startDate,
|
||||
'start_note' => $startNote !== '' ? $startNote : null,
|
||||
'due_date' => $finishDate,
|
||||
'due_note' => $finishNote !== '' ? $finishNote : null,
|
||||
'status' => $status,
|
||||
'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED
|
||||
? ($finishDate ?? now()->toDateString())
|
||||
: null,
|
||||
'progress' => $progress !== '' ? $progress : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'sort_order' => $sortOrder,
|
||||
'source_key' => hash(
|
||||
'sha256',
|
||||
implode('|', [
|
||||
$companyId,
|
||||
$sheetName,
|
||||
$rowNumber,
|
||||
$titleIndex,
|
||||
$category,
|
||||
$section,
|
||||
$title,
|
||||
$owner,
|
||||
$assignee,
|
||||
])
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{row:int,category:string,section:string,title:string,data:array<string,mixed>}> $rows
|
||||
* Split a cell into separate tasks only when it contains multiple numbered lines.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function titleSegments(string $value): array
|
||||
{
|
||||
$value = trim(str_replace(["\r\n", "\r"], "\n", $value));
|
||||
$marker = '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u';
|
||||
$lines = preg_split('/\n/u', $value) ?: [];
|
||||
$numberedLines = array_filter(
|
||||
$lines,
|
||||
static fn (string $line): bool => preg_match($marker, trim($line)) === 1,
|
||||
);
|
||||
if (count($numberedLines) < 2) {
|
||||
return [$value];
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$current = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match($marker, $line) === 1) {
|
||||
if ($current !== '') {
|
||||
$segments[] = trim($current);
|
||||
}
|
||||
$current = trim((string) preg_replace($marker, '', $line));
|
||||
continue;
|
||||
}
|
||||
$current = $current === '' ? $line : $current . "\n" . $line;
|
||||
}
|
||||
if ($current !== '') {
|
||||
$segments[] = trim($current);
|
||||
}
|
||||
|
||||
return $segments !== [] ? $segments : [$value];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{
|
||||
* row:int,
|
||||
* title_index:int,
|
||||
* title_count:int,
|
||||
* source_title:string,
|
||||
* category:string,
|
||||
* section:string,
|
||||
* title:string,
|
||||
* data:array<string,mixed>
|
||||
* }> $rows
|
||||
* @return array{0:array<int,int>,1:array<int,int>}
|
||||
*/
|
||||
private function matchRows(array $rows, \Illuminate\Support\Collection $existing): array
|
||||
@ -381,6 +411,32 @@ class SystemizationTodoWorkbookImporter
|
||||
}
|
||||
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if ($row['title_count'] < 2 || $row['title_index'] !== 1) {
|
||||
continue;
|
||||
}
|
||||
$sourceKey = $this->matchTitleKey($row['source_title']);
|
||||
$available = array_values(array_filter(
|
||||
$candidates[$sourceKey] ?? [],
|
||||
fn (SystemizationTodo $todo): bool =>
|
||||
! in_array($todo->id, $usedIds, true)
|
||||
&& $this->matchText($todo->category) === $this->matchText($row['category'])
|
||||
&& $this->matchText($todo->section) === $this->matchText($row['section']),
|
||||
));
|
||||
if ($available === []) {
|
||||
continue;
|
||||
}
|
||||
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
|
||||
abs($a->sort_order - $row['data']['sort_order'])
|
||||
<=> abs($b->sort_order - $row['data']['sort_order'])
|
||||
);
|
||||
$matches[$rowIndex] = $available[0]->id;
|
||||
$usedIds[] = $available[0]->id;
|
||||
}
|
||||
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
if (isset($matches[$rowIndex])) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->matchTitleKey($row['title']);
|
||||
$available = array_values(array_filter(
|
||||
$candidates[$key] ?? [],
|
||||
@ -390,7 +446,8 @@ class SystemizationTodoWorkbookImporter
|
||||
continue;
|
||||
}
|
||||
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
|
||||
abs($a->sort_order - $row['row']) <=> abs($b->sort_order - $row['row'])
|
||||
abs($a->sort_order - $row['data']['sort_order'])
|
||||
<=> abs($b->sort_order - $row['data']['sort_order'])
|
||||
);
|
||||
$todo = $available[0];
|
||||
$matches[$rowIndex] = $todo->id;
|
||||
@ -419,7 +476,7 @@ class SystemizationTodoWorkbookImporter
|
||||
$pairs[] = [
|
||||
'rowIndex' => $rowIndex,
|
||||
'todoId' => $todoId,
|
||||
'score' => $similarity - abs($todo->sort_order - $row['row']) / 1000,
|
||||
'score' => $similarity - abs($todo->sort_order - $row['data']['sort_order']) / 1000,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -445,7 +502,7 @@ class SystemizationTodoWorkbookImporter
|
||||
private function matchTitleKey(?string $value): string
|
||||
{
|
||||
$value = preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
) ?? '';
|
||||
|
||||
@ -9,6 +9,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
@ -91,6 +92,8 @@ class SystemizationTodos extends Controller
|
||||
'order' => PHP_INT_MAX,
|
||||
'category_display' => '',
|
||||
'section_display' => '',
|
||||
'type_count' => 0,
|
||||
'content_count' => 0,
|
||||
];
|
||||
$todo->setAttribute('type_sequence', $sequence['type_sequence']);
|
||||
$todo->setAttribute('content_sequence', $sequence['content_sequence']);
|
||||
@ -98,8 +101,11 @@ class SystemizationTodos extends Controller
|
||||
$todo->setAttribute('hierarchy_order', $sequence['order']);
|
||||
$todo->setAttribute('category_display', $sequence['category_display']);
|
||||
$todo->setAttribute('section_display', $sequence['section_display']);
|
||||
$todo->setAttribute('type_count', $sequence['type_count']);
|
||||
$todo->setAttribute('content_count', $sequence['content_count']);
|
||||
});
|
||||
$items = $items->sortBy('hierarchy_order')->values();
|
||||
$positionGroups = $this->positionGroups($sequenceMap);
|
||||
if ($filters['type_sequence'] > 0) {
|
||||
$items = $items
|
||||
->filter(static fn (SystemizationTodo $todo): bool =>
|
||||
@ -158,6 +164,7 @@ class SystemizationTodos extends Controller
|
||||
'view' => 'business.pages.systemization-todos',
|
||||
'title' => '系统化待办',
|
||||
'items' => $items,
|
||||
'todoPositionGroups' => $positionGroups,
|
||||
'todoFilters' => $filters,
|
||||
'todoCategories' => $categories,
|
||||
'todoOwners' => $this->peopleOptions('owner'),
|
||||
@ -174,6 +181,8 @@ class SystemizationTodos extends Controller
|
||||
'todoRoutes' => [
|
||||
'store' => route('business.systemization-todos.store'),
|
||||
'update' => route('business.systemization-todos.update', ['todo' => '__TODO__']),
|
||||
'category' => route('business.systemization-todos.category', ['todo' => '__TODO__']),
|
||||
'section' => route('business.systemization-todos.section', ['todo' => '__TODO__']),
|
||||
'destroy' => route('business.systemization-todos.destroy', ['todo' => '__TODO__']),
|
||||
'attachment' => route('business.systemization-todos.attachments.show', [
|
||||
'todo' => '__TODO__',
|
||||
@ -241,6 +250,10 @@ class SystemizationTodos extends Controller
|
||||
$sequenceMap = [];
|
||||
$hierarchyOrder = 0;
|
||||
foreach ($categoryKeys as $categoryIndex => $categoryKey) {
|
||||
$typeCount = array_sum(array_map(
|
||||
static fn (array $rows): int => count($rows),
|
||||
$groupedRows[$categoryKey],
|
||||
));
|
||||
$contentKeys = array_keys($sectionOrder[$categoryKey]);
|
||||
usort($contentKeys, function (string $left, string $right) use (
|
||||
$categoryKey,
|
||||
@ -269,6 +282,8 @@ class SystemizationTodos extends Controller
|
||||
'order' => $hierarchyOrder++,
|
||||
'category_display' => $row['category_label'],
|
||||
'section_display' => $row['section_label'],
|
||||
'type_count' => $typeCount,
|
||||
'content_count' => count($contentRows),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -277,6 +292,30 @@ class SystemizationTodos extends Controller
|
||||
return $sequenceMap;
|
||||
}
|
||||
|
||||
private function positionGroups(array $sequenceMap): array
|
||||
{
|
||||
$groups = [];
|
||||
$todos = SystemizationTodo::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get(['id', 'category', 'section', 'sort_order']);
|
||||
foreach ($todos as $todo) {
|
||||
$sequence = $sequenceMap[$todo->id] ?? null;
|
||||
if ($sequence === null) {
|
||||
continue;
|
||||
}
|
||||
$key = $sequence['type_sequence'] . ':' . $sequence['content_sequence'];
|
||||
$groups[$key] ??= [
|
||||
'category' => (string) ($todo->category ?? ''),
|
||||
'section' => $sequence['section_display'],
|
||||
'count' => 0,
|
||||
];
|
||||
$groups[$key]['count']++;
|
||||
}
|
||||
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
private function compareHierarchyGroups(
|
||||
string $left,
|
||||
string $right,
|
||||
@ -293,7 +332,7 @@ class SystemizationTodos extends Controller
|
||||
private function hierarchyLabel(?string $value): string
|
||||
{
|
||||
return trim((string) preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
));
|
||||
@ -314,20 +353,134 @@ class SystemizationTodos extends Controller
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
$position = max(1, (int) ($data['position'] ?? 1));
|
||||
unset($data['position']);
|
||||
$data['company_id'] = (int) company_id();
|
||||
$data['sort_order'] = (int) SystemizationTodo::query()->max('sort_order') + 1;
|
||||
$data['created_by'] = user_id();
|
||||
$data['updated_by'] = user_id();
|
||||
$this->normalizeCompletionDates($data);
|
||||
$todo = SystemizationTodo::query()->create($data);
|
||||
$insertedSequence = $position;
|
||||
$todo = DB::transaction(function () use ($data, $position, &$insertedSequence): SystemizationTodo {
|
||||
$siblings = $this->contentSiblings($data['category'] ?? null, $data['section'] ?? null);
|
||||
$nextOrder = (int) SystemizationTodo::query()->max('sort_order') + 1;
|
||||
if ($siblings->isEmpty()) {
|
||||
$insertOrder = $nextOrder;
|
||||
} elseif ($position <= $siblings->count()) {
|
||||
$insertOrder = (int) $siblings[$position - 1]->sort_order;
|
||||
} else {
|
||||
$insertOrder = (int) $siblings->max('sort_order') + 1;
|
||||
}
|
||||
|
||||
SystemizationTodo::query()
|
||||
->where('sort_order', '>=', $insertOrder)
|
||||
->increment('sort_order');
|
||||
$insertedSequence = $siblings->isEmpty()
|
||||
? 1
|
||||
: min($position, $siblings->count() + 1);
|
||||
|
||||
return SystemizationTodo::query()->create([
|
||||
...$data,
|
||||
'sort_order' => $insertOrder,
|
||||
]);
|
||||
});
|
||||
$this->storeAttachments($request, $todo);
|
||||
|
||||
return $this->backToPage('待办事项已新增并定位。', 'success', $todo);
|
||||
return $this->backToPage(
|
||||
'待办事项已新增为当前内容第 ' . $insertedSequence . ' 条并定位。',
|
||||
'success',
|
||||
$todo,
|
||||
);
|
||||
}
|
||||
|
||||
public function updateCategory(Request $request, SystemizationTodo $todo): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'category' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
$ids = [];
|
||||
DB::transaction(function () use ($todo, $data, &$ids): void {
|
||||
$ids = $this->groupTodoIds($todo, false);
|
||||
$this->updateGroupField($ids, ['category' => trim($data['category'])]);
|
||||
});
|
||||
|
||||
return $this->backToPage(
|
||||
'类型已更新,当前类型下的 ' . count($ids) . ' 条待办已同步调整。',
|
||||
'success',
|
||||
$todo,
|
||||
);
|
||||
}
|
||||
|
||||
public function updateSection(Request $request, SystemizationTodo $todo): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'section' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
$ids = [];
|
||||
DB::transaction(function () use ($todo, $data, &$ids): void {
|
||||
$ids = $this->groupTodoIds($todo, true);
|
||||
$this->updateGroupField($ids, ['section' => trim($data['section'])]);
|
||||
});
|
||||
|
||||
return $this->backToPage(
|
||||
'内容已更新,当前内容下的 ' . count($ids) . ' 条待办已同步调整。',
|
||||
'success',
|
||||
$todo,
|
||||
);
|
||||
}
|
||||
|
||||
private function contentSiblings(?string $category, ?string $section): Collection
|
||||
{
|
||||
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($category));
|
||||
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($section));
|
||||
|
||||
return SystemizationTodo::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->filter(fn (SystemizationTodo $todo): bool =>
|
||||
$this->hierarchyGroupKey($this->hierarchyLabel($todo->category)) === $categoryKey
|
||||
&& $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)) === $sectionKey
|
||||
)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function groupTodoIds(SystemizationTodo $todo, bool $includeSection): array
|
||||
{
|
||||
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category));
|
||||
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section));
|
||||
|
||||
return SystemizationTodo::query()
|
||||
->lockForUpdate()
|
||||
->get(['id', 'category', 'section'])
|
||||
->filter(fn (SystemizationTodo $candidate): bool =>
|
||||
$this->hierarchyGroupKey($this->hierarchyLabel($candidate->category)) === $categoryKey
|
||||
&& (! $includeSection
|
||||
|| $this->hierarchyGroupKey($this->hierarchyLabel($candidate->section)) === $sectionKey)
|
||||
)
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function updateGroupField(array $ids, array $data): void
|
||||
{
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
SystemizationTodo::query()
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
...$data,
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
unset($data['position']);
|
||||
$data['updated_by'] = user_id();
|
||||
$this->normalizeCompletionDates($data);
|
||||
|
||||
@ -471,6 +624,7 @@ class SystemizationTodos extends Controller
|
||||
'category' => ['nullable', 'string', 'max:100'],
|
||||
'section' => ['nullable', 'string', 'max:255'],
|
||||
'title' => ['required', 'string', 'max:5000'],
|
||||
'position' => ['nullable', 'integer', 'min:1'],
|
||||
'owner' => ['nullable', 'string', 'max:255'],
|
||||
'assignee' => ['nullable', 'string', 'max:255'],
|
||||
'start_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
.sys-todo-filter__multi details[open] > summary { border-color:var(--sys-gold); box-shadow:0 0 0 3px rgba(133,102,50,.14); }
|
||||
.sys-todo-filter__multi details[open] > summary .material-icons-outlined { transform:translateY(-50%) rotate(180deg); }
|
||||
.sys-todo-filter__multi-label { color:#595959; font-size:.72rem; line-height:1; }
|
||||
.sys-todo-filter__multi-value { overflow:hidden; font-size:.8125rem; line-height:1.2; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sys-todo-filter__multi-value { font-size:.8125rem; line-height:1.35; overflow-wrap:anywhere; white-space:normal; }
|
||||
.sys-todo-filter__multi-menu { position:absolute; top:calc(100% + .3rem); left:0; z-index:20; display:grid; width:max(100%,13rem); max-height:17rem; padding:.35rem; border:1px solid var(--sys-border); border-radius:.55rem; background:#fff; box-shadow:0 8px 22px rgba(23,23,23,.16); overflow:auto; }
|
||||
.sys-todo-filter__multi-menu label { display:flex; flex:none; align-items:center; gap:.45rem; min-height:2rem; padding:.3rem .45rem; border-radius:.35rem; color:#333; font-size:.78rem; cursor:pointer; }
|
||||
.sys-todo-filter__multi-menu label:hover { background:var(--sys-gold-soft); }
|
||||
@ -42,15 +42,16 @@
|
||||
.sys-todo-table th { position:sticky; top:0; z-index:4; padding:.55rem .48rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
|
||||
.sys-todo-table th:first-child { left:0; z-index:8; width:5.4rem; min-width:5.4rem; text-align:center; box-shadow:1px 0 0 #e5ddcf; }
|
||||
.sys-todo-table td { padding:.72rem .6rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; line-height:1.6; vertical-align:top; }
|
||||
.sys-todo-table tbody tr { cursor:pointer; }
|
||||
.sys-todo-table tbody tr:hover td { background:#fdfbf6; }
|
||||
.sys-todo-table tbody tr.is-highlighted td { background:#fff4cf !important; }
|
||||
.sys-todo-table tbody tr:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; }
|
||||
.sys-todo-table [data-sys-todo-row-edit], .sys-todo-table [data-sys-todo-group-edit] { cursor:pointer; }
|
||||
.sys-todo-table [data-sys-todo-row-edit]:hover, .sys-todo-table [data-sys-todo-group-edit]:hover { background:#fff4cf; }
|
||||
.sys-todo-table [data-sys-todo-row-edit]:focus-visible, .sys-todo-table [data-sys-todo-group-edit]:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; }
|
||||
.sys-todo-table__index-cell { width:3.6rem; min-width:3.6rem; text-align:center; }
|
||||
.sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
.sys-todo-table__group { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.7; vertical-align:middle; }
|
||||
.sys-todo-table__group-content { display:inline-flex; align-items:flex-start; gap:.38rem; min-width:0; }
|
||||
.sys-todo-table__group-text { min-width:0; }
|
||||
.sys-todo-table__group-text { min-width:0; white-space:pre-line; overflow-wrap:anywhere; }
|
||||
.sys-todo-table__level { display:inline-flex; flex:0 0 auto; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; margin-top:.05rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); font-size:.68rem; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.sys-todo-table__type { position:sticky; left:0; z-index:3; width:5.4rem; min-width:5.4rem; text-align:center; white-space:nowrap; box-shadow:1px 0 0 #e5ddcf; }
|
||||
.sys-todo-table__type .sys-todo-table__group-text { white-space:normal; }
|
||||
@ -59,8 +60,6 @@
|
||||
.sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; }
|
||||
.sys-todo-table__progress { min-width:12rem; max-width:20rem; }
|
||||
.sys-todo-table__notes { min-width:9rem; max-width:16rem; }
|
||||
.sys-todo-table__clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:3; }
|
||||
.sys-todo-table__section .sys-todo-table__clamp { -webkit-line-clamp:2; }
|
||||
.sys-todo-badge { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; }
|
||||
.sys-todo-badge--progress { border-color:#dbbf7a; background:#fff7df; color:#935f07; }
|
||||
.sys-todo-badge--completed { border-color:#bad0ac; background:#f2f8ed; color:#53793e; }
|
||||
@ -95,13 +94,14 @@
|
||||
.sys-todo-dialog__body { max-height:calc(90vh - 9rem); overflow:auto; padding:1rem 1.25rem; }
|
||||
.sys-todo-form-grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:.75rem 1rem; }
|
||||
.sys-todo-field { display:grid; gap:.3rem; min-width:0; color:#595959; font-size:.75rem; }
|
||||
.sys-todo-field--2 { grid-column:span 2; }
|
||||
.sys-todo-field--3 { grid-column:span 3; }
|
||||
.sys-todo-field--4 { grid-column:span 4; }
|
||||
.sys-todo-field--6 { grid-column:span 6; }
|
||||
.sys-todo-field--12 { grid-column:span 12; }
|
||||
.sys-todo-field input, .sys-todo-field select, .sys-todo-field textarea { width:100%; padding:.5rem .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
|
||||
.sys-todo-field input, .sys-todo-field select { min-height:2.15rem; }
|
||||
.sys-todo-field textarea { min-height:5.5rem; resize:vertical; line-height:1.55; }
|
||||
.sys-todo-field textarea { min-height:5.5rem; resize:vertical; line-height:1.55; overflow:hidden; }
|
||||
.sys-todo-new-category { margin-top:.35rem; }
|
||||
.sys-todo-new-category[hidden] { display:none; }
|
||||
.sys-todo-owner-checks { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.2rem .45rem; max-height:7rem; padding:.4rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; overflow:auto; }
|
||||
@ -135,6 +135,18 @@
|
||||
.sys-todo-confirm__actions button { min-height:2.1rem; padding:0 .9rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; font:inherit; font-size:.8125rem; cursor:pointer; }
|
||||
.sys-todo-confirm__actions .sys-todo-confirm__submit { border-color:#b80000; background:#b80000; color:#fff; }
|
||||
.sys-todo-confirm__actions .sys-todo-confirm__submit:hover { background:#920000; }
|
||||
.sys-todo-group-dialog { width:min(34rem,calc(100vw - 2rem)); padding:0; border:1px solid var(--sys-border); border-radius:.8rem; background:#fff; color:#171717; overflow:hidden; }
|
||||
.sys-todo-group-dialog::backdrop { background:rgba(23,23,23,.58); }
|
||||
.sys-todo-group-dialog__head { display:flex; align-items:center; gap:.75rem; padding:.9rem 1rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; }
|
||||
.sys-todo-group-dialog__head h2 { margin:0; font-size:.95rem; }
|
||||
.sys-todo-group-dialog__head button { margin-left:auto; width:2rem; height:2rem; padding:0; border:1px solid var(--sys-border); border-radius:999px; background:#fff; color:#595959; cursor:pointer; }
|
||||
.sys-todo-group-dialog__body { display:grid; gap:.55rem; padding:1rem; }
|
||||
.sys-todo-group-dialog__body label { display:grid; gap:.35rem; color:#595959; font-size:.76rem; }
|
||||
.sys-todo-group-dialog__body textarea { width:100%; min-height:5.5rem; padding:.55rem .65rem; border:1px solid var(--sys-border); border-radius:.45rem; font:inherit; font-size:.84rem; line-height:1.6; overflow:hidden; resize:vertical; }
|
||||
.sys-todo-group-dialog__note { margin:0; color:#666; font-size:.73rem; line-height:1.55; }
|
||||
.sys-todo-group-dialog__actions { display:flex; justify-content:flex-end; gap:.5rem; padding:.75rem 1rem; border-top:1px solid #e8e0d2; background:#fcfaf5; }
|
||||
.sys-todo-group-dialog__actions button { min-height:2.1rem; padding:0 .9rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; font:inherit; font-size:.8125rem; cursor:pointer; }
|
||||
.sys-todo-group-dialog__actions button[type="submit"] { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; }
|
||||
.sys-todo-image-dialog { width:min(72rem,calc(100vw - 2rem)); max-height:92vh; padding:0; border:1px solid var(--sys-border); border-radius:.8rem; background:#111; color:#fff; overflow:hidden; }
|
||||
.sys-todo-image-dialog::backdrop { background:rgba(5,5,6,.78); }
|
||||
.sys-todo-image-dialog__head { display:flex; align-items:center; gap:1rem; padding:.65rem .85rem; background:#1d1d1f; }
|
||||
@ -146,7 +158,7 @@
|
||||
.sys-todo-image-dialog__foot a { color:#f0e4bf; font-size:.75rem; }
|
||||
@media (max-width:1300px) { .sys-todo-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media (max-width:900px) { .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:9rem; } .sys-todo-filter__actions { width:100%; } .sys-todo-filter__actions .sys-todo-page__button { flex:1; } }
|
||||
@media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } }
|
||||
@media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } }
|
||||
</style>
|
||||
|
||||
<div class="biz-card-head">
|
||||
@ -266,7 +278,7 @@
|
||||
@php
|
||||
$cleanDisplayLabel = static function (?string $value): string {
|
||||
return trim((string) preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
));
|
||||
@ -321,7 +333,7 @@
|
||||
@endphp
|
||||
|
||||
<div class="sys-todo-table__add">
|
||||
<span>共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。</span>
|
||||
<span>共 {{ number_format($items->count()) }} 条,已全部显示。点击待办事项编辑详情;点击类型或内容可单独修改该组名称。</span>
|
||||
<button type="button" data-sys-todo-create
|
||||
data-sys-todo-default-category="{{ $lastItem?->category ?? '' }}"
|
||||
data-sys-todo-default-section="{{ $lastSectionLabel }}">
|
||||
@ -375,9 +387,16 @@
|
||||
$categorySpan = $groupRowspans['category'][$loop->index] ?? 1;
|
||||
$sectionSpan = $groupRowspans['section'][$loop->index] ?? 1;
|
||||
@endphp
|
||||
<tr id="sys-todo-row-{{ $item->id }}" class="{{ $highlightId === (int) $item->id ? 'is-highlighted' : '' }}" tabindex="0" data-sys-todo-row-edit="{{ $todoPayloadEncoded }}" title="点击整行直接编辑">
|
||||
<tr id="sys-todo-row-{{ $item->id }}" class="{{ $highlightId === (int) $item->id ? 'is-highlighted' : '' }}">
|
||||
@if ($categorySpan !== 0)
|
||||
<td class="sys-todo-table__group sys-todo-table__type" @if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif>
|
||||
<td class="sys-todo-table__group sys-todo-table__type"
|
||||
@if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif
|
||||
tabindex="0"
|
||||
role="button"
|
||||
title="点击修改类型名称"
|
||||
data-sys-todo-group-edit="category"
|
||||
data-sys-todo-group-value="{{ $item->category ?? '' }}"
|
||||
data-sys-todo-group-count="{{ (int) $item->type_count }}">
|
||||
<span class="sys-todo-table__group-content">
|
||||
<span class="sys-todo-table__level" title="类型序号">{{ (int) $item->type_sequence }}</span>
|
||||
<span class="sys-todo-table__group-text">{{ $item->category_display ?: '未分类' }}</span>
|
||||
@ -385,15 +404,25 @@
|
||||
</td>
|
||||
@endif
|
||||
@if ($sectionSpan !== 0)
|
||||
<td class="sys-todo-table__group sys-todo-table__section" @if ($sectionSpan > 1) rowspan="{{ $sectionSpan }}" @endif>
|
||||
<td class="sys-todo-table__group sys-todo-table__section"
|
||||
@if ($sectionSpan > 1) rowspan="{{ $sectionSpan }}" @endif
|
||||
tabindex="0"
|
||||
role="button"
|
||||
title="点击修改内容名称"
|
||||
data-sys-todo-group-edit="section"
|
||||
data-sys-todo-group-value="{{ $sectionLabels[$loop->index] ?? '' }}"
|
||||
data-sys-todo-group-count="{{ (int) $item->content_count }}">
|
||||
<span class="sys-todo-table__group-content">
|
||||
<span class="sys-todo-table__level" title="内容序号">{{ (int) $item->content_sequence }}</span>
|
||||
<span class="sys-todo-table__group-text sys-todo-table__clamp" title="{{ $sectionLabels[$loop->index] ?: '未填写内容' }}">{{ $sectionLabels[$loop->index] ?: '未填写内容' }}</span>
|
||||
<span class="sys-todo-table__group-text">{{ $sectionLabels[$loop->index] ?: '未填写内容' }}</span>
|
||||
</span>
|
||||
</td>
|
||||
@endif
|
||||
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前内容内待办序号">{{ (int) $item->item_sequence }}</span></td>
|
||||
<td class="sys-todo-table__title"><div class="sys-todo-table__clamp" title="{{ $titleLabels[$loop->index] }}">{{ $titleLabels[$loop->index] }}</div></td>
|
||||
<td class="sys-todo-table__title"
|
||||
tabindex="0"
|
||||
title="点击编辑待办"
|
||||
data-sys-todo-row-edit="{{ $todoPayloadEncoded }}">{{ $titleLabels[$loop->index] }}</td>
|
||||
<td>{{ $item->owner ?: '—' }}</td>
|
||||
<td>{{ $item->assignee ?: '—' }}</td>
|
||||
<td>{{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }}</td>
|
||||
@ -402,7 +431,7 @@
|
||||
@if ($isOverdue)<br><span class="sys-todo-badge sys-todo-badge--overdue">已逾期</span>@endif
|
||||
</td>
|
||||
<td><span class="sys-todo-badge {{ $statusClass }}">{{ $item->status }}</span></td>
|
||||
<td class="sys-todo-table__progress"><div class="sys-todo-table__clamp" title="{{ $item->progress ?: '—' }}">{{ $item->progress ?: '—' }}</div></td>
|
||||
<td class="sys-todo-table__progress">{{ $item->progress ?: '—' }}</td>
|
||||
<td>
|
||||
@if ($item->attachments->isNotEmpty())
|
||||
<div class="sys-todo-shots">
|
||||
@ -423,7 +452,7 @@
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="sys-todo-table__notes"><div class="sys-todo-table__clamp" title="{{ $item->notes ?: '—' }}">{{ $item->notes ?: '—' }}</div></td>
|
||||
<td class="sys-todo-table__notes">{{ $item->notes ?: '—' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="12"><div class="sys-todo-empty">当前筛选条件下没有待办事项。</div></td></tr>
|
||||
@ -459,7 +488,10 @@
|
||||
<input type="text" maxlength="100" placeholder="输入新类型" data-sys-todo-new-category-input>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sys-todo-field sys-todo-field--4">内容<input name="section" maxlength="255"></label>
|
||||
<label class="sys-todo-field sys-todo-field--4">内容<textarea name="section" maxlength="255" rows="2" data-sys-todo-auto-grow></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--2 sys-todo-position-field" data-sys-todo-position-field>新增位置
|
||||
<select name="position" data-sys-todo-position></select>
|
||||
</label>
|
||||
<label class="sys-todo-field sys-todo-field--3">状态<select name="status" required>@foreach ($todoStatuses as $status)<option value="{{ $status }}">{{ $status }}</option>@endforeach</select></label>
|
||||
<div class="sys-todo-field sys-todo-field--3">
|
||||
<span>总负责人</span>
|
||||
@ -475,9 +507,9 @@
|
||||
<label class="sys-todo-field sys-todo-field--3">责任人<input name="assignee" maxlength="255" list="sysTodoAssignees"></label>
|
||||
<label class="sys-todo-field sys-todo-field--3">开始时间<input type="date" name="start_date"></label>
|
||||
<label class="sys-todo-field sys-todo-field--3">完成时间<input type="date" name="due_date"></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">待办事项<textarea name="title" maxlength="5000" required></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">完成情况<textarea name="progress" maxlength="10000"></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">备注<textarea name="notes" maxlength="10000"></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">待办事项<textarea name="title" maxlength="5000" required data-sys-todo-auto-grow></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">完成情况<textarea name="progress" maxlength="10000" data-sys-todo-auto-grow></textarea></label>
|
||||
<label class="sys-todo-field sys-todo-field--6">备注<textarea name="notes" maxlength="10000" data-sys-todo-auto-grow></textarea></label>
|
||||
<div class="sys-todo-field sys-todo-field--6" data-sys-todo-upload>
|
||||
<span>完成截图</span>
|
||||
<div class="sys-todo-upload-zone">
|
||||
@ -497,6 +529,28 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="sys-todo-group-dialog" id="sysTodoGroupDialog" aria-labelledby="sysTodoGroupDialogTitle">
|
||||
<form method="POST" data-sys-todo-group-form>
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div class="sys-todo-group-dialog__head">
|
||||
<h2 id="sysTodoGroupDialogTitle" data-sys-todo-group-title>修改类型</h2>
|
||||
<button type="button" data-sys-todo-group-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="sys-todo-group-dialog__body">
|
||||
<label>
|
||||
<span data-sys-todo-group-label>类型名称</span>
|
||||
<textarea name="category" required data-sys-todo-group-input data-sys-todo-auto-grow></textarea>
|
||||
</label>
|
||||
<p class="sys-todo-group-dialog__note" data-sys-todo-group-note>保存后会同步修改当前分组的名称。</p>
|
||||
</div>
|
||||
<div class="sys-todo-group-dialog__actions">
|
||||
<button type="button" data-sys-todo-group-close>取消</button>
|
||||
<button type="submit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="sys-todo-confirm" id="sysTodoDeleteConfirm" aria-labelledby="sysTodoDeleteConfirmTitle">
|
||||
<form method="POST" data-sys-todo-delete-form>
|
||||
@csrf
|
||||
@ -549,6 +603,9 @@
|
||||
var categoryValue = dialog.querySelector('[data-sys-todo-category-value]');
|
||||
var newCategoryWrap = dialog.querySelector('[data-sys-todo-new-category]');
|
||||
var newCategoryInput = dialog.querySelector('[data-sys-todo-new-category-input]');
|
||||
var sectionInput = dialog.querySelector('[name="section"]');
|
||||
var positionField = dialog.querySelector('[data-sys-todo-position-field]');
|
||||
var positionSelect = dialog.querySelector('[data-sys-todo-position]');
|
||||
var ownerValue = dialog.querySelector('[data-sys-todo-owner-value]');
|
||||
var ownerOptionInputs = Array.prototype.slice.call(
|
||||
dialog.querySelectorAll('[data-sys-todo-owner-options] input[type="checkbox"]')
|
||||
@ -557,8 +614,16 @@
|
||||
var deleteConfirm = document.getElementById('sysTodoDeleteConfirm');
|
||||
var deleteForm = deleteConfirm.querySelector('[data-sys-todo-delete-form]');
|
||||
var deleteTitle = deleteConfirm.querySelector('[data-sys-todo-delete-title]');
|
||||
var groupDialog = document.getElementById('sysTodoGroupDialog');
|
||||
var groupForm = groupDialog.querySelector('[data-sys-todo-group-form]');
|
||||
var groupTitle = groupDialog.querySelector('[data-sys-todo-group-title]');
|
||||
var groupLabel = groupDialog.querySelector('[data-sys-todo-group-label]');
|
||||
var groupInput = groupDialog.querySelector('[data-sys-todo-group-input]');
|
||||
var groupNote = groupDialog.querySelector('[data-sys-todo-group-note]');
|
||||
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
var positionGroups = @json($todoPositionGroups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
var currentTodoId = null;
|
||||
var currentGroupTodoId = null;
|
||||
var currentTodoTitle = '';
|
||||
var uploadPrompt = '点击选择、拖入此区域或直接粘贴,支持多张,每张不超过 10 MB。';
|
||||
var ownerFilter = document.querySelector('[data-sys-todo-owner-filter]');
|
||||
@ -582,6 +647,58 @@
|
||||
if (field) field.value = value || '';
|
||||
}
|
||||
|
||||
function resizeTextarea(field) {
|
||||
if (!field || field.offsetParent === null) return;
|
||||
field.style.height = 'auto';
|
||||
field.style.height = Math.max(field.scrollHeight, 72) + 'px';
|
||||
}
|
||||
|
||||
function resizeTextareas(root) {
|
||||
(root || document).querySelectorAll('[data-sys-todo-auto-grow]').forEach(resizeTextarea);
|
||||
}
|
||||
|
||||
function normalizedGroupText(value) {
|
||||
return String(value || '')
|
||||
.replace(/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u, '')
|
||||
.replace(/[\s\p{Z}\u200B\uFEFF]+/gu, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function currentPositionGroup() {
|
||||
var category = normalizedGroupText(categoryValue.value);
|
||||
var section = normalizedGroupText(sectionInput.value);
|
||||
return positionGroups.find(function (group) {
|
||||
return normalizedGroupText(group.category) === category
|
||||
&& normalizedGroupText(group.section) === section;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function populatePositionOptions() {
|
||||
positionSelect.replaceChildren();
|
||||
positionSelect.disabled = !!currentTodoId;
|
||||
if (currentTodoId) {
|
||||
positionField.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
positionField.hidden = false;
|
||||
var group = currentPositionGroup();
|
||||
var count = group ? Number(group.count || 0) : 0;
|
||||
for (var index = 1; index <= count; index++) {
|
||||
var before = document.createElement('option');
|
||||
before.value = String(index);
|
||||
before.textContent = '第 ' + index + ' 条前插入(原第 ' + index + ' 条及之后顺延)';
|
||||
positionSelect.appendChild(before);
|
||||
}
|
||||
var end = document.createElement('option');
|
||||
end.value = String(count + 1);
|
||||
end.textContent = count > 0
|
||||
? '添加到末尾(第 ' + (count + 1) + ' 条)'
|
||||
: '新增为第 1 条';
|
||||
positionSelect.appendChild(end);
|
||||
positionSelect.value = String(count + 1);
|
||||
}
|
||||
|
||||
function syncScrollRail() {
|
||||
var rect = tableWrap.getBoundingClientRect();
|
||||
var left = Math.max(8, rect.left);
|
||||
@ -719,6 +836,28 @@
|
||||
imagePreview.removeAttribute('src');
|
||||
}
|
||||
|
||||
function openGroupDialog(type, value, count) {
|
||||
var isCategory = type === 'category';
|
||||
groupForm.action = routes[isCategory ? 'category' : 'section'].replace('__TODO__', currentGroupTodoId);
|
||||
groupInput.name = isCategory ? 'category' : 'section';
|
||||
groupInput.value = value || '';
|
||||
groupTitle.textContent = isCategory ? '修改类型' : '修改内容';
|
||||
groupLabel.textContent = isCategory ? '类型名称' : '内容名称';
|
||||
groupNote.textContent = '保存后会同步修改当前分组内的 ' + count + ' 条待办。';
|
||||
if (typeof groupDialog.showModal === 'function') groupDialog.showModal();
|
||||
else groupDialog.setAttribute('open', '');
|
||||
window.setTimeout(function () {
|
||||
resizeTextarea(groupInput);
|
||||
groupInput.focus();
|
||||
groupInput.select();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function closeGroupDialog() {
|
||||
if (typeof groupDialog.close === 'function') groupDialog.close();
|
||||
else groupDialog.removeAttribute('open');
|
||||
}
|
||||
|
||||
function openDialog(payload) {
|
||||
currentTodoId = payload && payload.id ? payload.id : null;
|
||||
currentTodoTitle = payload && payload.title ? payload.title : '';
|
||||
@ -727,6 +866,7 @@
|
||||
form.action = currentTodoId ? routes.update.replace('__TODO__', currentTodoId) : routes.store;
|
||||
title.textContent = currentTodoId ? '编辑待办' : '新增待办';
|
||||
deleteButton.hidden = !currentTodoId;
|
||||
positionField.hidden = !!currentTodoId;
|
||||
if (payload) {
|
||||
setCategoryValue(payload.category);
|
||||
setValue('section', payload.section);
|
||||
@ -746,6 +886,7 @@
|
||||
if (!currentTodoId && !(payload && payload.status)) {
|
||||
setValue('status', '待办');
|
||||
}
|
||||
populatePositionOptions();
|
||||
|
||||
existing.replaceChildren();
|
||||
((payload && payload.attachments) || []).forEach(function (attachment) {
|
||||
@ -776,6 +917,7 @@
|
||||
else dialog.setAttribute('open', '');
|
||||
uploadNote.textContent = uploadPrompt;
|
||||
window.setTimeout(function () {
|
||||
resizeTextareas(dialog);
|
||||
var titleField = form.querySelector('[name="title"]');
|
||||
if (titleField) titleField.focus();
|
||||
}, 0);
|
||||
@ -817,6 +959,20 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
var groupCell = event.target.closest('[data-sys-todo-group-edit]');
|
||||
if (groupCell) {
|
||||
var groupRow = groupCell.closest('tr');
|
||||
var groupRowId = groupRow ? Number(String(groupRow.id).replace('sys-todo-row-', '')) : 0;
|
||||
if (groupRowId) {
|
||||
currentGroupTodoId = groupRowId;
|
||||
openGroupDialog(
|
||||
groupCell.getAttribute('data-sys-todo-group-edit'),
|
||||
groupCell.getAttribute('data-sys-todo-group-value'),
|
||||
Number(groupCell.getAttribute('data-sys-todo-group-count') || 1)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-sys-todo-open-delete]')) {
|
||||
openDeleteConfirm();
|
||||
return;
|
||||
@ -827,6 +983,7 @@
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-sys-todo-close]')) closeDialog();
|
||||
if (event.target.closest('[data-sys-todo-group-close]')) closeGroupDialog();
|
||||
if (event.target.closest('[data-sys-todo-delete-close]')) closeDeleteConfirm();
|
||||
if (event.target.closest('[data-sys-todo-image-close]')) closeImageViewer();
|
||||
if (event.target.closest('[data-sys-todo-choose-files]')) screenshotInput.click();
|
||||
@ -834,6 +991,9 @@
|
||||
dialog.addEventListener('click', function (event) {
|
||||
if (event.target === dialog) closeDialog();
|
||||
});
|
||||
groupDialog.addEventListener('click', function (event) {
|
||||
if (event.target === groupDialog) closeGroupDialog();
|
||||
});
|
||||
dialog.addEventListener('paste', function (event) {
|
||||
var files = Array.prototype.slice.call(event.clipboardData?.items || [])
|
||||
.filter(function (item) { return item.kind === 'file'; })
|
||||
@ -850,9 +1010,25 @@
|
||||
deleteConfirm.addEventListener('click', function (event) {
|
||||
if (event.target === deleteConfirm) closeDeleteConfirm();
|
||||
});
|
||||
categorySelect.addEventListener('change', syncCategoryValue);
|
||||
categorySelect.addEventListener('change', function () {
|
||||
syncCategoryValue();
|
||||
populatePositionOptions();
|
||||
});
|
||||
newCategoryInput.addEventListener('input', function () {
|
||||
if (categorySelect.value === '__new__') syncCategoryValue();
|
||||
if (categorySelect.value === '__new__') {
|
||||
syncCategoryValue();
|
||||
populatePositionOptions();
|
||||
}
|
||||
});
|
||||
sectionInput.addEventListener('input', function () {
|
||||
resizeTextarea(sectionInput);
|
||||
populatePositionOptions();
|
||||
});
|
||||
dialog.addEventListener('input', function (event) {
|
||||
if (event.target.matches('[data-sys-todo-auto-grow]')) resizeTextarea(event.target);
|
||||
});
|
||||
groupDialog.addEventListener('input', function (event) {
|
||||
if (event.target.matches('[data-sys-todo-auto-grow]')) resizeTextarea(event.target);
|
||||
});
|
||||
ownerOptionInputs.forEach(function (input) {
|
||||
input.addEventListener('change', updateOwnerValue);
|
||||
@ -881,6 +1057,21 @@
|
||||
closeImageViewer();
|
||||
return;
|
||||
}
|
||||
var groupCell = event.target.closest('[data-sys-todo-group-edit]');
|
||||
if (groupCell && event.target === groupCell && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
var groupRow = groupCell.closest('tr');
|
||||
var groupRowId = groupRow ? Number(String(groupRow.id).replace('sys-todo-row-', '')) : 0;
|
||||
if (groupRowId) {
|
||||
currentGroupTodoId = groupRowId;
|
||||
openGroupDialog(
|
||||
groupCell.getAttribute('data-sys-todo-group-edit'),
|
||||
groupCell.getAttribute('data-sys-todo-group-value'),
|
||||
Number(groupCell.getAttribute('data-sys-todo-group-count') || 1)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var row = event.target.closest('[data-sys-todo-row-edit]');
|
||||
if (row && event.target === row && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
|
||||
@ -785,6 +785,12 @@ Route::prefix('workspace/other/systemization-todos')
|
||||
->middleware('import')
|
||||
->name('import');
|
||||
Route::post('/', [SystemizationTodos::class, 'store'])->name('store');
|
||||
Route::patch('{todo}/category', [SystemizationTodos::class, 'updateCategory'])
|
||||
->whereNumber('todo')
|
||||
->name('category');
|
||||
Route::patch('{todo}/section', [SystemizationTodos::class, 'updateSection'])
|
||||
->whereNumber('todo')
|
||||
->name('section');
|
||||
Route::patch('{todo}', [SystemizationTodos::class, 'update'])
|
||||
->whereNumber('todo')
|
||||
->name('update');
|
||||
|
||||
@ -42,6 +42,10 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
->assertSee('name="assignee[]"', false)
|
||||
->assertSee('data-sys-todo-category-select', false)
|
||||
->assertSee('data-sys-todo-new-category-input', false)
|
||||
->assertSee('data-sys-todo-position', false)
|
||||
->assertSee('data-sys-todo-group-edit', false)
|
||||
->assertSee('id="sysTodoGroupDialog"', false)
|
||||
->assertDontSee('sys-todo-table__clamp', false)
|
||||
->assertSeeText('+ 新增类型')
|
||||
->assertSee('data-sys-todo-choose-files', false)
|
||||
->assertSee('data-sys-todo-open-delete', false)
|
||||
@ -127,6 +131,89 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
$this->assertSoftDeleted('business_systemization_todos', ['id' => $todo->id]);
|
||||
}
|
||||
|
||||
public function test_can_insert_a_todo_at_a_selected_sequence_and_shift_existing_items(): void
|
||||
{
|
||||
$this->loginAs();
|
||||
|
||||
$items = [];
|
||||
foreach (range(1, 7) as $index) {
|
||||
$items[$index] = $this->createTodo(
|
||||
'收入',
|
||||
'平台收入核对',
|
||||
'原待办 ' . $index,
|
||||
'2026-11-30',
|
||||
$index,
|
||||
);
|
||||
}
|
||||
|
||||
$this->post(route('business.systemization-todos.store'), [
|
||||
'category' => '收入',
|
||||
'section' => '平台收入核对',
|
||||
'position' => 7,
|
||||
'title' => '新增待办 7',
|
||||
'owner' => '晨曦',
|
||||
'assignee' => '栎阳',
|
||||
'status' => SystemizationTodo::STATUS_PENDING,
|
||||
])->assertRedirect()
|
||||
->assertSessionHas('success', '待办事项已新增为当前内容第 7 条并定位。');
|
||||
|
||||
$page = $this->get(route('business.pages.show', [
|
||||
'section' => 'other',
|
||||
'page' => 'systemization-todos',
|
||||
]))->assertOk()->viewData('content');
|
||||
$group = $page['items']
|
||||
->where('category', '收入')
|
||||
->where('section', '平台收入核对')
|
||||
->values();
|
||||
|
||||
$this->assertSame([
|
||||
'原待办 1',
|
||||
'原待办 2',
|
||||
'原待办 3',
|
||||
'原待办 4',
|
||||
'原待办 5',
|
||||
'原待办 6',
|
||||
'新增待办 7',
|
||||
'原待办 7',
|
||||
], $group->pluck('title')->all());
|
||||
$this->assertSame(
|
||||
range(1, 8),
|
||||
$group->pluck('item_sequence')->map(static fn (mixed $value): int => (int) $value)->all(),
|
||||
);
|
||||
$this->assertGreaterThan(
|
||||
$items[6]->fresh()->sort_order,
|
||||
$items[7]->fresh()->sort_order,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_type_and_content_cells_can_be_edited_for_the_whole_group(): void
|
||||
{
|
||||
$this->loginAs();
|
||||
|
||||
$incomeA = $this->createTodo('收入', '平台收入核对', '收入事项 A', '2026-11-30', 1);
|
||||
$incomeB = $this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-11-30', 2);
|
||||
$incomeOther = $this->createTodo('收入', '退款核对', '退款事项', '2026-11-30', 3);
|
||||
$cost = $this->createTodo('成本', '成本核对', '成本事项', '2026-11-30', 4);
|
||||
|
||||
$this->patch(route('business.systemization-todos.category', ['todo' => $incomeA->id]), [
|
||||
'category' => '业务收入',
|
||||
])->assertRedirect();
|
||||
|
||||
foreach ([$incomeA, $incomeB, $incomeOther] as $todo) {
|
||||
$this->assertSame('业务收入', $todo->fresh()->category);
|
||||
}
|
||||
$this->assertSame('成本', $cost->fresh()->category);
|
||||
|
||||
$this->patch(route('business.systemization-todos.section', ['todo' => $incomeA->id]), [
|
||||
'section' => '收入核对新版',
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertSame('收入核对新版', $incomeA->fresh()->section);
|
||||
$this->assertSame('收入核对新版', $incomeB->fresh()->section);
|
||||
$this->assertSame('退款核对', $incomeOther->fresh()->section);
|
||||
$this->assertSame('成本核对', $cost->fresh()->section);
|
||||
}
|
||||
|
||||
public function test_excel_import_preserves_hierarchy_and_does_not_overwrite_existing_rows(): void
|
||||
{
|
||||
$path = sys_get_temp_dir() . '/systemization-todos-' . uniqid() . '.xlsx';
|
||||
@ -461,6 +548,77 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_excel_sync_splits_numbered_titles_and_keeps_attachment_on_first_item(): void
|
||||
{
|
||||
Storage::fake('uploads');
|
||||
$this->loginAs();
|
||||
|
||||
$combinedTitle = "1、核对平台收入\n\n2、核对订单收入\n3,核对特殊订单";
|
||||
$historical = SystemizationTodo::query()->create([
|
||||
'company_id' => (int) company_id(),
|
||||
'category' => '收入',
|
||||
'section' => '平台收入核对',
|
||||
'title' => $combinedTitle,
|
||||
'owner' => '晨曦',
|
||||
'assignee' => '栎阳',
|
||||
'start_date' => '2026-10-20',
|
||||
'due_date' => '2026-10-21',
|
||||
'status' => SystemizationTodo::STATUS_COMPLETED,
|
||||
'completed_at' => '2026-10-21',
|
||||
'progress' => '已完成',
|
||||
'sort_order' => 2,
|
||||
'source_key' => 'legacy-combined-row',
|
||||
'imported_at' => now(),
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
Storage::disk('uploads')->put('systemization-todos/combined.png', 'image');
|
||||
SystemizationTodoAttachment::query()->create([
|
||||
'company_id' => (int) company_id(),
|
||||
'systemization_todo_id' => $historical->id,
|
||||
'path' => 'systemization-todos/combined.png',
|
||||
'original_name' => 'combined.png',
|
||||
'mime_type' => 'image/png',
|
||||
'size' => 5,
|
||||
'uploaded_by' => user_id(),
|
||||
]);
|
||||
|
||||
$path = sys_get_temp_dir() . '/systemization-split-' . uniqid() . '.xlsx';
|
||||
try {
|
||||
$this->writeWorkbook($path, [
|
||||
['收入', '平台收入核对', $combinedTitle, '晨曦', '栎阳', '2026-10-20', '2026-10-21', '已完成', null],
|
||||
]);
|
||||
$preview = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($path, (int) company_id(), user_id(), true);
|
||||
$this->assertSame(2, $preview['created']);
|
||||
$this->assertSame(1, $preview['updated']);
|
||||
$this->assertSame(0, $preview['retained']);
|
||||
|
||||
$result = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($path, (int) company_id(), user_id());
|
||||
$this->assertSame(2, $result['created']);
|
||||
$this->assertSame(1, $result['updated']);
|
||||
$this->assertSame(0, $result['retained']);
|
||||
|
||||
$todos = SystemizationTodo::query()->orderBy('sort_order')->get();
|
||||
$this->assertSame([
|
||||
'核对平台收入',
|
||||
'核对订单收入',
|
||||
'核对特殊订单',
|
||||
], $todos->pluck('title')->all());
|
||||
$this->assertSame($historical->id, $todos->first()->id);
|
||||
$this->assertSame(1, $todos->first()->attachments()->count());
|
||||
|
||||
$secondResult = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($path, (int) company_id(), user_id());
|
||||
$this->assertSame(0, $secondResult['created']);
|
||||
$this->assertSame(0, $secondResult['updated']);
|
||||
$this->assertSame(3, SystemizationTodo::query()->count());
|
||||
} finally {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function createTodo(
|
||||
string $category,
|
||||
string $section,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user