[FEAT] 新增 Header 全域自動更新功能並優化行動端 UI
1. 在管理後台 Header 新增自動更新組件,支援 10s, 30s, 60s 及手動刷新。 2. 實作 SVG 環形進度條動畫與倒數計時邏輯。 3. 整合 localStorage 持久化儲存使用者更新頻率偏好。 4. 新增 zh_TW, en, ja 三種語系的對應字串。 5. 優化行動端顯示:在手機版隱藏秒數標籤以節省空間,並調整 Header 元素間距與圖示排列。 6. 電腦版 Header 移除左側 Padding,使自動更新組件完美貼齊側邊欄邊界。
This commit is contained in:
parent
41b31ffb77
commit
c774d46243
@ -1,4 +1,11 @@
|
||||
{
|
||||
"10s": "10s",
|
||||
"30s": "30s",
|
||||
"60s": "60s",
|
||||
"Auto Refresh": "Auto Refresh",
|
||||
"Off": "Off",
|
||||
"Refresh Now": "Refresh Now",
|
||||
"Refreshing in :seconds s": "Refreshing in :seconds s",
|
||||
"15 Seconds": "15 Seconds",
|
||||
"30 Seconds": "30 Seconds",
|
||||
"60 Seconds": "60 Seconds",
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
{
|
||||
"10s": "10秒",
|
||||
"30s": "30秒",
|
||||
"60s": "60秒",
|
||||
"Auto Refresh": "自動更新",
|
||||
"Off": "オフ",
|
||||
"Refresh Now": "今すぐ更新",
|
||||
"Refreshing in :seconds s": ":seconds 秒後に更新します",
|
||||
"15 Seconds": "15 Seconds",
|
||||
"30 Seconds": "30 Seconds",
|
||||
"60 Seconds": "60 Seconds",
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
{
|
||||
"10s": "10秒",
|
||||
"30s": "30秒",
|
||||
"60s": "60秒",
|
||||
"Auto Refresh": "自動更新",
|
||||
"Off": "關閉",
|
||||
"Refresh Now": "立即更新",
|
||||
"Refreshing in :seconds s": "將在 :seconds 秒後更新",
|
||||
"15 Seconds": "15 秒",
|
||||
"30 Seconds": "30 秒",
|
||||
"60 Seconds": "60 秒",
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
<header
|
||||
class="sticky top-0 inset-x-0 flex flex-wrap sm:justify-start sm:flex-nowrap z-[48] w-full bg-white/80 backdrop-blur-md border-b border-slate-200/50 text-sm py-2.5 sm:py-4 lg:pl-[256px] dark:bg-[#0f172a]/80 dark:border-slate-800/50 shadow-sm transition-all duration-300"
|
||||
:class="sidebarCollapsed ? 'lg:pl-24' : 'lg:pl-[256px]'">
|
||||
<nav class="flex basis-full items-center w-full mx-auto px-4 sm:px-6 md:px-8" aria-label="Global">
|
||||
<nav class="flex basis-full items-center w-full mx-auto pl-4 lg:pl-0 pr-4 sm:pr-6 md:pr-8" aria-label="Global">
|
||||
<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">
|
||||
@ -74,9 +74,103 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Auto Refresh -->
|
||||
<div class="relative inline-flex ml-4 sm:ml-10 lg:ml-0" x-data="{
|
||||
refreshOpen: false,
|
||||
interval: localStorage.getItem('autoRefreshInterval') || 'Off',
|
||||
timeLeft: 0,
|
||||
timer: null,
|
||||
circumference: 2 * Math.PI * 9,
|
||||
init() {
|
||||
this.resetTimer();
|
||||
this.$watch('interval', (val) => {
|
||||
localStorage.setItem('autoRefreshInterval', val);
|
||||
this.resetTimer();
|
||||
});
|
||||
},
|
||||
resetTimer() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.interval === 'Off') {
|
||||
this.timeLeft = 0;
|
||||
return;
|
||||
}
|
||||
const totalSeconds = parseInt(this.interval);
|
||||
this.timeLeft = totalSeconds;
|
||||
this.timer = setInterval(() => {
|
||||
if (this.timeLeft > 0) {
|
||||
this.timeLeft--;
|
||||
} else {
|
||||
this.manualRefresh();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
manualRefresh() {
|
||||
const loader = document.getElementById('top-loading-bar');
|
||||
if (loader) loader.classList.add('loading');
|
||||
window.location.reload();
|
||||
}
|
||||
}">
|
||||
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-full border border-slate-200/50 dark:border-slate-700/50 p-0.5 shadow-sm">
|
||||
<!-- Manual Refresh Button with Progress -->
|
||||
<button type="button" @click="manualRefresh()"
|
||||
:title="interval === 'Off' ? '{{ __('Refresh Now') }}' : '{{ __('Refreshing in :seconds s') }}'.replace(':seconds', timeLeft)"
|
||||
class="relative flex items-center justify-center size-9 rounded-full text-gray-500 hover:text-cyan-500 hover:bg-gray-50 dark:hover:bg-slate-700 transition-all focus:outline-none">
|
||||
<!-- Progress Ring -->
|
||||
<svg class="absolute inset-0 size-full -rotate-90" viewBox="0 0 24 24">
|
||||
<circle class="text-gray-100 dark:text-gray-700/50" stroke-width="2" stroke="currentColor" fill="transparent" r="9" cx="12" cy="12"/>
|
||||
<circle class="text-cyan-500 transition-all duration-1000 ease-linear" stroke-width="2"
|
||||
:stroke-dasharray="circumference"
|
||||
:stroke-dashoffset="circumference - (interval === 'Off' ? 0 : ((parseInt(interval) - timeLeft) / parseInt(interval)) * circumference)"
|
||||
stroke-linecap="round" stroke="currentColor" fill="transparent" r="9" cx="12" cy="12"
|
||||
x-show="interval !== 'Off'"/>
|
||||
</svg>
|
||||
<!-- Refresh Icon -->
|
||||
<svg class="size-4 relative z-10" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||
<path d="M3 21v-5h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Interval Selector -->
|
||||
<button type="button" @click="refreshOpen = !refreshOpen" @click.away="refreshOpen = false"
|
||||
class="flex items-center gap-x-1.5 h-8 px-2 rounded-full text-[11px] font-bold text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700 transition-all border-l border-slate-100 dark:border-slate-700/50 ml-0.5 focus:outline-none md:min-w-[3.5rem] justify-center">
|
||||
<span class="hidden md:inline whitespace-nowrap" x-text="interval === 'Off' ? '{{ __('Off') }}' : interval"></span>
|
||||
<svg class="size-3 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div x-show="refreshOpen" x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute left-0 top-full mt-2 min-w-[9rem] bg-white shadow-xl rounded-2xl p-2 dark:bg-gray-800 dark:border dark:border-gray-700 z-50 border border-slate-100"
|
||||
x-cloak>
|
||||
<div class="px-3 py-2 text-[10px] font-bold text-gray-400 uppercase tracking-wider">
|
||||
{{ __('Auto Refresh') }}
|
||||
</div>
|
||||
<template x-for="opt in ['Off', '10s', '30s', '60s']">
|
||||
<button @click="interval = opt; refreshOpen = false"
|
||||
class="flex items-center justify-between w-full py-2 px-3 rounded-xl text-sm text-gray-800 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
:class="interval === opt ? 'bg-gray-50 dark:bg-gray-900/50 text-cyan-600 dark:text-cyan-400' : ''">
|
||||
<span class="font-medium" x-text="opt === 'Off' ? '{{ __('Off') }}' : opt"></span>
|
||||
<svg x-show="interval === opt" class="size-4 text-cyan-500" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center justify-end ml-auto sm:gap-x-3 sm:order-3">
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-x-1.5 sm:gap-x-3">
|
||||
<div class="flex flex-row items-center justify-end gap-x-1 sm:gap-x-3">
|
||||
<!-- Language Switcher -->
|
||||
<div class="relative inline-flex" x-data="{ langOpen: false }">
|
||||
<button type="button" @click="langOpen = !langOpen" @click.away="langOpen = false"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user