feat: 实现操作日志管理界面

- ActivityLogResource: Filament 资源类
  - 只读模式(禁用创建、编辑、删除)
  - 表格列:时间、用户、操作类型、对象、详情
  - 按时间倒序排序
  - 支持多维度筛选(时间范围、操作类型、用户、对象类型)
  - 集成导出功能(Excel/CSV)

- ViewActivityLog: 日志详情页面
  - 完整的变更信息展示
  - JSON diff 对比视图
  - 支持查看原始 JSON 数据

- activity-log-diff.blade.php: Diff 对比组件
  - 字段级别的变更对比
  - 使用颜色区分新旧值(绿色/红色)
  - 支持 JSON 数据格式化显示
This commit is contained in:
2026-03-09 10:08:44 +08:00
parent 232db047f1
commit b9c897cd64
4 changed files with 459 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ActivityLogResource\Pages;
use App\Exports\ActivityLogExport;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\Activitylog\Models\Activity;
class ActivityLogResource extends Resource
{
protected static ?string $model = Activity::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationLabel = '操作日志';
protected static ?string $modelLabel = '操作日志';
protected static ?string $pluralModelLabel = '操作日志';
protected static ?int $navigationSort = 2;
// 禁用创建功能
public static function canCreate(): bool
{
return false;
}
public static function form(Form $form): Form
{
return $form
->schema([
// 只读资源,不需要表单
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('操作时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('causer.name')
->label('操作用户')
->searchable()
->sortable()
->default('系统')
->placeholder('系统'),
Tables\Columns\TextColumn::make('description')
->label('操作类型')
->badge()
->color(fn (string $state): string => match ($state) {
'created' => 'success',
'updated' => 'info',
'deleted' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'created' => '创建',
'updated' => '更新',
'deleted' => '删除',
default => $state,
})
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('subject_type')
->label('操作对象')
->formatStateUsing(function (?string $state): string {
if (!$state) {
return '-';
}
// 提取类名
$className = class_basename($state);
// 转换为中文
return match ($className) {
'SystemSetting' => '系统设置',
'User' => '用户',
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'SopTemplate' => 'SOP模板',
default => $className,
};
})
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('subject_id')
->label('对象ID')
->searchable()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('properties')
->label('详情')
->limit(50)
->tooltip(function (Tables\Columns\TextColumn $column): ?string {
$state = $column->getState();
if (is_array($state)) {
return json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
return null;
})
->formatStateUsing(function ($state): string {
if (is_array($state)) {
$summary = [];
if (isset($state['attributes'])) {
$summary[] = '新值: ' . count($state['attributes']) . '项';
}
if (isset($state['old'])) {
$summary[] = '旧值: ' . count($state['old']) . '项';
}
return implode(', ', $summary) ?: '无变更';
}
return '-';
})
->toggleable(),
])
->defaultSort('created_at', 'desc')
->filters([
// 时间范围筛选
Tables\Filters\Filter::make('created_at')
->form([
\Filament\Forms\Components\DatePicker::make('created_from')
->label('开始时间')
->placeholder('选择开始时间'),
\Filament\Forms\Components\DatePicker::make('created_until')
->label('结束时间')
->placeholder('选择结束时间'),
])
->query(function ($query, array $data) {
return $query
->when($data['created_from'], fn ($query, $date) => $query->whereDate('created_at', '>=', $date))
->when($data['created_until'], fn ($query, $date) => $query->whereDate('created_at', '<=', $date));
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['created_from'] ?? null) {
$indicators[] = Tables\Filters\Indicator::make('开始时间: ' . \Carbon\Carbon::parse($data['created_from'])->format('Y-m-d'))
->removeField('created_from');
}
if ($data['created_until'] ?? null) {
$indicators[] = Tables\Filters\Indicator::make('结束时间: ' . \Carbon\Carbon::parse($data['created_until'])->format('Y-m-d'))
->removeField('created_until');
}
return $indicators;
}),
// 操作类型筛选
Tables\Filters\SelectFilter::make('description')
->label('操作类型')
->options([
'created' => '创建',
'updated' => '更新',
'deleted' => '删除',
])
->placeholder('全部类型'),
// 用户筛选
Tables\Filters\SelectFilter::make('causer_id')
->label('操作用户')
->relationship('causer', 'name')
->searchable()
->preload()
->placeholder('全部用户'),
// 对象类型筛选
Tables\Filters\SelectFilter::make('subject_type')
->label('对象类型')
->options([
'App\\Models\\SystemSetting' => '系统设置',
'App\\Models\\User' => '用户',
'App\\Models\\Document' => '文档',
'App\\Models\\Group' => '分组',
'App\\Models\\Terminal' => '终端',
'App\\Models\\SopTemplate' => 'SOP模板',
])
->placeholder('全部类型'),
])
->actions([
Tables\Actions\ViewAction::make()
->label('查看'),
])
->bulkActions([
// 不允许批量操作
])
->headerActions([
// 导出操作
Tables\Actions\Action::make('export')
->label('导出日志')
->icon('heroicon-o-arrow-down-tray')
->form([
\Filament\Forms\Components\Select::make('format')
->label('导出格式')
->options([
'xlsx' => 'Excel (XLSX)',
'csv' => 'CSV',
])
->default('xlsx')
->required(),
])
->action(function (array $data, Table $table) {
// 获取当前筛选后的查询
$query = $table->getFilteredTableQuery();
// 导出文件名
$filename = '操作日志_' . now()->format('YmdHis');
// 根据格式导出
if ($data['format'] === 'csv') {
return Excel::download(
new ActivityLogExport($query),
$filename . '.csv',
\Maatwebsite\Excel\Excel::CSV
);
}
return Excel::download(
new ActivityLogExport($query),
$filename . '.xlsx'
);
})
->color('success')
->requiresConfirmation()
->modalHeading('导出操作日志')
->modalDescription('将根据当前筛选条件导出日志数据')
->modalSubmitActionLabel('确认导出'),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListActivityLogs::route('/'),
'view' => Pages\ViewActivityLog::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Filament\Resources\ActivityLogResource\Pages;
use App\Filament\Resources\ActivityLogResource;
use Filament\Resources\Pages\ListRecords;
class ListActivityLogs extends ListRecords
{
protected static string $resource = ActivityLogResource::class;
protected function getHeaderActions(): array
{
return [
// 不允许创建操作
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources\ActivityLogResource\Pages;
use App\Filament\Resources\ActivityLogResource;
use Filament\Infolists;
use Filament\Infolists\Infolist;
use Filament\Resources\Pages\ViewRecord;
class ViewActivityLog extends ViewRecord
{
protected static string $resource = ActivityLogResource::class;
protected function getHeaderActions(): array
{
return [
// 不允许编辑和删除操作
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('基本信息')
->schema([
Infolists\Components\TextEntry::make('created_at')
->label('操作时间')
->dateTime('Y-m-d H:i:s'),
Infolists\Components\TextEntry::make('causer.name')
->label('操作用户')
->default('系统'),
Infolists\Components\TextEntry::make('description')
->label('操作类型')
->badge()
->color(fn (string $state): string => match ($state) {
'created' => 'success',
'updated' => 'info',
'deleted' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'created' => '创建',
'updated' => '更新',
'deleted' => '删除',
default => $state,
}),
Infolists\Components\TextEntry::make('subject_type')
->label('操作对象类型')
->formatStateUsing(function (?string $state): string {
if (!$state) {
return '-';
}
$className = class_basename($state);
return match ($className) {
'SystemSetting' => '系统设置',
'User' => '用户',
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'SopTemplate' => 'SOP模板',
default => $className,
};
}),
Infolists\Components\TextEntry::make('subject_id')
->label('对象ID'),
Infolists\Components\TextEntry::make('log_name')
->label('日志名称')
->default('default'),
])
->columns(2),
Infolists\Components\Section::make('变更详情')
->schema([
Infolists\Components\ViewEntry::make('properties')
->label('')
->view('filament.infolists.components.activity-log-diff')
->columnSpanFull()
->visible(fn ($record) => !empty($record->properties)),
])
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,103 @@
<div class="space-y-4">
@php
$properties = $getState();
$old = $properties['old'] ?? [];
$attributes = $properties['attributes'] ?? [];
// 获取所有键
$allKeys = array_unique(array_merge(array_keys($old), array_keys($attributes)));
@endphp
@if(empty($allKeys))
<div class="text-sm text-gray-500 dark:text-gray-400">
无变更数据
</div>
@else
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-1/4">
字段
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-3/8">
旧值
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-3/8">
新值
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
@foreach($allKeys as $key)
@php
$oldValue = $old[$key] ?? null;
$newValue = $attributes[$key] ?? null;
$hasChanged = $oldValue !== $newValue;
@endphp
<tr class="{{ $hasChanged ? 'bg-yellow-50 dark:bg-yellow-900/10' : '' }}">
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $key }}
@if($hasChanged)
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
已变更
</span>
@endif
</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
@if($oldValue !== null)
<div class="font-mono text-xs bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 p-2 rounded border border-red-200 dark:border-red-800">
@if(is_array($oldValue) || is_object($oldValue))
<pre class="whitespace-pre-wrap break-words">{{ json_encode($oldValue, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) }}</pre>
@else
{{ $oldValue }}
@endif
</div>
@else
<span class="text-gray-400 dark:text-gray-600 italic">-</span>
@endif
</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
@if($newValue !== null)
<div class="font-mono text-xs bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 p-2 rounded border border-green-200 dark:border-green-800">
@if(is_array($newValue) || is_object($newValue))
<pre class="whitespace-pre-wrap break-words">{{ json_encode($newValue, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) }}</pre>
@else
{{ $newValue }}
@endif
</div>
@else
<span class="text-gray-400 dark:text-gray-600 italic">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if(!empty($old) || !empty($attributes))
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<details class="space-y-2">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
查看原始 JSON 数据
</summary>
<div class="mt-2 space-y-2">
@if(!empty($old))
<div>
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">旧值 (JSON):</div>
<pre class="text-xs bg-white dark:bg-gray-900 p-3 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">{{ json_encode($old, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) }}</pre>
</div>
@endif
@if(!empty($attributes))
<div>
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">新值 (JSON):</div>
<pre class="text-xs bg-white dark:bg-gray-900 p-3 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">{{ json_encode($attributes, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) }}</pre>
</div>
@endif
</div>
</details>
</div>
@endif
@endif
</div>