feat: 增加系统化待办操作记录并修正 Excel 同步匹配 #690

Merged
yueyang merged 1 commits from feature/yueyang-systemization-todo-audit into main 2026-10-27 15:45:12 +08:00
7 changed files with 906 additions and 17 deletions

View File

@ -0,0 +1,298 @@
<?php
namespace App\Business;
use App\Models\Auth\User;
use App\Models\Business\SystemizationTodo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
class SystemizationTodoAuditLogger
{
public const TABLE = 'business_systemization_todo_audit_logs';
public const HISTORY_LIMIT = 500;
public const ACTION_LABELS = [
'create' => '新增',
'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<string, mixed>
*/
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<int, array{
* company_id:int,
* todo_id:?int,
* action:string,
* before:?array,
* after:?array,
* user_id:?int,
* actor_name?:?string,
* meta?: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<int, array<string, mixed>>
*/
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<string, mixed>
*/
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<int, string>
*/
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;
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('business_systemization_todo_audit_logs')) {
return;
}
Schema::create('business_systemization_todo_audit_logs', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -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 @@
</div>
<p class="sys-todo-page__intro">日常过会使用。在类型、内容或序号旁直接添加,筛选修改后仍保持当前筛选结果。</p>
</div>
<div class="sys-todo-page__head-actions">
<button type="button" class="sys-todo-page__button" data-sys-todo-history-open>
<span class="material-icons-outlined" aria-hidden="true">history</span>
操作记录
</button>
</div>
</div>
</div>
@ -747,6 +779,43 @@
</form>
</dialog>
<dialog class="sys-todo-dialog sys-todo-dialog--history" id="sysTodoHistoryDialog" aria-labelledby="sysTodoHistoryDialogTitle">
<div class="sys-todo-dialog__head">
<h2 id="sysTodoHistoryDialogTitle">系统化待办 · 操作记录</h2>
<button type="button" class="sys-todo-dialog__close" data-sys-todo-history-close aria-label="关闭">×</button>
</div>
<div class="sys-todo-dialog__body">
<div class="sys-todo-history-tools">
<select data-sys-todo-history-action aria-label="按操作筛选">
<option value="">全部操作</option>
<option value="create">新增</option>
<option value="update">修改</option>
<option value="delete">删除</option>
<option value="import">导入</option>
</select>
<input type="search" data-sys-todo-history-keyword placeholder="搜类型、内容、待办事项、变更内容或操作人" aria-label="搜索操作记录">
<button type="button" data-sys-todo-history-export>导出记录 CSV</button>
<span class="sys-todo-history-meta" data-sys-todo-history-meta></span>
<span class="sys-todo-history-meta">时间为北京时间,保留最近 500 条</span>
</div>
<div class="sys-todo-history-scroll">
<table class="sys-todo-history-table">
<thead>
<tr>
<th class="sys-todo-history-table__at">时间</th>
<th class="sys-todo-history-table__action">操作</th>
<th class="sys-todo-history-table__item">类型 / 内容 / 待办事项</th>
<th>变更内容</th>
<th class="sys-todo-history-table__actor">操作人</th>
</tr>
</thead>
<tbody data-sys-todo-history-body></tbody>
</table>
</div>
<div class="sys-todo-history-empty" data-sys-todo-history-empty hidden>暂无操作记录。</div>
</div>
</dialog>
<dialog class="sys-todo-confirm" id="sysTodoDeleteConfirm" aria-labelledby="sysTodoDeleteConfirmTitle">
<form method="POST" data-sys-todo-delete-form>
@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();

View File

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

View File

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