[DOCS] 更新 RBAC 實作規範與角色初始化流程建議
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 55s

This commit is contained in:
2026-03-19 17:18:21 +08:00
parent 5548bb1cc9
commit f00fc940a9
13 changed files with 474 additions and 74 deletions

View File

@@ -61,6 +61,66 @@
animation: fadeUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* Additional Loading Animations */
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in-right {
animation: fadeInRight 0.8s ease-out forwards;
}
@keyframes trickle {
0% { width: 0%; opacity: 1; }
20% { width: 30%; }
50% { width: 70%; }
80% { width: 90%; }
95% { width: 95%; }
100% { width: 95%; }
}
.animate-trickle {
animation: trickle 10s cubic-bezier(0.1, 0.5, 0.5, 1) forwards;
}
/* Top Progress Bar (Trickle Style) */
.top-loading-bar {
@apply fixed top-0 left-0 right-0 h-0.5 z-[99999] bg-gradient-to-r from-cyan-500 to-blue-500 opacity-0 transition-opacity duration-300;
width: 0%;
}
.top-loading-bar.loading {
@apply opacity-100;
animation: trickle 12s cubic-bezier(0.1, 0.5, 0.5, 1) forwards;
}
@keyframes loadingPulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
/* Skeleton Loading Utilities (Option C) */
.skeleton {
@apply bg-slate-100 dark:bg-slate-800 animate-pulse-subtle rounded-lg overflow-hidden relative border-none !text-transparent selection:bg-transparent pointer-events-none;
}
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse-subtle {
animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Custom Scrollbar - Minimal & Elegant */
::-webkit-scrollbar {
width: 6px;

View File

@@ -322,9 +322,21 @@
placeholder="{{ __('Min 8 characters') }}">
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Admin Name') }}</label>
<input type="text" name="admin_name" class="luxury-input w-full" placeholder="{{ __('Admin display name') }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Admin Name') }}</label>
<input type="text" name="admin_name" class="luxury-input w-full" placeholder="{{ __('Admin display name') }}">
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Initial Role') }}</label>
<select name="admin_role" class="luxury-select w-full">
@foreach($template_roles as $role)
<option value="{{ $role->name }}" {{ $role->name == '通用客戶角色範本' ? 'selected' : '' }}>
{{ $role->name }}
</option>
@endforeach
</select>
</div>
</div>
</div>

View File

@@ -7,28 +7,53 @@
@section('content')
<div class="space-y-6" x-data="{
showModal: false,
editing: false,
showModal: {{ $errors->any() ? 'true' : 'false' }},
editing: {{ old('_method') === 'PUT' || (isset($user) && $errors->any()) ? 'true' : 'false' }},
allRoles: @js($roles),
currentUser: {
id: '',
name: '',
username: '',
email: '',
phone: '',
company_id: '',
role: 'user',
status: 1
id: '{{ old('id') }}',
name: '{{ old('name') }}',
username: '{{ old('username') }}',
email: '{{ old('email') }}',
phone: '{{ old('phone') }}',
company_id: '{{ old('company_id', auth()->user()->isSystemAdmin() ? '' : auth()->user()->company_id) }}',
role: '{{ old('role', '') }}',
status: {{ old('status', 1) }}
},
get filteredRoles() {
if (this.currentUser.company_id === '' || this.currentUser.company_id === null) {
// 系統層級:顯示 is_system = 1 的角色
return this.allRoles.filter(r => r.is_system);
} else {
// 客戶層級:只顯示該公司的角色
let roles = this.allRoles.filter(r => r.company_id == this.currentUser.company_id);
// 如果是系統管理員,額外允許選擇「客戶層級範本」
@if(auth()->user()->isSystemAdmin())
let templates = this.allRoles.filter(r => !r.is_system && (r.company_id === null || r.company_id === ''));
roles = [...roles, ...templates];
@endif
return roles;
}
},
openCreateModal() {
this.editing = false;
this.currentUser = { id: '', name: '', username: '', email: '', phone: '', company_id: '', role: 'user', status: 1 };
this.currentUser = { id: '', name: '', username: '', email: '', phone: '', company_id: '{{ auth()->user()->isSystemAdmin() ? "" : auth()->user()->company_id }}', role: '', status: 1 };
this.showModal = true;
// 預設選取第一個可用的角色
this.$nextTick(() => {
if (this.filteredRoles.length > 0) {
this.currentUser.role = this.filteredRoles[0].name;
}
});
},
openEditModal(user) {
this.editing = true;
this.currentUser = {
...user,
role: user.roles && user.roles.length > 0 ? user.roles[0].name : 'user'
company_id: user.company_id || '',
role: user.roles && user.roles.length > 0 ? user.roles[0].name : ''
};
this.showModal = true;
}
@@ -217,61 +242,97 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Full Name') }}</label>
<input type="text" name="name" x-model="currentUser.name" required class="luxury-input" placeholder="{{ __('e.g. John Doe') }}">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Full Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" x-model="currentUser.name" required class="luxury-input @error('name') border-rose-500 @enderror" placeholder="{{ __('e.g. John Doe') }}">
@error('name')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Username') }}</label>
<input type="text" name="username" x-model="currentUser.username" required class="luxury-input" placeholder="{{ __('e.g. johndoe') }}">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Username') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="username" x-model="currentUser.username" required class="luxury-input @error('username') border-rose-500 @enderror" placeholder="{{ __('e.g. johndoe') }}">
@error('username')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Email') }}</label>
<input type="email" name="email" x-model="currentUser.email" class="luxury-input" placeholder="{{ __('john@example.com') }}">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Email') }} <span class="text-rose-500">*</span>
</label>
<input type="email" name="email" x-model="currentUser.email" required class="luxury-input @error('email') border-rose-500 @enderror" placeholder="{{ __('john@example.com') }}">
@error('email')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Phone') }}</label>
<input type="text" name="phone" x-model="currentUser.phone" class="luxury-input">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Role') }}</label>
<select name="role" x-model="currentUser.role" class="luxury-select">
@foreach($roles as $role)
<option value="{{ $role->name }}">{{ __($role->name) }}</option>
@endforeach
</select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Status') }}</label>
<select name="status" x-model="currentUser.status" class="luxury-select">
<option value="1">{{ __('Active') }}</option>
<option value="0">{{ __('Disabled') }}</option>
</select>
<input type="text" name="phone" x-model="currentUser.phone" class="luxury-input @error('phone') border-rose-500 @enderror">
@error('phone')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
</div>
@if(auth()->user()->isSystemAdmin())
<div class="space-y-2">
<div class="space-y-2 mb-6">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Company') }}</label>
<select name="company_id" x-model="currentUser.company_id" class="luxury-select">
<select name="company_id" x-model="currentUser.company_id" class="luxury-select @error('company_id') border-rose-500 @enderror"
@change="$nextTick(() => { if (filteredRoles.length > 0 && !filteredRoles.find(r => r.name === currentUser.role)) { currentUser.role = filteredRoles[0].name; } })">
<option value="">{{ __('SYSTEM') }}</option>
@foreach($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</select>
@error('company_id')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Role') }} <span class="text-rose-500">*</span>
</label>
<select name="role" x-model="currentUser.role" class="luxury-select @error('role') border-rose-500 @enderror">
<template x-for="role in filteredRoles" :key="role.id">
<option :value="role.name" x-text="role.name"></option>
</template>
</select>
@error('role')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Status') }}</label>
<select name="status" x-model="currentUser.status" class="luxury-select @error('status') border-rose-500 @enderror">
<option value="1">{{ __('Active') }}</option>
<option value="0">{{ __('Disabled') }}</option>
</select>
@error('status')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
<span x-text="editing ? '{{ __('New Password (leave blank to keep current)') }}' : '{{ __('Password') }}'"></span>
<template x-if="!editing">
<span class="text-rose-500">*</span>
</template>
</label>
<input type="password" name="password" :required="!editing" class="luxury-input" placeholder="••••••••">
<input type="password" name="password" :required="!editing" class="luxury-input @error('password') border-rose-500 @enderror" placeholder="••••••••">
@error('password')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</div>
<div class="flex justify-end gap-x-4 pt-8">

View File

@@ -7,13 +7,13 @@
@section('content')
<div class="space-y-6" x-data="{
showModal: false,
isEdit: false,
roleId: '',
roleName: '',
rolePermissions: [],
isSystem: false,
modalTitle: '{{ __('Create Role') }}',
showModal: {{ $errors->any() ? 'true' : 'false' }},
isEdit: {{ (old('_method') == 'PUT' || request()->has('edit')) ? 'true' : 'false' }},
roleId: '{{ old('roleId', '') }}',
roleName: '{{ old('name', '') }}',
rolePermissions: @js(old('permissions', [])),
isSystem: {{ old('is_system', '0') }},
modalTitle: '{{ $errors->any() && old('_method') == 'PUT' ? __('Edit Role') : ($errors->any() ? __('Create Role') : __('Create Role')) }}',
openModal(edit = false, id = '', name = '', permissions = [], isSys = false) {
this.isEdit = edit;
this.roleId = id;
@@ -160,6 +160,7 @@
<form :action="isEdit ? '{{ route($baseRoute) }}/' + roleId : '{{ route($baseRoute . '.store') }}'" method="POST">
@csrf
<template x-if="isEdit"><input type="hidden" name="_method" value="PUT"></template>
<input type="hidden" name="roleId" x-model="roleId">
<div class="p-8 max-h-[65vh] overflow-y-auto custom-scrollbar">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
@@ -167,7 +168,13 @@
<div class="space-y-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-400 uppercase tracking-widest pl-1">{{ __('Role Name') }}</label>
<input type="text" name="name" x-model="roleName" required class="luxury-input w-full" placeholder="{{ __('Enter role name') }}" :disabled="isEdit && roleName === 'super-admin'">
<input type="text" name="name" x-model="roleName" required class="luxury-input w-full @error('name') border-rose-500 @enderror" placeholder="{{ __('Enter role name') }}" :disabled="isEdit && roleName === 'super-admin'">
@error('name')
<p class="text-[11px] text-rose-500 font-bold mt-1.5 px-1 flex items-center gap-1.5 animate-luxury-in">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
{{ $message }}
</p>
@enderror
<template x-if="isEdit && roleName === 'super-admin'">
<p class="text-[10px] text-amber-500 font-bold mt-1 px-1 flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>

View File

@@ -0,0 +1,51 @@
<div x-data="{
loading: true
}"
x-init="
const urlParams = new URLSearchParams(window.location.search);
const simulateDelay = urlParams.get('simulate_loading');
const delay = simulateDelay ? parseInt(simulateDelay) : 300;
const hideLoading = () => setTimeout(() => loading = false, delay);
if (document.readyState === 'complete') {
hideLoading();
} else {
window.addEventListener('load', hideLoading);
// 安全保險:模擬模式下為 30 秒,正常模式下為 5 秒
const safetyTimeout = simulateDelay ? 30000 : 5000;
setTimeout(hideLoading, safetyTimeout);
}
"
x-show="loading"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-md"
style="display: none;"
x-cloak>
<div class="relative flex flex-col items-center animate-luxury-in">
<!-- Logo with Spinner Animation -->
<div class="relative w-28 h-28 mb-10 flex items-center justify-center">
<!-- Luxury Rotating Ring -->
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin" style="animation-duration: 1.5s;"></div>
<div class="absolute inset-2 rounded-full border border-white/5 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
<!-- Glow Effect -->
<div class="absolute inset-0 rounded-full bg-cyan-500/10 blur-xl animate-pulse"></div>
<!-- Central Logo -->
<div class="relative w-20 h-20 rounded-3xl bg-slate-900/80 backdrop-blur-xl border border-white/20 flex items-center justify-center shadow-2xl">
<span class="text-white text-5xl font-black font-display tracking-tighter">S</span>
</div>
</div>
<!-- Text Animation -->
<div class="flex items-center gap-x-3 text-3xl font-bold text-white font-display tracking-tightest overflow-hidden mb-4">
<span class="animate-fade-in-right opacity-0" style="animation-delay: 100ms; animation-fill-mode: forwards;">Star</span>
<span class="text-cyan-400 animate-fade-in-right opacity-0" style="animation-delay: 300ms; animation-fill-mode: forwards;">Cloud</span>
</div>
<p class="text-[10px] font-black text-white/30 uppercase tracking-[0.6em] animate-pulse">{{ __('Systems Initializing') }}</p>
</div>
</div>

View File

@@ -151,6 +151,26 @@
</div>
<!-- End Card -->
</div>
<!-- Skeleton Example (Option C) -->
<div class="luxury-card p-8 animate-fade-up">
<div class="flex items-center justify-between mb-10">
<div>
<div class="h-4 w-32 skeleton mb-2"></div>
<div class="h-7 w-48 skeleton"></div>
</div>
<div class="h-10 w-24 skeleton"></div>
</div>
<div class="space-y-4">
<div class="h-4 w-full skeleton"></div>
<div class="h-4 w-11/12 skeleton"></div>
<div class="h-4 w-4/5 skeleton"></div>
</div>
<div class="mt-8 pt-6 border-t border-slate-100 dark:border-slate-800 flex gap-x-3">
<div class="h-10 w-28 skeleton"></div>
<div class="h-10 w-28 skeleton"></div>
</div>
</div>
</div>
@endsection

View File

@@ -25,6 +25,22 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-gray-50 dark:bg-[#0f172a] antialiased font-sans h-full selection:bg-indigo-100 dark:selection:bg-indigo-900/40" x-data="{ sidebarOpen: false, userDropdownOpen: false }">
<!-- Option A: Loading Screen -->
<x-loading-screen />
<!-- Option B: Top Progress Bar -->
<div id="top-loading-bar" class="top-loading-bar"></div>
<script>
// 僅保留最基本的導航列觸發,不使用全螢幕遮罩防止卡死
window.addEventListener('beforeunload', () => {
document.getElementById('top-loading-bar').classList.add('loading');
});
window.addEventListener('pageshow', () => {
document.getElementById('top-loading-bar').classList.remove('loading');
});
</script>
<!-- Sidebar Overlay (Mobile) -->
<div x-show="sidebarOpen"