diff --git a/app/Business/SystemizationTodoAuditLogger.php b/app/Business/SystemizationTodoAuditLogger.php new file mode 100644 index 00000000..d90877f4 --- /dev/null +++ b/app/Business/SystemizationTodoAuditLogger.php @@ -0,0 +1,298 @@ + '新增', + 'update' => '修改', + 'delete' => '删除', + 'import' => '导入', + ]; + + public const FIELD_LABELS = [ + 'category' => '类型', + 'section' => '内容', + 'title' => '待办事项', + 'owner' => '总负责人', + 'assignee' => '责任人', + 'start_date' => '开始时间', + 'start_note' => '开始时间备注', + 'due_date' => '完成时间', + 'due_note' => '完成时间备注', + 'status' => '状态', + 'completed_at' => '完成日期', + 'progress' => '完成情况', + 'notes' => '备注', + 'attachment_count' => '完成截图', + 'sort_order' => '排序位置', + ]; + + /** + * @return array + */ + public function snapshot(SystemizationTodo $todo, bool $withAttachments = false): array + { + $snapshot = [ + 'id' => (int) $todo->id, + 'category' => (string) ($todo->category ?? ''), + 'section' => (string) ($todo->section ?? ''), + 'title' => (string) ($todo->title ?? ''), + 'owner' => (string) ($todo->owner ?? ''), + 'assignee' => (string) ($todo->assignee ?? ''), + 'start_date' => $todo->start_date?->format('Y-m-d') ?? '', + 'start_note' => (string) ($todo->start_note ?? ''), + 'due_date' => $todo->due_date?->format('Y-m-d') ?? '', + 'due_note' => (string) ($todo->due_note ?? ''), + 'status' => $todo->statusLabel(), + 'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '', + 'progress' => (string) ($todo->progress ?? ''), + 'notes' => (string) ($todo->notes ?? ''), + 'sort_order' => (int) $todo->sort_order, + 'source_key' => (string) ($todo->source_key ?? ''), + ]; + + if ($withAttachments) { + $snapshot['attachment_count'] = (int) $todo->attachments()->count(); + } + + return $snapshot; + } + + public function record( + string $action, + int $companyId, + ?int $todoId, + ?array $before, + ?array $after, + ?int $userId = null, + ?string $actorName = null, + array $meta = [], + ): void { + $this->recordMany([[ + 'company_id' => $companyId, + 'todo_id' => $todoId, + 'action' => $action, + 'before' => $before, + 'after' => $after, + 'user_id' => $userId, + 'actor_name' => $actorName, + 'meta' => $meta, + ]]); + } + + /** + * @param array $events + */ + public function recordMany(array $events): void + { + if ($events === []) { + return; + } + if (! $this->tableReady()) { + throw new RuntimeException('系统化待办操作记录表尚未就绪,请先完成数据库迁移。'); + } + + $now = $this->platformNow(); + $actorNames = []; + $rows = []; + foreach ($events as $event) { + $action = (string) ($event['action'] ?? ''); + if (! array_key_exists($action, self::ACTION_LABELS)) { + throw new RuntimeException('不支持的待办操作记录类型。'); + } + + $userId = isset($event['user_id']) ? (int) $event['user_id'] : null; + $actorName = trim((string) ($event['actor_name'] ?? '')); + if ($actorName === '' && $userId !== null) { + if (! array_key_exists($userId, $actorNames)) { + $actorNames[$userId] = (string) (User::query()->whereKey($userId)->value('name') ?? ''); + } + $actorName = $actorNames[$userId]; + } + + $meta = is_array($event['meta'] ?? null) ? $event['meta'] : []; + $rows[] = [ + 'company_id' => max(1, (int) ($event['company_id'] ?? 1)), + 'todo_id' => isset($event['todo_id']) ? (int) $event['todo_id'] : null, + 'action' => $action, + 'before_json' => $this->json($event['before'] ?? null), + 'after_json' => $this->json($event['after'] ?? null), + 'actor_user_id' => $userId, + 'actor_name' => mb_substr($actorName, 0, 191), + 'extra_json' => $meta === [] ? null : $this->json($meta), + 'at' => $now->toDateTimeString(), + 'created_at' => $now->toDateTimeString(), + 'updated_at' => $now->toDateTimeString(), + ]; + } + + DB::table(self::TABLE)->insert($rows); + $companyIds = array_values(array_unique(array_column($rows, 'company_id'))); + foreach ($companyIds as $companyId) { + $maxId = (int) DB::table(self::TABLE) + ->where('company_id', $companyId) + ->max('id'); + if ($maxId > self::HISTORY_LIMIT) { + DB::table(self::TABLE) + ->where('company_id', $companyId) + ->where('id', '<=', $maxId - self::HISTORY_LIMIT) + ->delete(); + } + } + } + + /** + * @return array> + */ + public function history(int $companyId, int $limit = self::HISTORY_LIMIT): array + { + if (! $this->tableReady()) { + return []; + } + + return DB::table(self::TABLE) + ->where('company_id', $companyId) + ->orderByDesc('id') + ->limit(max(1, min($limit, self::HISTORY_LIMIT))) + ->get() + ->map(fn (object $row): array => $this->serializeEvent($row)) + ->all(); + } + + public function platformNow(): Carbon + { + $timezone = trim((string) config('app.platform_timezone', '')); + if ($timezone === '') { + $timezone = (string) config('app.timezone', 'UTC'); + } + + try { + return Carbon::now($timezone); + } catch (Throwable) { + return Carbon::now((string) config('app.timezone', 'UTC')); + } + } + + /** + * @return array + */ + private function serializeEvent(object $row): array + { + $before = $row->before_json ? json_decode((string) $row->before_json, true) : null; + $after = $row->after_json ? json_decode((string) $row->after_json, true) : null; + $before = is_array($before) ? $before : null; + $after = is_array($after) ? $after : null; + $meta = $row->extra_json ? json_decode((string) $row->extra_json, true) : []; + $meta = is_array($meta) ? $meta : []; + $source = $after ?? $before ?? []; + + return [ + 'id' => (int) $row->id, + 'at' => (string) $row->at, + 'action' => (string) $row->action, + 'action_label' => self::ACTION_LABELS[(string) $row->action] ?? (string) $row->action, + 'todo_id' => $row->todo_id === null ? null : (int) $row->todo_id, + 'category' => (string) ($source['category'] ?? ''), + 'section' => (string) ($source['section'] ?? ''), + 'title' => (string) ($source['title'] ?? ''), + 'actor' => (string) ($row->actor_name ?? ''), + 'before' => $before, + 'after' => $after, + 'meta' => $meta, + 'changes' => $this->changes($before, $after), + ]; + } + + /** + * @return array + */ + private function changes(?array $before, ?array $after): array + { + if ($after === null && $before !== null) { + return ['删除前:' . $this->itemLabel($before)]; + } + if ($before === null && $after !== null) { + return ['新增:' . $this->itemLabel($after)]; + } + if ($before === null && $after === null) { + return ['—']; + } + + $changes = []; + foreach (self::FIELD_LABELS as $field => $label) { + $old = $this->displayValue($field, $before[$field] ?? ''); + $new = $this->displayValue($field, $after[$field] ?? ''); + if ($old !== $new) { + $changes[] = $label . ':' . ($old !== '' ? $old : '空') + . ' → ' . ($new !== '' ? $new : '空'); + } + } + + return $changes === [] ? ['内容无变化'] : $changes; + } + + private function itemLabel(array $row): string + { + $parts = array_values(array_filter([ + trim((string) ($row['category'] ?? '')), + trim((string) ($row['section'] ?? '')), + trim((string) ($row['title'] ?? '')), + ], static fn (string $value): bool => $value !== '')); + + return $parts === [] ? '未命名待办' : implode(' · ', $parts); + } + + private function displayValue(string $field, mixed $value): string + { + if ($field === 'attachment_count') { + return max(0, (int) $value) . ' 张'; + } + + return trim(str_replace(["\r\n", "\r"], "\n", (string) ($value ?? ''))); + } + + private function json(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('待办操作记录序列化失败。'); + } + + return $json; + } + + private function tableReady(): bool + { + try { + return DB::getSchemaBuilder()->hasTable(self::TABLE); + } catch (Throwable) { + return false; + } + } +} diff --git a/app/Business/SystemizationTodoWorkbookImporter.php b/app/Business/SystemizationTodoWorkbookImporter.php index 34659c38..e66f5d8d 100644 --- a/app/Business/SystemizationTodoWorkbookImporter.php +++ b/app/Business/SystemizationTodoWorkbookImporter.php @@ -23,6 +23,11 @@ class SystemizationTodoWorkbookImporter 'notes' => '备注', ]; + public function __construct( + private readonly SystemizationTodoAuditLogger $audit, + ) { + } + /** * 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。 * @@ -58,7 +63,15 @@ class SystemizationTodoWorkbookImporter ); $created = 0; $skipped = 0; - DB::transaction(function () use ($rows, $companyId, $userId, &$created, &$skipped): void { + DB::transaction(function () use ( + $rows, + $sheetName, + $companyId, + $userId, + &$created, + &$skipped, + ): void { + $auditEvents = []; foreach ($rows as $row) { $exists = SystemizationTodo::withoutGlobalScopes() ->where('company_id', $companyId) @@ -69,15 +82,29 @@ class SystemizationTodoWorkbookImporter continue; } - SystemizationTodo::withoutGlobalScopes()->create([ + $todo = SystemizationTodo::withoutGlobalScopes()->create([ ...$row['data'], 'company_id' => $companyId, 'imported_at' => now(), 'created_by' => $userId, 'updated_by' => $userId, ]); + $auditEvents[] = [ + 'company_id' => $companyId, + 'todo_id' => (int) $todo->id, + 'action' => 'import', + 'before' => null, + 'after' => $this->audit->snapshot($todo), + 'user_id' => $userId, + 'meta' => [ + 'source' => 'excel', + 'mode' => 'import', + 'sheet' => $sheetName, + ], + ]; $created++; } + $this->audit->recordMany($auditEvents); }); return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName]; @@ -171,9 +198,11 @@ class SystemizationTodoWorkbookImporter $retainedSortOrder, $companyId, $userId, + $sheetName, &$created, &$updated, ): void { + $auditEvents = []; SystemizationTodo::withoutGlobalScopes() ->where('company_id', $companyId) ->whereNull('deleted_at') @@ -185,6 +214,7 @@ class SystemizationTodoWorkbookImporter if (! $todo) { continue; } + $before = $this->audit->snapshot($todo); $todo->source_key = null; $todo->syncOriginalAttribute('source_key'); $todo->fill($rows[$rowIndex]['data']); @@ -206,18 +236,44 @@ class SystemizationTodoWorkbookImporter $todo->updated_by = $userId; $todo->save(); if ($contentChanged) { + $auditEvents[] = [ + 'company_id' => $companyId, + 'todo_id' => (int) $todo->id, + 'action' => 'import', + 'before' => $before, + 'after' => $this->audit->snapshot($todo), + 'user_id' => $userId, + 'meta' => [ + 'source' => 'excel', + 'mode' => 'sync', + 'sheet' => $sheetName, + ], + ]; $updated++; } } foreach ($newIndexes as $rowIndex) { - SystemizationTodo::withoutGlobalScopes()->create([ + $todo = SystemizationTodo::withoutGlobalScopes()->create([ ...$rows[$rowIndex]['data'], 'company_id' => $companyId, 'imported_at' => now(), 'created_by' => $userId, 'updated_by' => $userId, ]); + $auditEvents[] = [ + 'company_id' => $companyId, + 'todo_id' => (int) $todo->id, + 'action' => 'import', + 'before' => null, + 'after' => $this->audit->snapshot($todo), + 'user_id' => $userId, + 'meta' => [ + 'source' => 'excel', + 'mode' => 'sync', + 'sheet' => $sheetName, + ], + ]; $created++; } @@ -229,6 +285,8 @@ class SystemizationTodoWorkbookImporter 'source_key' => $oldSourceKeys[$todo->id] ?? null, ]); } + + $this->audit->recordMany($auditEvents); }); return [ @@ -272,11 +330,15 @@ class SystemizationTodoWorkbookImporter if ($rowNumber <= $headerRow) { continue; } - $categoryValue = $this->cell($cells, $columns['category'] ?? null); + $categoryValue = $this->cleanImportedLabel( + $this->cell($cells, $columns['category'] ?? null) + ); if ($categoryValue !== '') { $category = $categoryValue; } - $sectionValue = $this->cell($cells, $columns['section'] ?? null); + $sectionValue = $this->cleanImportedLabel( + $this->cell($cells, $columns['section'] ?? null) + ); if ($sectionValue !== '') { $section = $sectionValue; } @@ -362,7 +424,7 @@ class SystemizationTodoWorkbookImporter static fn (string $line): bool => preg_match($marker, trim($line)) === 1, ); if (count($numberedLines) < 2) { - return [$value]; + return [$this->cleanImportedLabel($value)]; } $segments = []; @@ -385,7 +447,9 @@ class SystemizationTodoWorkbookImporter $segments[] = trim($current); } - return $segments !== [] ? $segments : [$value]; + return $segments !== [] + ? $segments + : [$this->cleanImportedLabel($value)]; } /** @@ -419,8 +483,8 @@ class SystemizationTodoWorkbookImporter $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']), + && $this->matchGroupKey($todo->category) === $this->matchGroupKey($row['category']) + && $this->matchGroupKey($todo->section) === $this->matchGroupKey($row['section']), )); if ($available === []) { continue; @@ -467,8 +531,8 @@ class SystemizationTodoWorkbookImporter foreach ($remainingIds as $todoId) { $todo = $todoById->get($todoId); if (! $todo - || $this->matchText($todo->category) !== $this->matchText($row['category']) - || $this->matchText($todo->section) !== $this->matchText($row['section'])) { + || $this->matchGroupKey($todo->category) !== $this->matchGroupKey($row['category']) + || $this->matchGroupKey($todo->section) !== $this->matchGroupKey($row['section'])) { continue; } $similarity = $this->titleSimilarity((string) $todo->title, $row['title']); @@ -510,6 +574,20 @@ class SystemizationTodoWorkbookImporter return $this->matchText($value); } + private function matchGroupKey(?string $value): string + { + return $this->matchTitleKey($this->cleanImportedLabel($value)); + } + + private function cleanImportedLabel(?string $value): string + { + return trim((string) preg_replace( + '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u', + '', + (string) $value, + )); + } + private function matchText(?string $value): string { return mb_strtolower( diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 607c60d7..2205c5ec 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\Business; +use App\Business\SystemizationTodoAuditLogger; use App\Business\SystemizationTodoWorkbookImporter; use App\Models\Business\SystemizationTodo; use App\Models\Business\SystemizationTodoAttachment; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -27,6 +29,11 @@ class SystemizationTodos extends Controller 'none' => '无提醒', ]; + public function __construct( + private readonly SystemizationTodoAuditLogger $audit, + ) { + } + public function page(): array { $filters = $this->filters(); @@ -202,6 +209,8 @@ class SystemizationTodos extends Controller 'attachment' => '__ATTACHMENT__', ]), 'import' => route('business.systemization-todos.import'), + 'history' => route('business.systemization-todos.history'), + 'historyExport' => route('business.systemization-todos.history.export'), ], ]; } @@ -392,10 +401,22 @@ class SystemizationTodos extends Controller ? 1 : min($position, $siblings->count() + 1); - return SystemizationTodo::query()->create([ + $todo = SystemizationTodo::query()->create([ ...$data, 'sort_order' => $insertOrder, ]); + $this->audit->record( + 'create', + (int) company_id(), + (int) $todo->id, + null, + $this->audit->snapshot($todo), + user_id(), + null, + ['source' => 'manual'], + ); + + return $todo; }); $this->storeAttachments($request, $todo); @@ -529,16 +550,34 @@ class SystemizationTodos extends Controller if ($ids === []) { return; } - SystemizationTodo::query() + + $todos = SystemizationTodo::query() ->whereIn('id', $ids) - ->update([ + ->lockForUpdate() + ->get(); + $auditEvents = []; + foreach ($todos as $todo) { + $before = $this->audit->snapshot($todo); + $todo->fill([ ...$data, 'updated_by' => user_id(), - ]); + ])->save(); + $auditEvents[] = [ + 'company_id' => (int) company_id(), + 'todo_id' => (int) $todo->id, + 'action' => 'update', + 'before' => $before, + 'after' => $this->audit->snapshot($todo), + 'user_id' => user_id(), + 'meta' => ['source' => 'manual', 'scope' => 'group'], + ]; + } + $this->audit->recordMany($auditEvents); } public function update(Request $request, SystemizationTodo $todo): RedirectResponse { + $before = $this->audit->snapshot($todo, true); $data = $this->validated($request); $this->normalizePendingDates($request, $data); $targetPosition = max(0, (int) ($data['position'] ?? 0)); @@ -573,18 +612,107 @@ class SystemizationTodos extends Controller $todo->setRawAttributes($lockedTodo->getAttributes(), true); }); $this->storeAttachments($request, $todo); + $todo->refresh(); + $after = $this->audit->snapshot($todo, true); + if ($before !== $after) { + $this->audit->record( + 'update', + (int) company_id(), + (int) $todo->id, + $before, + $after, + user_id(), + null, + ['source' => 'manual'], + ); + } return $this->backToPage('待办事项已更新并定位。', 'success', $todo); } public function destroy(SystemizationTodo $todo): RedirectResponse { - $todo->update(['updated_by' => user_id()]); - $todo->delete(); + DB::transaction(function () use ($todo): void { + $before = $this->audit->snapshot($todo, true); + $todo->update(['updated_by' => user_id()]); + $todo->delete(); + $this->audit->record( + 'delete', + (int) company_id(), + (int) $todo->id, + $before, + null, + user_id(), + null, + ['source' => 'manual'], + ); + }); return $this->backToPage('待办事项已删除。'); } + public function history(): JsonResponse + { + $events = $this->audit->history((int) company_id()); + + return response()->json([ + 'events' => $events, + 'total' => count($events), + 'limit' => SystemizationTodoAuditLogger::HISTORY_LIMIT, + ]); + } + + public function historyExport(Request $request): StreamedResponse + { + $action = trim((string) $request->query('action', '')); + $keyword = trim((string) $request->query('keyword', '')); + $rows = array_values(array_filter( + $this->audit->history((int) company_id()), + fn (array $row): bool => $this->historyMatches($row, $action, $keyword), + )); + + return response()->streamDownload(function () use ($rows): void { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + fputcsv($handle, ['时间', '操作', '类型', '内容', '待办事项', '变更内容', '操作人']); + foreach ($rows as $row) { + fputcsv($handle, [ + $row['at'], + $row['action_label'], + $row['category'], + $row['section'], + $row['title'], + implode(';', $row['changes']), + $row['actor'], + ]); + } + fclose($handle); + }, '系统化待办_操作记录.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + private function historyMatches(array $row, string $action, string $keyword): bool + { + if ($action !== '' && (string) ($row['action'] ?? '') !== $action) { + return false; + } + if ($keyword === '') { + return true; + } + + $haystack = implode(' ', [ + (string) ($row['category'] ?? ''), + (string) ($row['section'] ?? ''), + (string) ($row['title'] ?? ''), + (string) ($row['actor'] ?? ''), + implode(' ', (array) ($row['changes'] ?? [])), + ]); + + return mb_stripos($haystack, $keyword, 0, 'UTF-8') !== false; + } + public function attachment(SystemizationTodo $todo, SystemizationTodoAttachment $attachment): StreamedResponse { abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404); diff --git a/database/migrations/2026_10_27_150000_create_systemization_todo_audit_logs.php b/database/migrations/2026_10_27_150000_create_systemization_todo_audit_logs.php new file mode 100644 index 00000000..5c7f8654 --- /dev/null +++ b/database/migrations/2026_10_27_150000_create_systemization_todo_audit_logs.php @@ -0,0 +1,47 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id')->default(1); + $table->unsignedBigInteger('todo_id')->nullable(); + $table->string('action', 32); + $table->longText('before_json')->nullable(); + $table->longText('after_json')->nullable(); + $table->unsignedBigInteger('actor_user_id')->nullable(); + $table->string('actor_name', 191)->nullable(); + $table->longText('extra_json')->nullable(); + $table->dateTime('at'); + $table->timestamps(); + + $table->index( + ['company_id', 'at'], + 'systemization_todo_audit_company_at_index' + ); + $table->index( + ['company_id', 'todo_id', 'id'], + 'systemization_todo_audit_company_todo_index' + ); + $table->index( + ['company_id', 'action', 'id'], + 'systemization_todo_audit_company_action_index' + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_systemization_todo_audit_logs'); + } +}; diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index 1d206f65..4d84db70 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -11,6 +11,7 @@ .sys-todo-page__button--primary { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; } .sys-todo-page__button--primary:hover { background:#6a5129; color:#fff; } .sys-todo-page__button .material-icons-outlined { font-size:1rem; } + .sys-todo-page__head-actions .sys-todo-page__button { min-height:2.125rem; } .sys-todo-page__intro { margin:.35rem 0 0; color:var(--sys-muted); font-size:.75rem; line-height:1.6; } .sys-todo-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.55rem; margin:.8rem 0; } .sys-todo-summary__item { display:flex; align-items:center; justify-content:space-between; gap:.6rem; padding:.5rem .7rem; border:1px solid #e6dfd2; border-radius:.5rem; background:#fffdf9; } @@ -111,6 +112,7 @@ .sys-todo-import label { display:grid; gap:.3rem; color:#595959; font-size:.75rem; } .sys-todo-import input { font:inherit; font-size:.8125rem; } .sys-todo-dialog { width:min(64rem,calc(100vw - 2rem)); max-height:90vh; padding:0; border:1px solid var(--sys-border); border-radius:1rem; background:#fff; color:#171717; overflow:hidden; } + .sys-todo-dialog--history { width:min(82rem,calc(100vw - 2rem)); } .sys-todo-dialog::backdrop { background:rgba(23,23,23,.5); } .sys-todo-dialog__head { display:flex; align-items:center; gap:1rem; padding:1rem 1.25rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; } .sys-todo-dialog__head h2 { margin:0; font-size:1rem; } @@ -176,6 +178,30 @@ .sys-todo-dialog__foot .sys-todo-dialog__delete { margin-right:auto; border-color:#e7c3c3; color:#b80000; } .sys-todo-dialog__foot .sys-todo-dialog__delete:hover { background:#fff3f3; } .sys-todo-dialog__foot .sys-todo-dialog__delete[hidden] { display:none; } + .sys-todo-history-tools { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.75rem; } + .sys-todo-history-tools select, .sys-todo-history-tools input { min-height:2.1rem; padding:0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.78rem; } + .sys-todo-history-tools input { flex:1 1 16rem; min-width:12rem; } + .sys-todo-history-tools button { min-height:2.1rem; padding:0 .75rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.78rem; cursor:pointer; } + .sys-todo-history-tools button:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); } + .sys-todo-history-meta { color:#666; font-size:.72rem; } + .sys-todo-history-scroll { max-height:calc(90vh - 13rem); overflow:auto; border:1px solid var(--sys-border); border-radius:.55rem; } + .sys-todo-history-table { width:100%; min-width:68rem; border-collapse:separate; border-spacing:0; font-size:.75rem; } + .sys-todo-history-table th { position:sticky; top:0; z-index:1; padding:.55rem .65rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; } + .sys-todo-history-table td { padding:.6rem .65rem; border-bottom:1px solid #eee7da; color:#302b24; line-height:1.55; vertical-align:top; overflow-wrap:anywhere; } + .sys-todo-history-table tbody tr:hover td { background:#fdfbf6; } + .sys-todo-history-table__at { width:9.5rem; white-space:nowrap; } + .sys-todo-history-table__action { width:5rem; white-space:nowrap; } + .sys-todo-history-table__item { width:18rem; } + .sys-todo-history-table__actor { width:7rem; white-space:nowrap; } + .sys-todo-history-tag { display:inline-flex; align-items:center; padding:.08rem .42rem; border:1px solid #d9cfbd; border-radius:999px; background:#faf6ee; color:#51483c; font-size:.7rem; font-weight:600; } + .sys-todo-history-tag--create,.sys-todo-history-tag--import { border-color:#bad0ac; background:#f2f8ed; color:#53793e; } + .sys-todo-history-tag--update { border-color:#dbbf7a; background:#fff7df; color:#935f07; } + .sys-todo-history-tag--delete { border-color:#e7c3c3; background:#fff3f3; color:#b80000; } + .sys-todo-history-item-title { margin-top:.2rem; color:#595959; font-size:.72rem; } + .sys-todo-history-changes { margin:0; padding-left:1rem; } + .sys-todo-history-changes li + li { margin-top:.22rem; } + .sys-todo-history-empty { padding:1.5rem 1rem; color:#666; font-size:.8rem; text-align:center; } + .sys-todo-history-empty[hidden] { display:none; } .sys-todo-confirm { width:min(30rem,calc(100vw - 2rem)); padding:0; border:1px solid var(--sys-border); border-radius:.8rem; background:#fff; color:#171717; overflow:hidden; } .sys-todo-confirm::backdrop { background:rgba(23,23,23,.58); } .sys-todo-confirm__head { display:flex; align-items:center; gap:.55rem; padding:.9rem 1rem; border-bottom:1px solid #e8e0d2; background:#fff8f8; color:#b80000; font-weight:600; } @@ -224,6 +250,12 @@

日常过会使用。在类型、内容或序号旁直接添加,筛选修改后仍保持当前筛选结果。

+
+ +
@@ -747,6 +779,43 @@ + +
+

系统化待办 · 操作记录

+ +
+
+
+ + + + + 时间为北京时间,保留最近 500 条 +
+
+ + + + + + + + + + + +
时间操作类型 / 内容 / 待办事项变更内容操作人
+
+ +
+
+
@csrf @@ -847,6 +916,13 @@ 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 historyDialog = document.getElementById('sysTodoHistoryDialog'); + var historyAction = historyDialog.querySelector('[data-sys-todo-history-action]'); + var historyKeyword = historyDialog.querySelector('[data-sys-todo-history-keyword]'); + var historyExport = historyDialog.querySelector('[data-sys-todo-history-export]'); + var historyMeta = historyDialog.querySelector('[data-sys-todo-history-meta]'); + var historyBody = historyDialog.querySelector('[data-sys-todo-history-body]'); + var historyEmpty = historyDialog.querySelector('[data-sys-todo-history-empty]'); var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); var positionGroups = @json($todoPositionGroups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); var contentOptions = @json($todoContentOptions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -873,6 +949,7 @@ return Number(column.getAttribute('data-default-width') || 100); }); var columnWidths = defaultColumnWidths.slice(); + var historyEvents = []; function decodePayload(value) { var binary = atob(value); @@ -1494,7 +1571,134 @@ else deleteConfirm.removeAttribute('open'); } + function historyFilteredEvents() { + var action = historyAction.value; + var keyword = historyKeyword.value.trim().toLowerCase(); + return historyEvents.filter(function (event) { + if (action && event.action !== action) return false; + if (!keyword) return true; + var changes = Array.isArray(event.changes) ? event.changes.join(' ') : ''; + var haystack = [ + event.category, + event.section, + event.title, + event.actor, + changes + ].join(' ').toLowerCase(); + return haystack.indexOf(keyword) >= 0; + }); + } + + function appendHistoryChanges(cell, event) { + var changes = Array.isArray(event.changes) && event.changes.length + ? event.changes + : ['—']; + if (changes.length === 1) { + cell.textContent = changes[0]; + return; + } + var list = document.createElement('ul'); + list.className = 'sys-todo-history-changes'; + changes.forEach(function (change) { + var item = document.createElement('li'); + item.textContent = change; + list.appendChild(item); + }); + cell.appendChild(list); + } + + function renderHistory() { + var events = historyFilteredEvents(); + historyBody.replaceChildren(); + events.forEach(function (event) { + var row = document.createElement('tr'); + + var at = document.createElement('td'); + at.className = 'sys-todo-history-table__at'; + at.textContent = event.at || '—'; + row.appendChild(at); + + var action = document.createElement('td'); + action.className = 'sys-todo-history-table__action'; + var tag = document.createElement('span'); + tag.className = 'sys-todo-history-tag sys-todo-history-tag--' + (event.action || ''); + tag.textContent = event.action_label || event.action || '—'; + action.appendChild(tag); + row.appendChild(action); + + var item = document.createElement('td'); + item.className = 'sys-todo-history-table__item'; + var category = document.createElement('div'); + category.textContent = [event.category, event.section].filter(Boolean).join(' / ') || '—'; + var title = document.createElement('div'); + title.className = 'sys-todo-history-item-title'; + title.textContent = event.title || '—'; + item.appendChild(category); + item.appendChild(title); + row.appendChild(item); + + var changes = document.createElement('td'); + appendHistoryChanges(changes, event); + row.appendChild(changes); + + var actor = document.createElement('td'); + actor.className = 'sys-todo-history-table__actor'; + actor.textContent = event.actor || '—'; + row.appendChild(actor); + historyBody.appendChild(row); + }); + + historyEmpty.hidden = events.length > 0; + historyEmpty.textContent = historyEvents.length + ? '没有符合条件的操作记录。' + : '暂无操作记录;从本次功能上线后的第一次新增、修改、删除或导入开始记录。'; + historyMeta.textContent = '显示 ' + events.length + ' / ' + historyEvents.length + ' 条'; + var params = new URLSearchParams(); + if (historyAction.value) params.set('action', historyAction.value); + if (historyKeyword.value.trim()) params.set('keyword', historyKeyword.value.trim()); + historyExport.setAttribute( + 'data-export-url', + routes.historyExport + (params.toString() ? '?' + params.toString() : '') + ); + } + + function loadHistory() { + historyMeta.textContent = '正在读取…'; + fetch(routes.history, { + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } + }).then(function (response) { + return response.json().catch(function () { return {}; }).then(function (data) { + if (!response.ok) throw new Error(data.message || '读取失败(HTTP ' + response.status + ')'); + return data; + }); + }).then(function (data) { + historyEvents = Array.isArray(data.events) ? data.events : []; + renderHistory(); + }).catch(function (error) { + historyEvents = []; + historyBody.replaceChildren(); + historyEmpty.hidden = false; + historyEmpty.textContent = '操作记录读取失败:' + (error && error.message ? error.message : error); + historyMeta.textContent = '读取失败'; + }); + } + + function openHistoryDialog() { + if (typeof historyDialog.showModal === 'function') historyDialog.showModal(); + else historyDialog.setAttribute('open', ''); + loadHistory(); + } + + function closeHistoryDialog() { + if (typeof historyDialog.close === 'function') historyDialog.close(); + else historyDialog.removeAttribute('open'); + } + document.addEventListener('click', function (event) { + if (event.target.closest('[data-sys-todo-history-open]')) { + openHistoryDialog(); + return; + } var gallery = event.target.closest('[data-sys-todo-image-gallery]'); if (gallery) { event.preventDefault(); @@ -1575,6 +1779,7 @@ 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-history-close]')) closeHistoryDialog(); if (event.target.closest('[data-sys-todo-image-close]')) closeImageViewer(); if (event.target.closest('[data-sys-todo-image-prev]')) showPreviousImage(); if (event.target.closest('[data-sys-todo-image-next]')) showNextImage(); @@ -1602,6 +1807,15 @@ deleteConfirm.addEventListener('click', function (event) { if (event.target === deleteConfirm) closeDeleteConfirm(); }); + historyDialog.addEventListener('click', function (event) { + if (event.target === historyDialog) closeHistoryDialog(); + }); + historyAction.addEventListener('change', renderHistory); + historyKeyword.addEventListener('input', renderHistory); + historyExport.addEventListener('click', function () { + var url = historyExport.getAttribute('data-export-url'); + if (url) window.location.href = url; + }); categorySelect.addEventListener('change', function () { syncCategoryValue(); refreshSectionForCategory(); diff --git a/routes/admin.php b/routes/admin.php index b4899f31..b309a010 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -568,6 +568,11 @@ Route::get('workspace/courier-payables/third-party-warehouse-storage/settlement- ->middleware('business.page:courier-payables,third-party-warehouse-storage') ->name('business.courier-payables.third-party-warehouse-storage.settlement-export'); +// 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。 +Route::get('workspace/other/systemization-todos/history/export', [SystemizationTodos::class, 'historyExport']) + ->middleware('business.page:other,systemization-todos') + ->name('business.systemization-todos.history.export'); + Route::middleware('business.page.viewer')->group(function (): void { Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export') ->name('business.pages.export'); @@ -785,6 +790,7 @@ Route::prefix('workspace/other/systemization-todos') ->middleware('import') ->name('import'); Route::post('/', [SystemizationTodos::class, 'store'])->name('store'); + Route::get('history', [SystemizationTodos::class, 'history'])->name('history'); Route::patch('{todo}/category', [SystemizationTodos::class, 'updateCategory']) ->whereNumber('todo') ->name('category'); diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index 63b741ba..a21e9fa8 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -39,6 +39,9 @@ class SystemizationTodosTest extends FeatureTestCase ->assertSeeText('完成情况 / 备注') ->assertSeeText('提醒') ->assertSee('data-sys-todo-default-start-date', false) + ->assertSee('data-sys-todo-history-open', false) + ->assertSee('id="sysTodoHistoryDialog"', false) + ->assertSee('data-sys-todo-history-export', false) ->assertSee('data-sys-todo-inline-add', false) ->assertSee('data-sys-todo-col-resizer', false) ->assertSee('data-sys-todo-reset-columns', false) @@ -857,6 +860,121 @@ class SystemizationTodosTest extends FeatureTestCase } } + public function test_manual_changes_are_listed_in_operation_history_and_csv(): void + { + $this->loginAs(); + + $this->post(route('business.systemization-todos.store'), [ + 'category' => '收入', + 'section' => '操作记录测试', + 'title' => '原待办', + 'owner' => '晨曦', + 'assignee' => '栎阳', + 'start_date' => '2026-10-27', + 'due_date' => '2026-10-30', + 'status' => SystemizationTodo::STATUS_INCOMPLETE, + ])->assertRedirect(); + + $todo = SystemizationTodo::query()->firstOrFail(); + $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [ + 'category' => '收入', + 'section' => '操作记录测试', + 'title' => '修改后的待办', + 'owner' => '晨曦、人参', + 'assignee' => '栎阳', + 'start_date' => '2026-10-27', + 'due_date' => '2026-10-31', + 'status' => SystemizationTodo::STATUS_COMPLETED, + 'progress' => '已完成并留档', + ])->assertRedirect(); + + $this->delete(route('business.systemization-todos.destroy', ['todo' => $todo->id])) + ->assertRedirect(); + + $history = $this->getJson(route('business.systemization-todos.history')) + ->assertOk() + ->json(); + + $this->assertSame(['delete', 'update', 'create'], array_column($history['events'], 'action')); + $this->assertSame($this->user->name, $history['events'][0]['actor']); + $this->assertSame('修改后的待办', $history['events'][1]['title']); + $this->assertContains('待办事项:原待办 → 修改后的待办', $history['events'][1]['changes']); + $this->assertContains('状态:未完成 → 已完成', $history['events'][1]['changes']); + $this->assertStringContainsString('删除前:收入 · 操作记录测试 · 修改后的待办', $history['events'][0]['changes'][0]); + + $csv = $this->get( + route('business.systemization-todos.history.export') . '?action=update' + )->streamedContent(); + $this->assertStringContainsString('时间,操作,类型,内容,待办事项,变更内容,操作人', $csv); + $this->assertStringContainsString('修改,收入,操作记录测试,修改后的待办', $csv); + $this->assertStringNotContainsString('删除前:', $csv); + $this->assertStringNotContainsString('新增:', $csv); + } + + public function test_excel_sync_audits_changes_and_matches_numbered_group_labels_without_duplicates(): void + { + $this->loginAs(); + + $todo = $this->createTodo( + '收入', + '平台收入核对', + '平台收入核对', + '2026-11-30', + 10, + ); + $todo->update(['imported_at' => now()]); + + $path = sys_get_temp_dir() . '/systemization-numbered-sync-' . uniqid() . '.xlsx'; + try { + $this->writeWorkbook($path, [[ + '1、收入', + '2、平台收入核对', + '1、平台收入核对(已更新)', + '晨曦', + '栎阳', + '2026-10-27', + '2026-11-30', + null, + null, + ]]); + + $preview = app(SystemizationTodoWorkbookImporter::class) + ->sync($path, (int) company_id(), user_id(), true); + $this->assertSame(0, $preview['created']); + $this->assertSame(1, $preview['updated']); + $this->assertSame(0, $preview['retained']); + $this->assertDatabaseCount('business_systemization_todo_audit_logs', 0); + + $result = app(SystemizationTodoWorkbookImporter::class) + ->sync($path, (int) company_id(), user_id()); + $this->assertSame(0, $result['created']); + $this->assertSame(1, $result['updated']); + $this->assertSame(0, $result['retained']); + $this->assertSame(1, SystemizationTodo::query()->count()); + + $todo->refresh(); + $this->assertSame('收入', $todo->category); + $this->assertSame('平台收入核对', $todo->section); + $this->assertSame('平台收入核对(已更新)', $todo->title); + $this->assertDatabaseHas('business_systemization_todo_audit_logs', [ + 'company_id' => (int) company_id(), + 'todo_id' => $todo->id, + 'action' => 'import', + ]); + + $event = $this->getJson(route('business.systemization-todos.history')) + ->assertOk() + ->json('events.0'); + $this->assertSame('import', $event['action']); + $this->assertContains( + '待办事项:平台收入核对 → 平台收入核对(已更新)', + $event['changes'] + ); + } finally { + @unlink($path); + } + } + public function test_excel_sync_splits_numbered_titles_and_keeps_attachment_on_first_item(): void { Storage::fake('uploads');