Compare commits

..

3 Commits

Author SHA1 Message Date
Bot 49d2ef3879 fix: stabilize background scaling and positioning 2026-05-01 14:00:37 +08:00
Bot 97ddc709e8 chore: update pnpm-lock.yaml to match package.json 2026-05-01 14:00:12 +08:00
Bot d45ae46fb4 feat: add scheduled theme mode and domain translations 2026-05-01 13:54:37 +08:00
9 changed files with 111 additions and 41 deletions
+28
View File
@@ -14,6 +14,9 @@ importers:
'@heroicons/react': '@heroicons/react':
specifier: 2.2.0 specifier: 2.2.0
version: 2.2.0(react@19.0.0) version: 2.2.0(react@19.0.0)
'@number-flow/react':
specifier: 0.5.5
version: 0.5.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: 1.2.3 specifier: 1.2.3
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -278,6 +281,12 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
'@number-flow/react@0.5.5':
resolution: {integrity: sha512-Zdju5n0osxrb+7jbcpUJ9L2VJ2+9ptwjz5+A+2wq9Q32hs3PW/noPJjHtLTrtGINM9mEw76DcDg0ac/dx6j1aA==}
peerDependencies:
react: ^18 || ^19
react-dom: ^18 || ^19
'@oxc-project/types@0.122.0': '@oxc-project/types@0.122.0':
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
@@ -1268,6 +1277,9 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
eventemitter3@4.0.7: eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
@@ -1444,6 +1456,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
number-flow@0.5.3:
resolution: {integrity: sha512-iLKyssImNWQmJ41rza9K7P5lHRZTyishi/9FarWPLQHYY2Ydtl6eiXINEjZ1fa8dHeY0O7+YOD+Py3ZsJddYkg==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1793,6 +1808,13 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
'@number-flow/react@0.5.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
esm-env: 1.2.2
number-flow: 0.5.3
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@oxc-project/types@0.122.0': {} '@oxc-project/types@0.122.0': {}
'@radix-ui/number@1.1.0': {} '@radix-ui/number@1.1.0': {}
@@ -2657,6 +2679,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.2 tapable: 2.3.2
esm-env@1.2.2: {}
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
fast-equals@5.4.0: {} fast-equals@5.4.0: {}
@@ -2776,6 +2800,10 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
number-flow@0.5.3:
dependencies:
esm-env: 1.2.2
object-assign@4.1.1: {} object-assign@4.1.1: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
+12 -4
View File
@@ -96,20 +96,28 @@ const MainApp: React.FC = () => {
{customBackgroundImage && ( {customBackgroundImage && (
<div <div
className={cn( className={cn(
"fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center dark:brightness-75", "fixed inset-0 z-0 bg-cover w-screen h-screen bg-no-repeat bg-center transition-none dark:brightness-75",
{ {
"hidden sm:block": customMobileBackgroundImage, "hidden sm:block": customMobileBackgroundImage,
}, },
)} )}
style={{ backgroundImage: `url(${customBackgroundImage})` }} style={{
backgroundImage: `url(${customBackgroundImage})`,
backfaceVisibility: 'hidden',
perspective: '1000px'
}}
/> />
)} )}
{customMobileBackgroundImage && ( {customMobileBackgroundImage && (
<div <div
className={cn( className={cn(
"fixed inset-0 z-0 bg-cover min-h-lvh bg-no-repeat bg-center sm:hidden dark:brightness-75", "fixed inset-0 z-0 bg-cover w-screen h-screen bg-no-repeat bg-center transition-none sm:hidden dark:brightness-75",
)} )}
style={{ backgroundImage: `url(${customMobileBackgroundImage})` }} style={{
backgroundImage: `url(${customMobileBackgroundImage})`,
backfaceVisibility: 'hidden',
perspective: '1000px'
}}
/> />
)} )}
<div <div
+7 -23
View File
@@ -1,15 +1,11 @@
// src/components/DomainStatus.tsx (最终完整版)
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getDomains, Domain } from '@/api/domain'; import { getDomains, Domain } from '@/api/domain';
import { CalendarDays, DollarSign } from 'lucide-react'; import { CalendarDays, DollarSign } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import RemainPercentBar from "./RemainPercentBar"; import RemainPercentBar from "./RemainPercentBar";
// =======================================================
// 彩色备注标签组件
// =======================================================
const DomainNoteTags = ({ notes }: { notes?: string }) => { const DomainNoteTags = ({ notes }: { notes?: string }) => {
if (!notes) { if (!notes) {
return null; return null;
@@ -42,11 +38,8 @@ const DomainNoteTags = ({ notes }: { notes?: string }) => {
); );
}; };
// =======================================================
// 行内模式卡片 (Inline Mode Card)
// =======================================================
const DomainCardInline = ({ domain }: { domain: Domain }) => { const DomainCardInline = ({ domain }: { domain: Domain }) => {
const { t } = useTranslation();
const expiresIn = domain.expires_in_days; const expiresIn = domain.expires_in_days;
const billingData = domain.BillingData || {}; const billingData = domain.BillingData || {};
const customBackgroundImage = (window as any).CustomBackgroundImage !== "" ? (window as any).CustomBackgroundImage : undefined; const customBackgroundImage = (window as any).CustomBackgroundImage !== "" ? (window as any).CustomBackgroundImage : undefined;
@@ -75,13 +68,13 @@ const DomainCardInline = ({ domain }: { domain: Domain }) => {
<span className="w-24 truncate">{billingData.registrar || 'N/A'}</span> <span className="w-24 truncate">{billingData.registrar || 'N/A'}</span>
<div className="flex items-center gap-1.5 w-28"> <div className="flex items-center gap-1.5 w-28">
<CalendarDays className="h-3.5 w-3.5" /> <CalendarDays className="h-3.5 w-3.5" />
<span>: {billingData.endDate ? new Date(billingData.endDate).toLocaleDateString() : 'N/A'}</span> <span>{t('domain.expiryPrefix')}: {billingData.endDate ? new Date(billingData.endDate).toLocaleDateString() : 'N/A'}</span>
</div> </div>
<div className="flex items-center gap-1.5 w-24"> <div className="flex items-center gap-1.5 w-24">
<DollarSign className="h-3.5 w-3.5" /> <DollarSign className="h-3.5 w-3.5" />
<span>{billingData.renewalPrice || 'N/A'}</span> <span>{billingData.renewalPrice || 'N/A'}</span>
</div> </div>
<span className="font-semibold w-24">{expiresIn !== undefined ? `${expiresIn} ` : 'N/A'}</span> <span className="font-semibold w-24">{expiresIn !== undefined ? `${expiresIn} ${t('domain.days')}` : 'N/A'}</span>
</div> </div>
</div> </div>
<DomainNoteTags notes={billingData.notes} /> <DomainNoteTags notes={billingData.notes} />
@@ -90,17 +83,12 @@ const DomainCardInline = ({ domain }: { domain: Domain }) => {
); );
}; };
// =======================================================
// 卡片模式 (Card Mode)
// =======================================================
const DomainCard = ({ domain }: { domain: Domain }) => { const DomainCard = ({ domain }: { domain: Domain }) => {
const { t } = useTranslation();
const expiresIn = domain.expires_in_days; const expiresIn = domain.expires_in_days;
const billingData = domain.BillingData || {}; const billingData = domain.BillingData || {};
const customBackgroundImage = (window as any).CustomBackgroundImage !== "" ? (window as any).CustomBackgroundImage : undefined; const customBackgroundImage = (window as any).CustomBackgroundImage !== "" ? (window as any).CustomBackgroundImage : undefined;
return ( return (
<a href={`https://${domain.Domain}`} target="_blank" rel="noopener noreferrer" className="block h-full"> <a href={`https://${domain.Domain}`} target="_blank" rel="noopener noreferrer" className="block h-full">
<div className={cn( <div className={cn(
@@ -111,7 +99,7 @@ const DomainCard = ({ domain }: { domain: Domain }) => {
<h4 className="font-semibold font-mono tracking-tight">{domain.Domain}</h4> <h4 className="font-semibold font-mono tracking-tight">{domain.Domain}</h4>
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-medium">{billingData.registrar || '未知注册商'}</span> <span className="font-medium">{billingData.registrar || t('domain.unknownRegistrar')}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CalendarDays className="h-3 w-3" /> <CalendarDays className="h-3 w-3" />
<span>{billingData.endDate ? new Date(billingData.endDate).toLocaleDateString() : 'N/A'}</span> <span>{billingData.endDate ? new Date(billingData.endDate).toLocaleDateString() : 'N/A'}</span>
@@ -125,7 +113,7 @@ const DomainCard = ({ domain }: { domain: Domain }) => {
<div className="flex-1"> <div className="flex-1">
<RemainPercentBar value={expiresIn ? Math.max(0, Math.min(100, (expiresIn / 365) * 100)) : 100} className="w-full h-1.5" /> <RemainPercentBar value={expiresIn ? Math.max(0, Math.min(100, (expiresIn / 365) * 100)) : 100} className="w-full h-1.5" />
</div> </div>
<span className="font-medium text-muted-foreground w-12 text-right">{expiresIn !== undefined ? `${expiresIn}` : 'N/A'}</span> <span className="font-medium text-muted-foreground w-12 text-right">{expiresIn !== undefined ? `${expiresIn}${t('domain.days')}` : 'N/A'}</span>
</div> </div>
<div className="pt-1"> <div className="pt-1">
<DomainNoteTags notes={billingData.notes} /> <DomainNoteTags notes={billingData.notes} />
@@ -135,10 +123,6 @@ const DomainCard = ({ domain }: { domain: Domain }) => {
); );
}; };
// =======================================================
// 主组件 (Main Component)
// =======================================================
export const DomainStatus = () => { export const DomainStatus = () => {
const { data: domains, isLoading, error } = useQuery({ const { data: domains, isLoading, error } = useQuery({
queryKey: ['domains'], queryKey: ['domains'],
+2 -2
View File
@@ -151,7 +151,7 @@ export default function ServerOverview({
> >
<CardContent className="flex h-full items-center px-6 py-3"> <CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base"></p> <p className="text-sm font-medium md:text-base">{t("serverOverview.totalDomains")}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" /> <Globe className="h-4 w-4 text-muted-foreground" />
<div className="text-lg font-semibold">{totalDomains}</div> <div className="text-lg font-semibold">{totalDomains}</div>
@@ -195,7 +195,7 @@ export default function ServerOverview({
</section> </section>
{!disableAnimatedMan && ( {!disableAnimatedMan && (
<img <img
className="absolute right-3 top-[-85px] z-50 w-20 scale-90 group-hover:opacity-50 md:scale-100 transition-all" className="absolute right-2 top-[-72px] z-50 w-24 scale-90 group-hover:opacity-50 md:scale-100 transition-all"
alt={"animated-man"} alt={"animated-man"}
src={customIllustration} src={customIllustration}
loading="eager" loading="eager"
+17 -3
View File
@@ -1,7 +1,7 @@
import { createContext, type ReactNode, useEffect, useState } from "react"; import { createContext, type ReactNode, useEffect, useState } from "react";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
export type Theme = "dark" | "light" | "system"; export type Theme = "dark" | "light" | "system" | "scheduled";
type ThemeProviderProps = { type ThemeProviderProps = {
children: ReactNode; children: ReactNode;
@@ -29,13 +29,25 @@ export function ThemeProvider({
() => (localStorage.getItem(storageKey) as Theme) || "system", () => (localStorage.getItem(storageKey) as Theme) || "system",
); );
const [isSystemDark, setIsSystemDark] = useState(
() => window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const [hour, setHour] = useState(() => DateTime.now().hour); const [hour, setHour] = useState(() => DateTime.now().hour);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
setHour(DateTime.now().hour); setHour(DateTime.now().hour);
}, 60000); }, 60000);
return () => clearInterval(timer);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => setIsSystemDark(e.matches);
mediaQuery.addEventListener("change", handler);
return () => {
clearInterval(timer);
mediaQuery.removeEventListener("change", handler);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -46,6 +58,8 @@ export function ThemeProvider({
let effectiveTheme = theme; let effectiveTheme = theme;
if (theme === "system") { if (theme === "system") {
effectiveTheme = isSystemDark ? "dark" : "light";
} else if (theme === "scheduled") {
// Time-based theme: 18:00 - 06:00 is dark // Time-based theme: 18:00 - 06:00 is dark
const isNight = hour >= 18 || hour < 6; const isNight = hour >= 18 || hour < 6;
effectiveTheme = isNight ? "dark" : "light"; effectiveTheme = isNight ? "dark" : "light";
@@ -62,7 +76,7 @@ export function ThemeProvider({
root.classList.remove("disable-transitions"); root.classList.remove("disable-transitions");
}, 0); }, 0);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [theme, hour]); }, [theme, hour, isSystemDark]);
const value = { const value = {
theme, theme,
+9
View File
@@ -70,6 +70,15 @@ export function ModeToggle() {
{t("theme.system")} {t("theme.system")}
{theme === "system" && <CheckCircleIcon className="size-4" />} {theme === "system" && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className={cn("rounded-[5px] text-xs", {
"gap-3 bg-muted font-semibold": theme === "scheduled",
})}
onSelect={(e) => handleSelect(e, "scheduled")}
>
{t("theme.scheduled")}
{theme === "scheduled" && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
+12 -3
View File
@@ -19,7 +19,8 @@
"offlineServers": "Offline Servers", "offlineServers": "Offline Servers",
"totalBandwidth": "Total Bandwidth", "totalBandwidth": "Total Bandwidth",
"speed": "Speed", "speed": "Speed",
"network": "Network" "network": "Network",
"totalDomains": "Total Domains"
}, },
"map": { "map": {
"Distributions": "Servers are distributed in", "Distributions": "Servers are distributed in",
@@ -100,7 +101,8 @@
"theme": { "theme": {
"light": "Light", "light": "Light",
"dark": "Dark", "dark": "Dark",
"system": "System" "system": "System",
"scheduled": "Scheduled"
}, },
"error": { "error": {
"pageNotFound": "Page not found", "pageNotFound": "Page not found",
@@ -144,5 +146,12 @@
"ToggleLightMode": "Toggle Light Mode", "ToggleLightMode": "Toggle Light Mode",
"ToggleDarkMode": "Toggle Dark Mode", "ToggleDarkMode": "Toggle Dark Mode",
"ToggleSystemMode": "Toggle System Mode", "ToggleSystemMode": "Toggle System Mode",
"Home": "Home" "Home": "Home",
"domain": {
"registrar": "Registrar",
"expiryDate": "Expiry Date",
"expiryPrefix": "Expires",
"days": "Days",
"unknownRegistrar": "Unknown Registrar"
}
} }
+12 -3
View File
@@ -19,7 +19,8 @@
"offlineServers": "离线服务器", "offlineServers": "离线服务器",
"totalBandwidth": "总流量", "totalBandwidth": "总流量",
"speed": "速率", "speed": "速率",
"network": "网络" "network": "网络",
"totalDomains": "总域名数"
}, },
"map": { "map": {
"Distributions": "服务器分布在", "Distributions": "服务器分布在",
@@ -101,7 +102,8 @@
"theme": { "theme": {
"light": "亮色", "light": "亮色",
"dark": "暗色", "dark": "暗色",
"system": "跟随系统" "system": "跟随系统",
"scheduled": "定时切换"
}, },
"error": { "error": {
"pageNotFound": "页面不存在", "pageNotFound": "页面不存在",
@@ -145,5 +147,12 @@
"ToggleLightMode": "切换亮色模式", "ToggleLightMode": "切换亮色模式",
"ToggleDarkMode": "切换暗色模式", "ToggleDarkMode": "切换暗色模式",
"ToggleSystemMode": "切换系统模式", "ToggleSystemMode": "切换系统模式",
"Home": "首页" "Home": "首页",
"domain": {
"registrar": "注册商",
"expiryDate": "到期日期",
"expiryPrefix": "到期",
"days": "天",
"unknownRegistrar": "未知注册商"
}
} }
+11 -2
View File
@@ -19,7 +19,8 @@
"offlineServers": "離線伺服器", "offlineServers": "離線伺服器",
"totalBandwidth": "總帶寬", "totalBandwidth": "總帶寬",
"speed": "速率", "speed": "速率",
"network": "網路" "network": "網路",
"totalDomains": "總域名數"
}, },
"map": { "map": {
"Distributions": "伺服器分布在", "Distributions": "伺服器分布在",
@@ -94,7 +95,8 @@
"theme": { "theme": {
"light": "亮色", "light": "亮色",
"dark": "暗色", "dark": "暗色",
"system": "跟隨系統" "system": "跟隨系統",
"scheduled": "定時切換"
}, },
"error": { "error": {
"pageNotFound": "頁面不存在", "pageNotFound": "頁面不存在",
@@ -136,6 +138,13 @@
"ToggleDarkMode": "切換暗色模式", "ToggleDarkMode": "切換暗色模式",
"ToggleSystemMode": "切換系統模式", "ToggleSystemMode": "切換系統模式",
"Home": "首頁", "Home": "首頁",
"domain": {
"registrar": "註冊商",
"expiryDate": "到期日期",
"expiryPrefix": "到期",
"days": "天",
"unknownRegistrar": "未知註冊商"
},
"pwa": { "pwa": {
"offlineReady": "可離線使用之應用程式", "offlineReady": "可離線使用之應用程式",
"newContent": "有新内容可用", "newContent": "有新内容可用",