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