[FEAT] 實作角色權限分類、租戶角控管理與介面多語系優化
1. [FEAT] 權限劃分為「系統層級」與「客戶層級」,並在後端強制過濾跨權限分配。 2. [FEAT] 整合選單權限至主選單層級 (基本設定、權限設定),簡化角色管理 UI。 3. [STYLE] 側邊欄優化:補齊多語系翻譯,並為基本設定子選單增加視覺圖示。 4. [REFACTOR] 更新 RoleSeeder,將 tenant-admin 重新分類為客戶層級角色。
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineModel;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MachineSettingController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示機台設定列表
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 20);
|
||||
$query = Machine::query()->with(['machineModel', 'paymentConfig', 'company']);
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$machines = $query->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
// 這裡應根據租戶 (Company) 決定可用的選項,暫採簡單模擬或從 Auth 取得
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
|
||||
return view('admin.basic-settings.machines.index', compact('machines', 'models', 'paymentConfigs', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存新機台 (僅核心欄位)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'serial_no' => 'required|string|unique:machines,serial_no',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
]);
|
||||
|
||||
$imagePaths = [];
|
||||
if ($request->hasFile('images')) {
|
||||
foreach (array_slice($request->file('images'), 0, 3) as $image) {
|
||||
$imagePaths[] = $this->processAndStoreImage($image);
|
||||
}
|
||||
}
|
||||
|
||||
$machine = Machine::create(array_merge($validated, [
|
||||
'status' => 'offline',
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
'card_reader_seconds' => 30, // 預設值
|
||||
'card_reader_checkout_time_1' => '22:30:00',
|
||||
'card_reader_checkout_time_2' => '23:45:00',
|
||||
'payment_buffer_seconds' => 5,
|
||||
'images' => $imagePaths,
|
||||
]));
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示詳細編輯頁面
|
||||
*/
|
||||
public function edit(Machine $machine): View
|
||||
{
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
|
||||
return view('admin.basic-settings.machines.edit', compact('machine', 'models', 'paymentConfigs', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新機台詳細參數
|
||||
*/
|
||||
public function update(Request $request, Machine $machine): RedirectResponse
|
||||
{
|
||||
Log::info('Machine Update Request', ['machine_id' => $machine->id, 'data' => $request->all()]);
|
||||
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'card_reader_seconds' => 'required|integer|min:0',
|
||||
'payment_buffer_seconds' => 'required|integer|min:0',
|
||||
'card_reader_checkout_time_1' => 'nullable|string',
|
||||
'card_reader_checkout_time_2' => 'nullable|string',
|
||||
'heating_start_time' => 'nullable|string',
|
||||
'heating_end_time' => 'nullable|string',
|
||||
'card_reader_no' => 'nullable|string|max:255',
|
||||
'key_no' => 'nullable|string|max:255',
|
||||
'invoice_status' => 'required|integer|in:0,1,2',
|
||||
'welcome_gift_enabled' => 'boolean',
|
||||
'is_spring_slot_1_10' => 'boolean',
|
||||
'is_spring_slot_11_20' => 'boolean',
|
||||
'is_spring_slot_21_30' => 'boolean',
|
||||
'is_spring_slot_31_40' => 'boolean',
|
||||
'is_spring_slot_41_50' => 'boolean',
|
||||
'is_spring_slot_51_60' => 'boolean',
|
||||
'member_system_enabled' => 'boolean',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
]);
|
||||
|
||||
Log::info('Machine Update Validated Data', ['data' => $validated]);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
Log::error('Machine Update Validation Failed', ['errors' => $e->errors()]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$machine->update(array_merge($validated, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
// 處理圖片更新 (若有上傳新圖片,則替換或附加,這裡採簡單邏輯:若有傳 images 則全換)
|
||||
if ($request->hasFile('images')) {
|
||||
// 刪除舊圖
|
||||
if (!empty($machine->images)) {
|
||||
foreach ($machine->images as $oldPath) {
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
$imagePaths = [];
|
||||
foreach (array_slice($request->file('images'), 0, 3) as $image) {
|
||||
$imagePaths[] = $this->processAndStoreImage($image);
|
||||
}
|
||||
$machine->update(['images' => $imagePaths]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理圖片並轉換為 WebP
|
||||
*/
|
||||
private function processAndStoreImage($file): string
|
||||
{
|
||||
$filename = Str::random(40) . '.webp';
|
||||
$path = 'machines/' . $filename;
|
||||
|
||||
// 建立圖資源
|
||||
$image = null;
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
switch ($extension) {
|
||||
case 'jpeg':
|
||||
case 'jpg':
|
||||
$image = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case 'png':
|
||||
$image = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case 'gif':
|
||||
$image = imagecreatefromgif($file->getRealPath());
|
||||
break;
|
||||
case 'webp':
|
||||
$image = imagecreatefromwebp($file->getRealPath());
|
||||
break;
|
||||
}
|
||||
|
||||
if ($image) {
|
||||
// 確保目錄存在
|
||||
Storage::disk('public')->makeDirectory('machines');
|
||||
$fullPath = Storage::disk('public')->path($path);
|
||||
|
||||
// 轉換並儲存
|
||||
imagewebp($image, $fullPath, 80); // 品質 80
|
||||
imagedestroy($image);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
// Fallback to standard store if GD fails
|
||||
return $file->store('machines', 'public');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class PaymentConfigController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示金流配置列表
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 20);
|
||||
$configs = PaymentConfig::query()
|
||||
->with(['company', 'creator'])
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.basic-settings.payment-configs.index', [
|
||||
'paymentConfigs' => $configs
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示新增頁面
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
return view('admin.basic-settings.payment-configs.create', compact('companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存金流配置
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
|
||||
PaymentConfig::create([
|
||||
'name' => $request->name,
|
||||
'company_id' => $request->company_id,
|
||||
'settings' => $request->settings,
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯頁面
|
||||
*/
|
||||
public function edit(PaymentConfig $paymentConfig): View
|
||||
{
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
return view('admin.basic-settings.payment-configs.edit', compact('paymentConfig', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新金流配置
|
||||
*/
|
||||
public function update(Request $request, PaymentConfig $paymentConfig): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
|
||||
$paymentConfig->update([
|
||||
'name' => $request->name,
|
||||
'settings' => $request->settings,
|
||||
'updater_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除金流配置
|
||||
*/
|
||||
public function destroy(PaymentConfig $paymentConfig): RedirectResponse
|
||||
{
|
||||
$paymentConfig->delete();
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration deleted successfully.'));
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class CompanyController extends Controller
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'required|string|max:255',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'valid_until' => 'nullable|date',
|
||||
|
||||
@@ -14,8 +14,17 @@ class MachineController extends AdminController
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$machines = Machine::query()
|
||||
->when($request->status, function ($query, $status) {
|
||||
$query = Machine::query();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
|
||||
@@ -11,15 +11,46 @@ class PermissionController extends Controller
|
||||
public function roles()
|
||||
{
|
||||
$per_page = request()->input('per_page', 10);
|
||||
$roles = \Spatie\Permission\Models\Role::with(['permissions', 'users'])->latest()->paginate($per_page)->withQueryString();
|
||||
$all_permissions = \Spatie\Permission\Models\Permission::all()->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
$user = auth()->user();
|
||||
$query = \App\Models\System\Role::query()->with(['permissions', 'users']);
|
||||
|
||||
// 租戶隔離:租戶只能看到自己公司的角色 + 系統角色 (company_id is null)
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where(function($q) use ($user) {
|
||||
$q->where('company_id', $user->company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
|
||||
// 搜尋:角色名稱
|
||||
if ($search = request()->input('search')) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$roles = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$all_permissions = \Spatie\Permission\Models\Permission::all()
|
||||
->filter(function($perm) {
|
||||
// 排除子項目的權限,只顯示主選單權限
|
||||
$excluded = [
|
||||
'menu.basic.machines',
|
||||
'menu.basic.payment-configs',
|
||||
'menu.companies',
|
||||
'menu.accounts',
|
||||
'menu.roles',
|
||||
];
|
||||
return !in_array($perm->name, $excluded);
|
||||
})
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions'));
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles') ? __('Sub Account Roles') : __('Role Settings');
|
||||
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,14 +64,22 @@ class PermissionController extends Controller
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
|
||||
$role = \Spatie\Permission\Models\Role::create([
|
||||
$is_system = auth()->user()->isSystemAdmin() && $request->boolean('is_system');
|
||||
|
||||
$role = \App\Models\System\Role::create([
|
||||
'name' => $validated['name'],
|
||||
'guard_name' => 'web',
|
||||
'is_system' => false,
|
||||
'company_id' => $is_system ? null : auth()->user()->company_id,
|
||||
'is_system' => $is_system,
|
||||
]);
|
||||
|
||||
if (!empty($validated['permissions'])) {
|
||||
$role->syncPermissions($validated['permissions']);
|
||||
$perms = $validated['permissions'];
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Role created successfully.'));
|
||||
@@ -51,7 +90,7 @@ class PermissionController extends Controller
|
||||
*/
|
||||
public function updateRole(Request $request, $id)
|
||||
{
|
||||
$role = \Spatie\Permission\Models\Role::findOrFail($id);
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:roles,name,' . $id,
|
||||
@@ -59,11 +98,28 @@ class PermissionController extends Controller
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
|
||||
if (!$role->is_system) {
|
||||
$role->update(['name' => $validated['name']]);
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('The Super Admin role is immutable.'));
|
||||
}
|
||||
|
||||
$role->syncPermissions($validated['permissions'] ?? []);
|
||||
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.'));
|
||||
}
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
|
||||
|
||||
$role->update([
|
||||
'name' => $validated['name'],
|
||||
'is_system' => $is_system,
|
||||
'company_id' => $is_system ? null : $role->company_id,
|
||||
]);
|
||||
|
||||
$perms = $validated['permissions'] ?? [];
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
|
||||
return redirect()->back()->with('success', __('Role updated successfully.'));
|
||||
}
|
||||
@@ -73,10 +129,14 @@ class PermissionController extends Controller
|
||||
*/
|
||||
public function destroyRole($id)
|
||||
{
|
||||
$role = \Spatie\Permission\Models\Role::findOrFail($id);
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
|
||||
if ($role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be deleted.'));
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.'));
|
||||
}
|
||||
|
||||
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.'));
|
||||
}
|
||||
|
||||
if ($role->users()->count() > 0) {
|
||||
@@ -115,9 +175,19 @@ class PermissionController extends Controller
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$users = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
$roles = \Spatie\Permission\Models\Role::all();
|
||||
$roles_query = \App\Models\System\Role::where('name', '!=', 'super-admin');
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$roles_query->where(function($q) {
|
||||
$q->where('company_id', auth()->user()->company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
|
||||
return view('admin.data-config.accounts', compact('users', 'companies', 'roles'));
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-accounts') ? __('Sub Account Management') : __('Account Management');
|
||||
|
||||
return view('admin.data-config.accounts', compact('users', 'companies', 'roles', 'title'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +228,10 @@ class PermissionController extends Controller
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be modified via this interface.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username,' . $id,
|
||||
@@ -178,7 +252,13 @@ class PermissionController extends Controller
|
||||
];
|
||||
|
||||
if (auth()->user()->isSystemAdmin()) {
|
||||
$updateData['company_id'] = $validated['company_id'];
|
||||
// 防止超級管理員不小心把自己綁定到租客公司或降級
|
||||
if ($user->id === auth()->id()) {
|
||||
$updateData['company_id'] = null;
|
||||
$validated['role'] = 'super-admin';
|
||||
} else {
|
||||
$updateData['company_id'] = $validated['company_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
@@ -187,7 +267,12 @@ class PermissionController extends Controller
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
$user->syncRoles([$validated['role']]);
|
||||
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
|
||||
if ($user->id === auth()->id() && auth()->user()->isSystemAdmin()) {
|
||||
$user->syncRoles(['super-admin']);
|
||||
} else {
|
||||
$user->syncRoles([$validated['role']]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Account updated successfully.'));
|
||||
}
|
||||
@@ -199,6 +284,10 @@ class PermissionController extends Controller
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be deleted.'));
|
||||
}
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return redirect()->back()->with('error', __('You cannot delete your own account.'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user