[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:
sky121113 2026-05-11 09:13:42 +08:00
parent 41b31ffb77
commit c774d46243
4 changed files with 117 additions and 2 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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 秒",

View File

@ -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"