[PROMOTE] 晉升 dev 至 demo 環境 (登入 UI 重構與帳號切換功能)
1. 實作系統管理員切換帳號 (Impersonate) 懸浮視窗與後端模擬登入功能。 2. 重新設計登入頁面,採用方案 B 白金極簡懸浮卡片,調大 Star Cloud 品牌 Logo,並提升智慧販賣機 IoT 浮水印至 20% 透明度。 3. 全面更新全站 Favicon 與側邊欄品牌標誌,最佳化高奢載入畫面中央 Logo 為珍珠白底。 4. 將個人檔案圖片上傳前端限制由 1MB 提升至 5MB,並確認頭像、商品管理、廣告管理圖片皆已轉換為高品質 WebP 格式。
@ -404,7 +404,82 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
| 刪除確認 Modal | `<x-delete-confirm-modal />` |
|
||||
| 頁面內警告(琥珀色)| `<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 font-bold">` |
|
||||
|
||||
---
|
||||
### 7.1 Modal 實作規範(強制)
|
||||
|
||||
> [!CAUTION]
|
||||
> **嚴禁使用 Preline 的 `hs-overlay` 系統**來實作 Modal。
|
||||
> 本專案全部 Modal 統一使用 **Alpine.js** (`x-show` + `x-transition`) 實作。
|
||||
> 使用 `hs-overlay` class 會導致 Modal 出現但完全透明(`opacity: 0`),因為 Preline overlay 的動畫 class 在此專案中未被正確初始化。
|
||||
|
||||
**正確:Alpine.js Modal 標準結構(參考 `modal-replenishment-create.blade.php`)**
|
||||
|
||||
```html
|
||||
{{-- 1. 觸發按鈕:用 $dispatch 發送 window 事件 --}}
|
||||
<button type="button" @click="$dispatch('open-xxx-modal')">開啟</button>
|
||||
|
||||
{{-- 2. Modal 主體:放在 layout 底部,x-data 宣告 Alpine state --}}
|
||||
<div x-data="{ showModal: false }" @open-xxx-modal.window="showModal = true" x-cloak>
|
||||
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 ...">
|
||||
|
||||
{{-- Backdrop --}}
|
||||
<div x-show="showModal"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
@click="showModal = false"></div>
|
||||
|
||||
{{-- Modal 內容 --}}
|
||||
<div x-show="showModal"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
|
||||
class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900
|
||||
rounded-[2.5rem] text-left shadow-2xl transform
|
||||
sm:my-8 sm:align-middle sm:max-w-lg w-full
|
||||
border border-slate-100 dark:border-slate-800 z-10"
|
||||
@click.stop>
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="px-10 py-8 pb-4 flex items-center justify-between">
|
||||
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">
|
||||
標題
|
||||
</h3>
|
||||
<button @click="showModal = false"
|
||||
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
|
||||
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Body / Footer --}}
|
||||
<div class="px-10 py-6">...</div>
|
||||
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex justify-end gap-4">
|
||||
<button @click="showModal = false" class="btn-luxury-ghost px-8">取消</button>
|
||||
<button type="submit" class="btn-luxury-primary px-12">確認</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**跨元件開啟 Modal(dropdown 內的按鈕):**
|
||||
- 按鈕在 Alpine.js dropdown 內時,需先關閉 dropdown 再發送事件:
|
||||
```html
|
||||
<button @click="open = false; $dispatch('open-xxx-modal')">切換帳號</button>
|
||||
```
|
||||
- Modal 透過 `@open-xxx-modal.window` 接收(`window` 層級才能跨 Alpine scope 傳遞)
|
||||
|
||||
|
||||
|
||||
## 8. AJAX fetchPage 標準實作
|
||||
|
||||
|
||||
98
app/Http/Controllers/Admin/ImpersonateController.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ImpersonateController extends Controller
|
||||
{
|
||||
/**
|
||||
* Start impersonating a user.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'user_id' => 'required|exists:users,id',
|
||||
]);
|
||||
|
||||
$currentUser = $request->user();
|
||||
|
||||
// 1. Only system admins can impersonate
|
||||
if (!$currentUser->isSystemAdmin()) {
|
||||
abort(403, '只有系統管理員可以使用此功能。');
|
||||
}
|
||||
|
||||
// 2. Prevent nested impersonation (cannot impersonate someone while already impersonating)
|
||||
if (session()->has('impersonated_by')) {
|
||||
return back()->with('error', '您目前已經處於切換狀態,無法再次切換。請先退出目前的切換狀態。');
|
||||
}
|
||||
|
||||
$targetUserId = $request->input('user_id');
|
||||
|
||||
// 3. Cannot impersonate oneself
|
||||
if ($currentUser->id == $targetUserId) {
|
||||
return back()->with('error', '無法切換至自己的帳號。');
|
||||
}
|
||||
|
||||
$targetUser = User::findOrFail($targetUserId);
|
||||
|
||||
// 4. Set session and login
|
||||
session()->put('impersonated_by', $currentUser->id);
|
||||
Auth::loginUsingId($targetUser->id);
|
||||
|
||||
return redirect()->route('admin.dashboard')->with('success', "已成功切換至 {$targetUser->name} 的帳號。");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop impersonating and return to the original user.
|
||||
*/
|
||||
public function leave(Request $request)
|
||||
{
|
||||
if (!session()->has('impersonated_by')) {
|
||||
return back()->with('error', '您目前並未處於切換狀態。');
|
||||
}
|
||||
|
||||
$originalUserId = session()->pull('impersonated_by');
|
||||
Auth::loginUsingId($originalUserId);
|
||||
|
||||
return redirect()->route('admin.dashboard')->with('success', '已退出切換狀態,返回原帳號。');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch companies for the impersonate modal.
|
||||
*/
|
||||
public function companies(Request $request)
|
||||
{
|
||||
if (!$request->user()->isSystemAdmin()) abort(403);
|
||||
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->orderBy('name')->get();
|
||||
return response()->json($companies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch users for the impersonate modal, optionally filtered by company.
|
||||
*/
|
||||
public function users(Request $request)
|
||||
{
|
||||
if (!$request->user()->isSystemAdmin()) abort(403);
|
||||
|
||||
$query = User::select('id', 'name', 'username', 'email', 'company_id');
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$query->where('company_id', $request->company_id);
|
||||
} else {
|
||||
// Include system accounts (company_id is null) when no company is selected?
|
||||
// Actually let's just return all users if no company selected, or just system accounts.
|
||||
// But if company_id is explicitly sent as 'system', we filter for null.
|
||||
if ($request->company_id === 'system') {
|
||||
$query->whereNull('company_id');
|
||||
}
|
||||
}
|
||||
|
||||
$users = $query->orderBy('name')->get();
|
||||
return response()->json($users);
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,11 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use App\Traits\ImageHandler;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
use ImageHandler;
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
@ -48,7 +50,7 @@ class ProfileController extends Controller
|
||||
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:1024'],
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
@ -59,7 +61,8 @@ class ProfileController extends Controller
|
||||
Storage::disk('public')->delete($user->avatar);
|
||||
}
|
||||
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
// 將上傳的大頭貼轉換為高壓縮率且清晰的 WebP 格式 (300x300 居中裁切)
|
||||
$path = $this->storeAsWebp($request->file('avatar'), 'avatars', 85, 300, 300);
|
||||
$user->avatar = $path;
|
||||
$user->save();
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
|
||||
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1616,6 +1616,7 @@
|
||||
"The Super Admin role is immutable.": "The Super Admin role is immutable.",
|
||||
"The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
|
||||
"This is a system administrator role. Its name is locked to ensure system stability.": "This is a system administrator role. Its name is locked to ensure system stability.",
|
||||
"This machine has a pending command. Please wait.": "This machine has a pending command. Please wait.",
|
||||
"This role belongs to another company and cannot be assigned.": "This role belongs to another company and cannot be assigned.",
|
||||
|
||||
@ -1616,6 +1616,7 @@
|
||||
"The Super Admin role is immutable.": "特権管理者ロールは変更できません。",
|
||||
"The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
|
||||
"This is a system administrator role. Its name is locked to ensure system stability.": "これはシステム管理者ロールです。システムの安定性を確保するため、名前はロックされています。",
|
||||
"This machine has a pending command. Please wait.": "この機器には実行中のコマンドがあります。しばらくお待ちください。",
|
||||
"This role belongs to another company and cannot be assigned.": "このロールは別の会社に属しているため、割り当てることができません。",
|
||||
|
||||
@ -1638,6 +1638,7 @@
|
||||
"The Super Admin role is immutable.": "超級管理員角色不可修改。",
|
||||
"The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
|
||||
"This is a system administrator role. Its name is locked to ensure system stability.": "這是系統管理員角色,名稱已鎖定以確保系統穩定性。",
|
||||
"This machine has a pending command. Please wait.": "此機台已有指令正在執行,請稍後。",
|
||||
"This role belongs to another company and cannot be assigned.": "此角色屬於其他公司,無法指派。",
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 96 KiB |
BIN
public/starcloud_icon.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
public/starcloudlogo-Photoroom.png
Normal file
|
After Width: | Height: | Size: 826 KiB |
BIN
public/starcloudlogo.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/tech_lines_bg.png
Normal file
|
After Width: | Height: | Size: 691 KiB |
BIN
public/tech_vending_bg.png
Normal file
|
After Width: | Height: | Size: 504 KiB |
273
resources/views/admin/impersonate/modal.blade.php
Normal file
@ -0,0 +1,273 @@
|
||||
{{-- 切換帳號 Modal --}}
|
||||
<div x-data="impersonateModal()" @open-impersonate-modal.window="openModal()" x-cloak>
|
||||
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
{{-- Backdrop --}}
|
||||
<div x-show="showModal" x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
||||
@click="showModal = false"></div>
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">​</span>
|
||||
{{-- Modal Content --}}
|
||||
<div x-show="showModal" x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
|
||||
class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] text-left shadow-2xl transform sm:my-8 sm:align-middle sm:max-w-lg w-full border border-slate-100 dark:border-slate-800 z-10"
|
||||
@click.stop>
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="px-10 py-8 pb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-3">
|
||||
{{ __('切換帳號') }}
|
||||
</h3>
|
||||
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
{{ __('以其他帳號身份操作系統') }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="showModal = false"
|
||||
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700 shadow-sm">
|
||||
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Body --}}
|
||||
<form action="{{ route('admin.impersonate.store') }}" method="POST" class="flex-1 overflow-y-auto px-10 py-2">
|
||||
@csrf
|
||||
<div class="space-y-6 pb-6">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ __('請選擇您要切換的目標公司與帳號。切換後,您將擁有該帳號的所有操作權限。') }}
|
||||
</p>
|
||||
|
||||
{{-- Company Select --}}
|
||||
<div class="space-y-3 relative group focus-within:z-[60]">
|
||||
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">
|
||||
{{ __('公司') }}
|
||||
</label>
|
||||
<div id="impersonate-company-select-wrapper" class="relative">
|
||||
{{-- 透過 Alpine.js 動態重建 HSSelect --}}
|
||||
<div class="luxury-input w-full px-6 py-4 bg-slate-50/50 dark:bg-slate-900/50 text-slate-400 text-sm flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-cyan-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ __('載入中...') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- User Select --}}
|
||||
<div class="space-y-3 relative group focus-within:z-[60]">
|
||||
<label class="block text-xs font-black text-slate-500 uppercase tracking-[0.15em] pl-1">
|
||||
{{ __('使用者帳號') }}
|
||||
</label>
|
||||
<div id="impersonate-user-select-wrapper" class="relative">
|
||||
{{-- 透過 Alpine.js 動態重建 HSSelect --}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="py-6 border-t border-slate-100 dark:border-slate-800/50 flex items-center justify-end gap-4">
|
||||
<button type="button" @click="showModal = false" class="btn-luxury-ghost px-8">
|
||||
{{ __('取消') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="!selectedUser" class="btn-luxury-primary px-12 disabled:opacity-50">
|
||||
{{ __('確認切換') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function impersonateModal() {
|
||||
return {
|
||||
showModal: false,
|
||||
companies: [],
|
||||
users: [],
|
||||
selectedCompany: '',
|
||||
selectedUser: '',
|
||||
isLoadingUsers: false,
|
||||
initialized: false,
|
||||
|
||||
openModal() {
|
||||
this.showModal = true;
|
||||
if (!this.initialized) {
|
||||
this.fetchCompanies();
|
||||
this.initialized = true;
|
||||
} else {
|
||||
// Make sure Preline JS re-initializes or attaches correctly when modal becomes visible
|
||||
this.$nextTick(() => {
|
||||
this.updateCompanySelect();
|
||||
this.updateUserSelect();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchCompanies() {
|
||||
fetch("{{ route('admin.impersonate.companies') }}")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.companies = data;
|
||||
this.$nextTick(() => {
|
||||
this.updateCompanySelect();
|
||||
this.updateUserSelect();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
fetchUsers() {
|
||||
if (this.selectedCompany === '') return;
|
||||
this.isLoadingUsers = true;
|
||||
this.users = [];
|
||||
this.selectedUser = '';
|
||||
this.$nextTick(() => { this.updateUserSelect(); });
|
||||
|
||||
let url = "{{ route('admin.impersonate.users') }}" + '?company_id=' + this.selectedCompany;
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
this.users = data;
|
||||
this.isLoadingUsers = false;
|
||||
this.$nextTick(() => { this.updateUserSelect(); });
|
||||
});
|
||||
},
|
||||
|
||||
updateCompanySelect() {
|
||||
const wrapper = document.getElementById('impersonate-company-select-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
const oldSelect = wrapper.querySelector('select');
|
||||
if (oldSelect && window.HSSelect?.getInstance(oldSelect)) {
|
||||
try { window.HSSelect.getInstance(oldSelect).destroy(); } catch (e) { }
|
||||
}
|
||||
wrapper.innerHTML = '';
|
||||
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.id = `impersonate-company-${Date.now()}`;
|
||||
selectEl.className = 'hidden';
|
||||
|
||||
const placeholderOpt = document.createElement('option');
|
||||
placeholderOpt.value = '';
|
||||
placeholderOpt.textContent = '{{ __("請選擇公司") }}';
|
||||
selectEl.appendChild(placeholderOpt);
|
||||
|
||||
const sysOpt = document.createElement('option');
|
||||
sysOpt.value = 'system';
|
||||
sysOpt.textContent = '{{ __("系統帳號 (無公司)") }}';
|
||||
if (this.selectedCompany === 'system') sysOpt.selected = true;
|
||||
selectEl.appendChild(sysOpt);
|
||||
|
||||
this.companies.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = c.name;
|
||||
if (this.selectedCompany == c.id) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
|
||||
selectEl.setAttribute('data-hs-select', JSON.stringify({
|
||||
"placeholder": "{{ __('請選擇公司') }}",
|
||||
"hasSearch": true,
|
||||
"searchPlaceholder": "{{ __('Search...') }}",
|
||||
"isHidePlaceholder": false,
|
||||
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
||||
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
|
||||
"toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left",
|
||||
"toggleTemplate": "<button type=\"button\" aria-expanded=\"false\"><span class=\"me-2\" data-icon></span><span class=\"text-slate-800 dark:text-slate-200 truncate\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400 transition-transform duration-300\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\" /></svg></div></button>",
|
||||
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in max-h-72 overflow-y-auto custom-scrollbar-thin",
|
||||
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
|
||||
"optionTemplate": "<div class=\"flex items-center justify-between w-full\"><span data-title></span><span class=\"hs-select-active-indicator hidden text-cyan-500\"><svg class=\"w-4 h-4\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg></span></div>"
|
||||
}));
|
||||
|
||||
wrapper.appendChild(selectEl);
|
||||
|
||||
selectEl.addEventListener('change', (e) => {
|
||||
this.selectedCompany = e.target.value;
|
||||
this.fetchUsers();
|
||||
});
|
||||
|
||||
if (window.HSStaticMethods) {
|
||||
window.HSStaticMethods.autoInit();
|
||||
}
|
||||
},
|
||||
|
||||
updateUserSelect() {
|
||||
const wrapper = document.getElementById('impersonate-user-select-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
const oldSelect = wrapper.querySelector('select');
|
||||
if (oldSelect && window.HSSelect?.getInstance(oldSelect)) {
|
||||
try { window.HSSelect.getInstance(oldSelect).destroy(); } catch (e) { }
|
||||
}
|
||||
wrapper.innerHTML = '';
|
||||
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.id = `impersonate-user-${Date.now()}`;
|
||||
selectEl.name = 'user_id';
|
||||
selectEl.required = true;
|
||||
selectEl.className = 'hidden';
|
||||
|
||||
const placeholderOpt = document.createElement('option');
|
||||
placeholderOpt.value = '';
|
||||
|
||||
if (this.isLoadingUsers) {
|
||||
placeholderOpt.textContent = '{{ __("載入中...") }}';
|
||||
selectEl.disabled = true;
|
||||
} else if (!this.selectedCompany) {
|
||||
placeholderOpt.textContent = '{{ __("請先選擇公司") }}';
|
||||
selectEl.disabled = true;
|
||||
} else if (this.users.length === 0) {
|
||||
placeholderOpt.textContent = '{{ __("此公司目前沒有帳號") }}';
|
||||
selectEl.disabled = true;
|
||||
} else {
|
||||
placeholderOpt.textContent = '{{ __("請選擇帳號") }}';
|
||||
}
|
||||
selectEl.appendChild(placeholderOpt);
|
||||
|
||||
this.users.forEach(u => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = u.id;
|
||||
const identifier = u.username || u.email || '';
|
||||
opt.textContent = identifier ? `${u.name} (${identifier})` : u.name;
|
||||
if (this.selectedUser == u.id) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
|
||||
selectEl.setAttribute('data-hs-select', JSON.stringify({
|
||||
"placeholder": selectEl.disabled ? placeholderOpt.textContent : "{{ __('請選擇帳號') }}",
|
||||
"hasSearch": true,
|
||||
"searchPlaceholder": "{{ __('Search...') }}",
|
||||
"isHidePlaceholder": false,
|
||||
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
||||
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
|
||||
"toggleClasses": "hs-select-toggle luxury-select-toggle w-full text-left " + (selectEl.disabled ? "opacity-50 cursor-not-allowed" : ""),
|
||||
"toggleTemplate": "<button type=\"button\" aria-expanded=\"false\"><span class=\"me-2\" data-icon></span><span class=\"text-slate-800 dark:text-slate-200 truncate\" data-title></span><div class=\"ms-auto\"><svg class=\"size-4 text-slate-400 transition-transform duration-300\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 9 6 6 6-6\" /></svg></div></button>",
|
||||
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in max-h-72 overflow-y-auto custom-scrollbar-thin",
|
||||
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
|
||||
"optionTemplate": "<div class=\"flex items-center justify-between w-full\"><span class=\"truncate\" data-title></span><span class=\"hs-select-active-indicator hidden text-cyan-500 shrink-0\"><svg class=\"w-4 h-4\" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg></span></div>"
|
||||
}));
|
||||
|
||||
wrapper.appendChild(selectEl);
|
||||
|
||||
selectEl.addEventListener('change', (e) => {
|
||||
this.selectedUser = e.target.value;
|
||||
});
|
||||
|
||||
if (window.HSStaticMethods) {
|
||||
window.HSStaticMethods.autoInit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -5,94 +5,138 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
<link rel="shortcut icon" href="{{ asset('favicon.ico') }}">
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
<script>
|
||||
// Dark mode
|
||||
const html = document.querySelector('html');
|
||||
const isLightOrAuto = localStorage.getItem('hs_theme') === 'light' || (localStorage.getItem('hs_theme') !== 'dark' && !window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDarkOrAuto = localStorage.getItem('hs_theme') === 'dark' || (localStorage.getItem('hs_theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (isLightOrAuto && html.classList.contains('dark')) html.classList.remove('dark');
|
||||
else if (isDarkOrAuto && html.classList.contains('light')) html.classList.remove('light');
|
||||
else if (isDarkOrAuto && !html.classList.contains('dark')) html.classList.add('dark');
|
||||
else if (isLightOrAuto && !html.classList.contains('light')) html.classList.add('light');
|
||||
</script>
|
||||
<style>
|
||||
/* 懸浮卡片高奢進入動畫 */
|
||||
@keyframes luxury-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-luxury-in {
|
||||
animation: luxury-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
</style>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-white dark:bg-slate-900">
|
||||
<div class="flex h-screen">
|
||||
<!-- Sidebar - Image Section -->
|
||||
<div class="hidden md:flex md:w-1/2 lg:w-3/5 bg-gray-50 border-r border-gray-200 dark:bg-slate-800 dark:border-slate-700 justify-center items-center relative overflow-hidden">
|
||||
<!-- Background Pattern or Gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-tr from-blue-600 to-purple-500 opacity-90 dark:from-blue-900 dark:to-purple-900"></div>
|
||||
<body class="font-sans antialiased bg-[#070B14]">
|
||||
|
||||
<!-- 全局科技星雲底圖 Canvas -->
|
||||
<div class="min-h-screen w-full flex items-center justify-center p-4 sm:p-6 md:p-10 relative overflow-hidden bg-[#070B14]">
|
||||
|
||||
<!-- 精美淡淡的科技不規則線條背景圖 (低透明度,優雅不花俏) -->
|
||||
<div class="absolute inset-0 bg-cover bg-center opacity-[0.18] pointer-events-none mix-blend-lighten" style="background-image: url('{{ asset('tech_lines_bg.png') }}');"></div>
|
||||
|
||||
<!-- 霓虹光暈 (Radial Glow) -->
|
||||
<div class="absolute top-[-10%] right-[-10%] w-[500px] h-[500px] rounded-full bg-indigo-600/10 blur-[120px] pointer-events-none"></div>
|
||||
<div class="absolute bottom-[-10%] left-[-10%] w-[600px] h-[600px] rounded-full bg-blue-600/10 blur-[130px] pointer-events-none"></div>
|
||||
<div class="absolute top-[30%] left-[20%] w-[350px] h-[350px] rounded-full bg-cyan-600/5 blur-[100px] pointer-events-none"></div>
|
||||
|
||||
<!-- 科技感精密網格線 (Grid Overlay) -->
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,#1e293b_1px,transparent_1px),linear-gradient(to_bottom,#1e293b_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_70%,transparent_100%)] opacity-[0.08] pointer-events-none"></div>
|
||||
|
||||
<!-- 懸浮珍珠白高奢卡片 (Unified Card - Option B: Solid Pristine White) -->
|
||||
<div class="w-full max-w-4xl bg-white rounded-[2.5rem] shadow-[0_30px_75px_-15px_rgba(0,0,0,0.65)] flex flex-col md:flex-row overflow-hidden relative z-10 animate-luxury-in">
|
||||
|
||||
<div class="relative z-10 text-center px-6">
|
||||
<div class="mb-6 flex justify-center">
|
||||
<img src="{{ asset('S1_cropped_transparent.png') }}" alt="{{ config('app.name') }} Logo" class="w-32 h-32 object-contain drop-shadow-xl">
|
||||
<!-- 左側品牌區 (Branding Area - Solid Premium Slate) -->
|
||||
<div class="w-full md:w-1/2 bg-[#F8FAFC] border-b md:border-b-0 md:border-r border-slate-100 flex flex-col justify-center items-center p-8 lg:p-12 relative overflow-hidden shrink-0 select-none min-h-[320px] md:min-h-0">
|
||||
|
||||
<!-- 精美淡淡的智慧販賣機背景圖 (浮水印效果 - 提升透明度) -->
|
||||
<div class="absolute inset-0 bg-cover bg-center opacity-[0.20] pointer-events-none mix-blend-multiply" style="background-image: url('{{ asset('tech_vending_bg.png') }}');"></div>
|
||||
|
||||
|
||||
|
||||
<div class="relative z-10 flex flex-col items-center justify-center space-y-12 py-6 md:py-12">
|
||||
<!-- Logo -->
|
||||
<img src="{{ asset('starcloudlogo-Photoroom.png') }}" alt="{{ config('app.name') }} Logo" class="w-64 sm:w-72 md:w-80 h-auto object-contain drop-shadow-sm">
|
||||
|
||||
<!-- 平台名稱 -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl sm:text-2xl font-black text-slate-800 tracking-tight font-display">
|
||||
AIoT 自動化設備智慧營運平台
|
||||
</h2>
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-2 font-mono">
|
||||
Smart Device Management Hub
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-white sm:text-4xl">
|
||||
{{ config('app.name', 'Star Cloud') }}
|
||||
</h2>
|
||||
<p class="mt-3 text-lg text-blue-100">
|
||||
智能販賣機管理平台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content - Login Form -->
|
||||
<div class="w-full md:w-1/2 lg:w-2/5 flex flex-col justify-center px-4 sm:px-6 md:px-8 lg:px-10 py-8">
|
||||
<div class="max-w-md w-full mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<div class="md:hidden mb-6 flex justify-center">
|
||||
<img src="{{ asset('S1_cropped_transparent.png') }}" alt="{{ config('app.name') }} Logo" class="w-20 h-20 object-contain drop-shadow-md">
|
||||
<!-- 右側登入表單區 (Form Area - Solid Pure White) -->
|
||||
<div class="flex-1 bg-white flex flex-col justify-center p-8 sm:p-10 lg:p-12 relative overflow-hidden">
|
||||
|
||||
|
||||
|
||||
<div class="max-w-sm w-full mx-auto relative z-10">
|
||||
|
||||
<!-- 歡迎文案 -->
|
||||
<div class="text-left mb-8">
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-slate-800 tracking-tight font-display">
|
||||
歡迎回來
|
||||
</h1>
|
||||
<p class="mt-2 text-sm font-medium text-slate-500">
|
||||
請輸入您的帳號與密碼,進入管理後台
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="block text-2xl font-bold text-gray-800 dark:text-white">登入</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
請輸入您的帳號密碼以繼續
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Session Status -->
|
||||
@if (session('status'))
|
||||
<div class="mb-4 text-sm font-medium text-green-600">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
<!-- Session 訊息狀態 -->
|
||||
@if (session('status'))
|
||||
<div class="mb-5 p-4 bg-emerald-50 border border-emerald-100 text-emerald-700 rounded-xl text-sm font-bold animate-luxury-in">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@csrf
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-6">
|
||||
@csrf
|
||||
|
||||
<div class="grid gap-y-4">
|
||||
<!-- Form Group -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm mb-2 dark:text-white">帳號</label>
|
||||
<!-- 帳號輸入框 -->
|
||||
<div class="space-y-2">
|
||||
<label for="username" class="block text-xs font-bold text-slate-500 uppercase tracking-widest">
|
||||
帳號
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="username" name="username" value="{{ old('username') }}" class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-slate-700 dark:text-gray-400 dark:focus:ring-gray-600" required autofocus autocomplete="username">
|
||||
<div class="hidden absolute inset-y-0 end-0 flex items-center pointer-events-none pe-3">
|
||||
<svg class="h-5 w-5 text-red-500" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8 4a.905.905 0 0 0-.9.995l.35 3.507a.552.552 0 0 0 1.1 0l.35-3.507A.905.905 0 0 0 8 4zm.002 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/>
|
||||
<span class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none text-slate-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
</span>
|
||||
<input type="text" id="username" name="username" value="{{ old('username') }}"
|
||||
class="py-3 ps-11 pe-4 block w-full border border-slate-200 rounded-xl text-sm font-medium text-slate-800 placeholder-slate-400 bg-slate-50/50 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 focus:outline-none transition-all"
|
||||
placeholder="請輸入您的帳號" required autofocus autocomplete="username">
|
||||
</div>
|
||||
@if ($errors->get('username'))
|
||||
<p class="text-xs text-red-600 mt-2" id="username-error">{{ $errors->first('username') }}</p>
|
||||
<p class="text-xs text-rose-500 font-bold mt-1.5" id="username-error">
|
||||
{{ $errors->first('username') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<!-- End Form Group -->
|
||||
|
||||
<!-- Form Group -->
|
||||
<div x-data="{ showPassword: false }">
|
||||
<!-- 密碼輸入框 -->
|
||||
<div class="space-y-2" x-data="{ showPassword: false }">
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="password" class="block text-sm mb-2 dark:text-white font-bold">密碼</label>
|
||||
<label for="password" class="block text-xs font-bold text-slate-500 uppercase tracking-widest">
|
||||
密碼
|
||||
</label>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none text-slate-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input :type="showPassword ? 'text' : 'password'" id="password" name="password"
|
||||
class="py-3 px-4 block w-full border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 dark:text-gray-400 dark:focus:ring-gray-600 transition-all pr-12"
|
||||
required autocomplete="current-password">
|
||||
class="py-3 ps-11 pe-12 block w-full border border-slate-200 rounded-xl text-sm font-medium text-slate-800 placeholder-slate-400 bg-slate-50/50 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 focus:outline-none transition-all"
|
||||
placeholder="請輸入您的密碼" required autocomplete="current-password">
|
||||
<button type="button" @click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 end-0 flex items-center z-20 px-4 cursor-pointer text-gray-400 hover:text-blue-600 transition-colors">
|
||||
class="absolute inset-y-0 end-0 flex items-center z-20 px-4 cursor-pointer text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<svg x-show="!showPassword" class="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
@ -103,29 +147,32 @@
|
||||
</button>
|
||||
</div>
|
||||
@if ($errors->get('password'))
|
||||
<p class="text-xs text-red-600 mt-2" id="password-error">{{ $errors->first('password') }}</p>
|
||||
<p class="text-xs text-rose-500 font-bold mt-1.5" id="password-error">
|
||||
{{ $errors->first('password') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<!-- End Form Group -->
|
||||
|
||||
<!-- Checkbox -->
|
||||
<!-- 記住我 -->
|
||||
<div class="flex items-center">
|
||||
<div class="flex">
|
||||
<input id="remember-me" name="remember" type="checkbox" class="shrink-0 mt-0.5 border-gray-200 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800">
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<label for="remember-me" class="text-sm dark:text-white">記住我</label>
|
||||
</div>
|
||||
<input id="remember-me" name="remember" type="checkbox"
|
||||
class="shrink-0 mt-0.5 border-slate-200 rounded text-blue-600 focus:ring-blue-500/20 cursor-pointer">
|
||||
<label for="remember-me" class="ms-3 text-sm font-semibold text-slate-600 cursor-pointer">
|
||||
記住我
|
||||
</label>
|
||||
</div>
|
||||
<!-- End Checkbox -->
|
||||
|
||||
<button type="submit" class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">
|
||||
<!-- 提交按鈕 -->
|
||||
<button type="submit"
|
||||
class="w-full py-3.5 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-extrabold rounded-xl border border-transparent bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white shadow-lg shadow-blue-500/25 active:scale-[0.98] focus:outline-none transition-all cursor-pointer">
|
||||
登入
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
<img src="{{ asset('S1_cropped_transparent.png') }}" alt="{{ config('app.name') }} Logo" {{ $attributes }}>
|
||||
<img src="{{ asset('starcloud_icon.png') }}" alt="{{ config('app.name') }} Logo" {{ $attributes }}>
|
||||
|
||||
@ -37,8 +37,8 @@
|
||||
<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-full bg-slate-900/80 backdrop-blur-xl border border-white/20 flex items-center justify-center shadow-2xl overflow-hidden p-1">
|
||||
<img src="{{ asset('S1_cropped_transparent.png') }}" alt="Logo" class="w-full h-full object-contain">
|
||||
<div class="relative w-20 h-20 rounded-full bg-white flex items-center justify-center shadow-2xl overflow-hidden p-2 border border-slate-100">
|
||||
<img src="{{ asset('starcloud_icon.png') }}" alt="Logo" class="w-full h-full object-contain">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ config('app.name', 'Star Cloud') }}</title>
|
||||
<link rel="shortcut icon" href="{{ asset('favicon.ico') }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@ -68,7 +69,7 @@
|
||||
<div class="mr-5 lg:mr-0 lg:hidden text-center">
|
||||
<a class="flex items-center gap-x-2 flex-none text-xl font-bold dark:text-white font-display tracking-tight"
|
||||
href="{{ route('admin.dashboard') }}" aria-label="Brand">
|
||||
<img src="{{ asset('S1_cropped_transparent.png') }}" alt="{{ config('app.name') }} Logo"
|
||||
<img src="{{ asset('starcloud_icon.png') }}" alt="{{ config('app.name') }} Logo"
|
||||
class="w-7 h-7 object-contain">
|
||||
<span>Star<span class="text-cyan-500">Cloud</span></span>
|
||||
</a>
|
||||
@ -306,6 +307,35 @@
|
||||
</svg>
|
||||
{{ __('Account Settings') }}
|
||||
</a>
|
||||
|
||||
@if(session()->has('impersonated_by'))
|
||||
<div class="my-2 border-t border-slate-100 dark:border-slate-700"></div>
|
||||
<form method="POST" action="{{ route('admin.impersonate.leave') }}">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="w-full flex items-center gap-x-3.5 py-2.5 px-3 rounded-xl text-sm font-bold text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 transition-colors">
|
||||
<svg class="flex-shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
{{ __('退出切換 (返回)') }}
|
||||
</button>
|
||||
</form>
|
||||
@elseif(auth()->user()->isSystemAdmin())
|
||||
<div class="my-2 border-t border-slate-100 dark:border-slate-700"></div>
|
||||
<button type="button" @click="open = false; $dispatch('open-impersonate-modal')"
|
||||
class="w-full flex items-center gap-x-3.5 py-2.5 px-3 rounded-xl text-sm font-bold text-slate-700 hover:bg-gray-100 focus:ring-2 focus:ring-blue-500 dark:text-slate-300 dark:hover:bg-gray-700 dark:hover:text-white transition-colors">
|
||||
<svg class="flex-shrink-0 size-4 text-emerald-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{{ __('切換帳號') }}
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<div class="my-2 border-t border-slate-100 dark:border-slate-700"></div>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
@ -383,7 +413,7 @@
|
||||
<a class="flex items-center gap-x-2 text-xl font-bold text-slate-900 dark:text-white font-display tracking-tight whitespace-nowrap overflow-hidden transition-all duration-300"
|
||||
href="{{ route('admin.dashboard') }}" aria-label="Brand"
|
||||
:class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">
|
||||
<img src="{{ asset('S1_cropped_transparent.png') }}" alt="{{ config('app.name') }} Logo"
|
||||
<img src="{{ asset('starcloud_icon.png') }}" alt="{{ config('app.name') }} Logo"
|
||||
class="w-8 h-8 object-contain shrink-0">
|
||||
<span>Star<span class="text-cyan-500">Cloud</span></span>
|
||||
</a>
|
||||
@ -420,6 +450,10 @@
|
||||
</div>
|
||||
<!-- End Content -->
|
||||
|
||||
@if(auth()->user() && auth()->user()->isSystemAdmin())
|
||||
@include('admin.impersonate.modal')
|
||||
@endif
|
||||
|
||||
<x-toast />
|
||||
|
||||
@yield('scripts')
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Size Check (1MB = 1024 * 1024 bytes)
|
||||
if (file.size > 1024 * 1024) {
|
||||
alert('{{ __('The image is too large. Please upload an image smaller than 1MB.') }}');
|
||||
// Size Check (5MB = 5 * 1024 * 1024 bytes)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('{{ __('The image is too large. Please upload an image smaller than 5MB.') }}');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -283,6 +283,12 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
|
||||
|
||||
// 主題設定
|
||||
Route::post('/theme', [App\Http\Controllers\Admin\ThemeController::class, 'update'])->name('theme.update');
|
||||
|
||||
// 16. 切換帳號 (Impersonation)
|
||||
Route::post('/impersonate', [App\Http\Controllers\Admin\ImpersonateController::class, 'store'])->name('impersonate.store');
|
||||
Route::post('/impersonate/leave', [App\Http\Controllers\Admin\ImpersonateController::class, 'leave'])->name('impersonate.leave');
|
||||
Route::get('/impersonate/companies', [App\Http\Controllers\Admin\ImpersonateController::class, 'companies'])->name('impersonate.companies');
|
||||
Route::get('/impersonate/users', [App\Http\Controllers\Admin\ImpersonateController::class, 'users'])->name('impersonate.users');
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||