feat: 实现操作日志管理界面
- ActivityLogResource: Filament 资源类 - 只读模式(禁用创建、编辑、删除) - 表格列:时间、用户、操作类型、对象、详情 - 按时间倒序排序 - 支持多维度筛选(时间范围、操作类型、用户、对象类型) - 集成导出功能(Excel/CSV) - ViewActivityLog: 日志详情页面 - 完整的变更信息展示 - JSON diff 对比视图 - 支持查看原始 JSON 数据 - activity-log-diff.blade.php: Diff 对比组件 - 字段级别的变更对比 - 使用颜色区分新旧值(绿色/红色) - 支持 JSON 数据格式化显示
This commit is contained in:
249
app/Filament/Resources/ActivityLogResource.php
Normal file
249
app/Filament/Resources/ActivityLogResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [
|
||||||
|
// 不允许创建操作
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user