feat: 實作操作紀錄與商品分類單位異動紀錄 (Operation Logs for System, Products, Categories, Units)
This commit is contained in:
119
.agent/skills/permission-management/SKILL.md
Normal file
119
.agent/skills/permission-management/SKILL.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: 權限管理與實作規範
|
||||||
|
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 權限管理與實作規範
|
||||||
|
|
||||||
|
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
|
||||||
|
|
||||||
|
## 1. 定義權限 (Backend)
|
||||||
|
|
||||||
|
所有權限皆定義於 `database/seeders/PermissionSeeder.php`。
|
||||||
|
|
||||||
|
### 步驟:
|
||||||
|
|
||||||
|
1. 開啟 `database/seeders/PermissionSeeder.php`。
|
||||||
|
2. 在 `$permissions` 陣列中新增功能對應的權限字串。
|
||||||
|
* **命名慣例**:`{resource}.{action}` (例如:`system.view_logs`, `products.create`)
|
||||||
|
* 常用動作:`view`, `create`, `edit`, `delete`, `publish`, `export`
|
||||||
|
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
|
||||||
|
* `super-admin`:通常擁有所有權限(程式碼中 `Permission::all()` 自動涵蓋,無需手動新增)。
|
||||||
|
* `admin`:通常擁有大部分權限。
|
||||||
|
* 其他角色 (`warehouse-manager`, `purchaser`, `viewer`):依業務邏輯分配。
|
||||||
|
|
||||||
|
### 範例:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. 新增權限字串
|
||||||
|
$permissions = [
|
||||||
|
// ... 現有權限
|
||||||
|
'system.view_logs', // 新增:檢視系統日誌
|
||||||
|
];
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// 2. 分配給角色
|
||||||
|
$admin->givePermissionTo([
|
||||||
|
// ... 現有權限
|
||||||
|
'system.view_logs',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 套用資料庫變更
|
||||||
|
|
||||||
|
修改 Seeder 後,必須重新執行 Seeder 以將權限寫入資料庫。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 對於所有租戶執行 Seeder (開發環境)
|
||||||
|
php artisan tenants:seed --class=PermissionSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 路由保護 (Backend Middleware)
|
||||||
|
|
||||||
|
在 `routes/web.php` 中,使用 `permission:{name}` middleware 保護路由。
|
||||||
|
|
||||||
|
### 範例:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 單一權限保護
|
||||||
|
Route::get('/logs', [LogController::class, 'index'])
|
||||||
|
->middleware('permission:system.view_logs')
|
||||||
|
->name('logs.index');
|
||||||
|
|
||||||
|
// 路由群組保護
|
||||||
|
Route::middleware('permission:products.view')->group(function () {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// 多重權限 (OR 邏輯:有其一即可)
|
||||||
|
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 前端權限判斷 (React Component)
|
||||||
|
|
||||||
|
使用自訂 Hook `usePermission` 來控制 UI 元素的顯示(例如:隱藏沒有權限的按鈕)。
|
||||||
|
|
||||||
|
### 引入 Hook:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePermission } from "@/hooks/usePermission";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用方式:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function ProductIndex() {
|
||||||
|
const { can } = usePermission();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>商品列表</h1>
|
||||||
|
|
||||||
|
{/* 只有擁有 create 權限才顯示按鈕 */}
|
||||||
|
{can('products.create') && (
|
||||||
|
<Button>新增商品</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 組合判斷 */}
|
||||||
|
{can('products.edit') && <EditButton />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 權限 Hook 介面說明:
|
||||||
|
|
||||||
|
- `can(permission: string)`: 檢查當前使用者是否擁有指定權限。
|
||||||
|
- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
|
||||||
|
- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
|
||||||
|
|
||||||
|
## 檢核清單
|
||||||
|
|
||||||
|
- [ ] `PermissionSeeder.php` 已新增權限字串。
|
||||||
|
- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
|
||||||
|
- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
|
||||||
|
- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
|
||||||
|
- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。
|
||||||
52
app/Http/Controllers/Admin/ActivityLogController.php
Normal file
52
app/Http/Controllers/Admin/ActivityLogController.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
|
class ActivityLogController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$activities = Activity::with('causer')
|
||||||
|
->latest()
|
||||||
|
->paginate($request->input('per_page', 10))
|
||||||
|
->through(function ($activity) {
|
||||||
|
$subjectMap = [
|
||||||
|
'App\Models\User' => '使用者',
|
||||||
|
'App\Models\Role' => '角色',
|
||||||
|
'App\Models\Product' => '商品',
|
||||||
|
'App\Models\Vendor' => '廠商',
|
||||||
|
'App\Models\Category' => '商品分類',
|
||||||
|
'App\Models\Unit' => '單位',
|
||||||
|
'App\Models\PurchaseOrder' => '採購單',
|
||||||
|
];
|
||||||
|
|
||||||
|
$eventMap = [
|
||||||
|
'created' => '新增',
|
||||||
|
'updated' => '更新',
|
||||||
|
'deleted' => '刪除',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $activity->id,
|
||||||
|
'description' => $eventMap[$activity->event] ?? $activity->event,
|
||||||
|
'subject_type' => $subjectMap[$activity->subject_type] ?? class_basename($activity->subject_type),
|
||||||
|
'event' => $activity->event,
|
||||||
|
'causer' => $activity->causer ? $activity->causer->name : 'System',
|
||||||
|
'created_at' => $activity->created_at->format('Y-m-d H:i:s'),
|
||||||
|
'properties' => $activity->properties,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Admin/ActivityLog/Index', [
|
||||||
|
'activities' => $activities,
|
||||||
|
'filters' => [
|
||||||
|
'per_page' => $request->input('per_page', '10'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
@@ -27,4 +28,12 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Product::class);
|
return $this->hasMany(Product::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
|
{
|
||||||
|
return \Spatie\Activitylog\LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class Product extends Model
|
class Product extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, LogsActivity, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
@@ -60,6 +63,19 @@ class Product extends Model
|
|||||||
return $this->hasMany(Inventory::class);
|
return $this->hasMany(Inventory::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function transactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InventoryTransaction::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
|
||||||
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Warehouse::class, 'inventories')
|
return $this->belongsToMany(Warehouse::class, 'inventories')
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class PurchaseOrder extends Model
|
class PurchaseOrder extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
@@ -125,4 +127,12 @@ class PurchaseOrder extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(PurchaseOrderItem::class);
|
return $this->hasMany(PurchaseOrderItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/Models/Role.php
Normal file
20
app/Models/Role.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Spatie\Permission\Models\Role as SpatieRole;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class Role extends SpatieRole
|
||||||
|
{
|
||||||
|
use LogsActivity;
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,23 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
class Unit extends Model
|
class Unit extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UnitFactory> */
|
/** @use HasFactory<\Database\Factories\UnitFactory> */
|
||||||
use HasFactory;
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'code',
|
'code',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
|
{
|
||||||
|
return \Spatie\Activitylog\LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable, HasRoles;
|
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -47,4 +49,12 @@ class User extends Authenticatable
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class Vendor extends Model
|
class Vendor extends Model
|
||||||
{
|
{
|
||||||
|
use LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
'name',
|
'name',
|
||||||
@@ -32,4 +36,12 @@ class Vendor extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(PurchaseOrder::class);
|
return $this->hasMany(PurchaseOrder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"spatie/laravel-activitylog": "^4.10",
|
||||||
"spatie/laravel-permission": "^6.24",
|
"spatie/laravel-permission": "^6.24",
|
||||||
"stancl/tenancy": "^3.9",
|
"stancl/tenancy": "^3.9",
|
||||||
"tightenco/ziggy": "^2.6"
|
"tightenco/ziggy": "^2.6"
|
||||||
|
|||||||
154
composer.lock
generated
154
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "931b01f076d9ee28568cd36f178a0c04",
|
"content-hash": "131ea6e8cc24a6a55229afded6bd9014",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -3413,6 +3413,158 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"time": "2025-12-14T04:43:48+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-activitylog",
|
||||||
|
"version": "4.10.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-activitylog.git",
|
||||||
|
"reference": "bb879775d487438ed9a99e64f09086b608990c10"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bb879775d487438ed9a99e64f09086b608990c10",
|
||||||
|
"reference": "bb879775d487438ed9a99e64f09086b608990c10",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
|
||||||
|
"illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0",
|
||||||
|
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"spatie/laravel-package-tools": "^1.6.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.0 || ^10.0",
|
||||||
|
"pestphp/pest": "^1.20 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Spatie\\Activitylog\\ActivitylogServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\Activitylog\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sebastian De Deyne",
|
||||||
|
"email": "sebastian@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tom Witkowski",
|
||||||
|
"email": "dev.gummibeer@gmail.com",
|
||||||
|
"homepage": "https://gummibeer.de",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A very simple activity logger to monitor the users of your website or application",
|
||||||
|
"homepage": "https://github.com/spatie/activitylog",
|
||||||
|
"keywords": [
|
||||||
|
"activity",
|
||||||
|
"laravel",
|
||||||
|
"log",
|
||||||
|
"spatie",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-activitylog/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-activitylog/tree/4.10.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://spatie.be/open-source/support-us",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-06-15T06:59:49+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-package-tools",
|
||||||
|
"version": "1.92.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||||
|
"reference": "f09a799850b1ed765103a4f0b4355006360c49a5"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5",
|
||||||
|
"reference": "f09a799850b1ed765103a4f0b4355006360c49a5",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.5",
|
||||||
|
"orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
|
||||||
|
"pestphp/pest": "^1.23|^2.1|^3.1",
|
||||||
|
"phpunit/php-code-coverage": "^9.0|^10.0|^11.0",
|
||||||
|
"phpunit/phpunit": "^9.5.24|^10.5|^11.5",
|
||||||
|
"spatie/pest-plugin-test-time": "^1.1|^2.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\LaravelPackageTools\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Tools for creating Laravel packages",
|
||||||
|
"homepage": "https://github.com/spatie/laravel-package-tools",
|
||||||
|
"keywords": [
|
||||||
|
"laravel-package-tools",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-07-17T15:46:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/laravel-permission",
|
"name": "spatie/laravel-permission",
|
||||||
"version": "6.24.0",
|
"version": "6.24.0",
|
||||||
|
|||||||
52
config/activitylog.php
Normal file
52
config/activitylog.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If set to false, no activities will be saved to the database.
|
||||||
|
*/
|
||||||
|
'enabled' => env('ACTIVITY_LOGGER_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When the clean-command is executed, all recording activities older than
|
||||||
|
* the number of days specified here will be deleted.
|
||||||
|
*/
|
||||||
|
'delete_records_older_than_days' => 365,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If no log name is passed to the activity() helper
|
||||||
|
* we use this default log name.
|
||||||
|
*/
|
||||||
|
'default_log_name' => 'default',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You can specify an auth driver here that gets user models.
|
||||||
|
* If this is null we'll use the current Laravel auth driver.
|
||||||
|
*/
|
||||||
|
'default_auth_driver' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If set to true, the subject returns soft deleted models.
|
||||||
|
*/
|
||||||
|
'subject_returns_soft_deleted_models' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This model will be used to log activity.
|
||||||
|
* It should implement the Spatie\Activitylog\Contracts\Activity interface
|
||||||
|
* and extend Illuminate\Database\Eloquent\Model.
|
||||||
|
*/
|
||||||
|
'activity_model' => \Spatie\Activitylog\Models\Activity::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is the name of the table that will be created by the migration and
|
||||||
|
* used by the Activity model shipped with this package.
|
||||||
|
*/
|
||||||
|
'table_name' => env('ACTIVITY_LOGGER_TABLE_NAME', 'activity_log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is the database connection that will be used by the migration and
|
||||||
|
* the Activity model shipped with this package. In case it's not set
|
||||||
|
* Laravel's database.default will be used instead.
|
||||||
|
*/
|
||||||
|
'database_connection' => env('ACTIVITY_LOGGER_DB_CONNECTION'),
|
||||||
|
];
|
||||||
@@ -24,7 +24,7 @@ return [
|
|||||||
* `Spatie\Permission\Contracts\Role` contract.
|
* `Spatie\Permission\Contracts\Role` contract.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'role' => Spatie\Permission\Models\Role::class,
|
'role' => App\Models\Role::class,
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class CreateActivityLogTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->create(config('activitylog.table_name'), function (Blueprint $table) {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->string('log_name')->nullable();
|
||||||
|
$table->text('description');
|
||||||
|
$table->nullableMorphs('subject', 'subject');
|
||||||
|
$table->nullableMorphs('causer', 'causer');
|
||||||
|
$table->json('properties')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->index('log_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class AddEventColumnToActivityLogTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||||
|
$table->string('event')->nullable()->after('subject_type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||||
|
$table->dropColumn('event');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class AddBatchUuidColumnToActivityLogTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||||
|
$table->uuid('batch_uuid')->nullable()->after('properties');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
|
||||||
|
$table->dropColumn('batch_uuid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ class PermissionSeeder extends Seeder
|
|||||||
'roles.create',
|
'roles.create',
|
||||||
'roles.edit',
|
'roles.edit',
|
||||||
'roles.delete',
|
'roles.delete',
|
||||||
|
|
||||||
|
// 系統日誌
|
||||||
|
'system.view_logs',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $permission) {
|
foreach ($permissions as $permission) {
|
||||||
@@ -87,6 +90,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
||||||
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
||||||
'users.view', 'users.create', 'users.edit',
|
'users.view', 'users.create', 'users.edit',
|
||||||
|
'system.view_logs',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// warehouse-manager 管理庫存與倉庫
|
// warehouse-manager 管理庫存與倉庫
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Users
|
Users,
|
||||||
|
FileText
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
@@ -145,6 +146,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/admin/roles",
|
route: "/admin/roles",
|
||||||
permission: "roles.view",
|
permission: "roles.view",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "activity-log",
|
||||||
|
label: "操作紀錄",
|
||||||
|
icon: <FileText className="h-4 w-4" />,
|
||||||
|
route: "/admin/activity-logs",
|
||||||
|
permission: "system.view_logs",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
142
resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx
Normal file
142
resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/Components/ui/dialog";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: number;
|
||||||
|
description: string;
|
||||||
|
subject_type: string;
|
||||||
|
event: string;
|
||||||
|
causer: string;
|
||||||
|
created_at: string;
|
||||||
|
properties: {
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
old?: Record<string, any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
activity: Activity | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) {
|
||||||
|
if (!activity) return null;
|
||||||
|
|
||||||
|
const attributes = activity.properties?.attributes || {};
|
||||||
|
const old = activity.properties?.old || {};
|
||||||
|
|
||||||
|
// Get all keys from both attributes and old to ensure we show all changes
|
||||||
|
const allKeys = Array.from(new Set([...Object.keys(attributes), ...Object.keys(old)]));
|
||||||
|
|
||||||
|
// Filter out internal keys often logged but not useful for users
|
||||||
|
const filteredKeys = allKeys.filter(key =>
|
||||||
|
!['created_at', 'updated_at', 'deleted_at', 'id'].includes(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getEventBadgeColor = (event: string) => {
|
||||||
|
switch (event) {
|
||||||
|
case 'created': return 'bg-green-500';
|
||||||
|
case 'updated': return 'bg-blue-500';
|
||||||
|
case 'deleted': return 'bg-red-500';
|
||||||
|
default: return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventLabel = (event: string) => {
|
||||||
|
switch (event) {
|
||||||
|
case 'created': return '新增';
|
||||||
|
case 'updated': return '更新';
|
||||||
|
case 'deleted': return '刪除';
|
||||||
|
default: return event;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: any) => {
|
||||||
|
if (value === null || value === undefined) return <span className="text-gray-400">-</span>;
|
||||||
|
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
操作詳情
|
||||||
|
<Badge className={getEventBadgeColor(activity.event)}>
|
||||||
|
{getEventLabel(activity.event)}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{activity.created_at} 由 {activity.causer} 執行的操作
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">操作對象:</span>
|
||||||
|
<span className="font-medium ml-2">{activity.subject_type}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">描述:</span>
|
||||||
|
<span className="font-medium ml-2">{activity.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activity.event === 'created' ? (
|
||||||
|
<div className="bg-gray-50 p-4 rounded-md text-center text-gray-500 text-sm">
|
||||||
|
已新增資料 (初始建立)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<div className="grid grid-cols-3 bg-gray-50 p-2 text-sm font-medium text-gray-500">
|
||||||
|
<div>欄位</div>
|
||||||
|
<div>異動前</div>
|
||||||
|
<div>異動後</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
{filteredKeys.length > 0 ? (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredKeys.map((key) => {
|
||||||
|
const oldValue = old[key];
|
||||||
|
const newValue = attributes[key];
|
||||||
|
// Ensure we catch changes even if one value is missing/null
|
||||||
|
// For deleted events, newValue might be empty, so we just show oldValue
|
||||||
|
const isChanged = JSON.stringify(oldValue) !== JSON.stringify(newValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className={`grid grid-cols-3 p-2 text-sm ${isChanged ? 'bg-yellow-50/30' : ''}`}>
|
||||||
|
<div className="font-medium text-gray-700">{key}</div>
|
||||||
|
<div className="text-gray-600 break-words pr-2">
|
||||||
|
{formatValue(oldValue)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-900 break-words font-medium">
|
||||||
|
{activity.event === 'deleted' ? '-' : formatValue(newValue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-gray-500 text-sm">
|
||||||
|
無詳細異動內容
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
resources/js/Pages/Admin/ActivityLog/Index.tsx
Normal file
203
resources/js/Pages/Admin/ActivityLog/Index.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { Head, router } from '@inertiajs/react';
|
||||||
|
import { PageProps } from '@/types/global';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
import Pagination from '@/Components/shared/Pagination';
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import { FileText, Eye } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import ActivityDetailDialog from './ActivityDetailDialog';
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: number;
|
||||||
|
description: string;
|
||||||
|
subject_type: string;
|
||||||
|
event: string;
|
||||||
|
causer: string;
|
||||||
|
created_at: string;
|
||||||
|
properties: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationLinks {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends PageProps {
|
||||||
|
activities: {
|
||||||
|
data: Activity[];
|
||||||
|
links: PaginationLinks[];
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
total: number;
|
||||||
|
from: number;
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
per_page?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityLogIndex({ activities, filters }: Props) {
|
||||||
|
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||||
|
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
const getEventBadgeColor = (event: string) => {
|
||||||
|
switch (event) {
|
||||||
|
case 'created': return 'bg-green-500 hover:bg-green-600';
|
||||||
|
case 'updated': return 'bg-blue-500 hover:bg-blue-600';
|
||||||
|
case 'deleted': return 'bg-red-500 hover:bg-red-600';
|
||||||
|
default: return 'bg-gray-500 hover:bg-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventLabel = (event: string) => {
|
||||||
|
switch (event) {
|
||||||
|
case 'created': return '新增';
|
||||||
|
case 'updated': return '更新';
|
||||||
|
case 'deleted': return '刪除';
|
||||||
|
default: return event;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetail = (activity: Activity) => {
|
||||||
|
setSelectedActivity(activity);
|
||||||
|
setDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handlePerPageChange = (value: string) => {
|
||||||
|
setPerPage(value);
|
||||||
|
router.get(
|
||||||
|
route('activity-logs.index'),
|
||||||
|
{ per_page: value },
|
||||||
|
{ preserveState: false, replace: true, preserveScroll: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '操作紀錄', href: route('activity-logs.index'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="操作紀錄" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6 text-primary-main" />
|
||||||
|
操作紀錄
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
檢視系統內的所有操作活動,包含新增、修改與刪除紀錄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[180px]">時間</TableHead>
|
||||||
|
<TableHead className="w-[150px]">操作人員</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">動作</TableHead>
|
||||||
|
<TableHead className="w-[150px]">對象</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{activities.data.length > 0 ? (
|
||||||
|
activities.data.map((activity) => (
|
||||||
|
<TableRow key={activity.id}>
|
||||||
|
<TableCell className="text-gray-500 font-medium whitespace-nowrap">
|
||||||
|
{activity.created_at}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium text-gray-900">{activity.causer}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={getEventBadgeColor(activity.event)}>
|
||||||
|
{getEventLabel(activity.event)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="bg-slate-50">
|
||||||
|
{activity.subject_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-600" title={activity.description}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{activity.causer}</span>
|
||||||
|
<span className="text-gray-400">執行了</span>
|
||||||
|
<span className="font-medium text-gray-700">{activity.description}</span>
|
||||||
|
<span className="text-gray-400">動作</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewDetail(activity)}
|
||||||
|
className="button-outlined-info"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
|
||||||
|
尚無操作紀錄
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>每頁顯示</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={perPage}
|
||||||
|
onValueChange={handlePerPageChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: "10" },
|
||||||
|
{ label: "20", value: "20" },
|
||||||
|
{ label: "50", value: "50" },
|
||||||
|
{ label: "100", value: "100" }
|
||||||
|
]}
|
||||||
|
className="w-[80px] h-8"
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<span>筆</span>
|
||||||
|
</div>
|
||||||
|
<Pagination links={activities.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActivityDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
activity={selectedActivity}
|
||||||
|
/>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use App\Http\Controllers\TransferOrderController;
|
|||||||
use App\Http\Controllers\UnitController;
|
use App\Http\Controllers\UnitController;
|
||||||
use App\Http\Controllers\Admin\RoleController;
|
use App\Http\Controllers\Admin\RoleController;
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
|
use App\Http\Controllers\Admin\ActivityLogController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
|
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
|
||||||
|
|
||||||
@@ -147,8 +148,13 @@ Route::middleware('auth')->group(function () {
|
|||||||
});
|
});
|
||||||
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
|
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
|
||||||
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
||||||
|
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
|
||||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
|
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::middleware('permission:system.view_logs')->group(function () {
|
||||||
|
Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}); // End of auth middleware group
|
}); // End of auth middleware group
|
||||||
|
|||||||
Reference in New Issue
Block a user