完善系统化待办文字展示、拆分与指定序位新增 #618

Merged
yueyang merged 3 commits from feature/yueyang-systemization-todo-structured-editing into main 2026-10-26 17:37:07 +08:00
5 changed files with 700 additions and 134 deletions

View File

@ -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,
) ?? '';

View File

@ -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'],

View File

@ -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();

View File

@ -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');

View File

@ -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,