fix: stabilize federation forward runtime cleanup and wire frontend version update UX

This commit is contained in:
sagitchu
2026-02-25 11:20:51 +08:00
parent 9c41410f17
commit 466cc65069
18 changed files with 665 additions and 66 deletions
@@ -55,7 +55,6 @@ export function SearchBar({
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Input
autoFocus
classNames={{
base: "bg-default-100",
input:
+8 -2
View File
@@ -38,13 +38,19 @@ function Alert({
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
function AlertTitle({
className,
children,
...props
}: React.ComponentProps<"h5">) {
return (
<h5
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
data-slot="alert-title"
{...props}
/>
>
{children}
</h5>
);
}
+8 -2
View File
@@ -25,7 +25,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
function CardTitle({
className,
children,
...props
}: React.ComponentProps<"h3">) {
return (
<h3
className={cn(
@@ -34,7 +38,9 @@ function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
)}
data-slot="card-title"
{...props}
/>
>
{children}
</h3>
);
}
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { siteConfig } from "@/config/site";
import {
UPDATE_CHANNEL_CHANGED_EVENT,
type UpdateReleaseChannel,
getLatestVersionByChannel,
getUpdateReleaseChannel,
hasVersionUpdate,
} from "@/utils/version-update";
const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel";
interface VersionFooterProps {
version: string;
containerClassName?: string;
versionClassName?: string;
poweredClassName?: string;
updateBadgeClassName?: string;
}
export function VersionFooter({
version,
containerClassName,
versionClassName,
poweredClassName,
updateBadgeClassName,
}: VersionFooterProps) {
const [channel, setChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestUpdateVersion, setLatestUpdateVersion] = useState<string | null>(
null,
);
useEffect(() => {
const handleChannelChange = () => {
setChannel(getUpdateReleaseChannel());
};
window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange);
window.addEventListener("storage", handleChannelChange);
return () => {
window.removeEventListener(
UPDATE_CHANNEL_CHANGED_EVENT,
handleChannelChange,
);
window.removeEventListener("storage", handleChannelChange);
};
}, []);
useEffect(() => {
let active = true;
const checkUpdate = async () => {
const latestVersion = await getLatestVersionByChannel(
channel,
siteConfig.github_repo || FALLBACK_GITHUB_REPO,
);
if (!active) {
return;
}
if (!latestVersion) {
setUpdateAvailable(false);
setLatestUpdateVersion(null);
return;
}
const hasUpdate = hasVersionUpdate(version, latestVersion);
setUpdateAvailable(hasUpdate);
setLatestUpdateVersion(hasUpdate ? latestVersion : null);
};
void checkUpdate();
return () => {
active = false;
};
}, [channel, version]);
return (
<div className={containerClassName}>
<p className={versionClassName}>
v{version}
{updateAvailable && latestUpdateVersion && (
<span className={updateBadgeClassName} role="status">
{latestUpdateVersion}
</span>
)}
</p>
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
</div>
);
}
+7 -14
View File
@@ -20,6 +20,7 @@ import {
} from "@/shadcn-bridge/heroui/modal";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Logo } from "@/components/icons";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { siteConfig } from "@/config/site";
@@ -337,9 +338,6 @@ export default function AdminLayout({
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
{siteConfig.name}
</h1>
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">
v{siteConfig.version}
</p>
</div>
</div>
@@ -405,17 +403,12 @@ export default function AdminLayout({
<div
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
>
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<VersionFooter
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500"
/>
</div>
{/* 桌面端折叠按钮 */}
+52
View File
@@ -33,6 +33,11 @@ import {
clearConfigCache,
updateSiteConfig,
} from "@/config/site";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
// 简单的保存图标组件
const SaveIcon = ({ className }: { className?: string }) => (
@@ -182,6 +187,9 @@ export default function ConfigPage() {
});
const [announcementLoading, setAnnouncementLoading] = useState(true);
const [announcementSaving, setAnnouncementSaving] = useState(false);
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
// 权限检查
useEffect(() => {
@@ -267,6 +275,14 @@ export default function ConfigPage() {
}
};
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
@@ -643,6 +659,42 @@ export default function ConfigPage() {
</div>
);
})}
<Divider className="my-2" />
<div className="space-y-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
更新通道
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
的版本。
</p>
</div>
<Select
selectedKeys={[updateChannel]}
size="md"
variant="bordered"
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
稳定版
</SelectItem>
<SelectItem
key="dev"
description="仅 alpha / beta / rc 关键字版本"
>
开发版
</SelectItem>
</Select>
</div>
</CardBody>
</Card>
+2 -2
View File
@@ -3199,7 +3199,7 @@ export default function ForwardPage() {
<ModalBody>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">按用户筛选</label>
<p className="text-sm font-medium">按用户筛选</p>
<Select
aria-label="筛选用户"
className="w-full"
@@ -3220,7 +3220,7 @@ export default function ForwardPage() {
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">按隧道筛选</label>
<p className="text-sm font-medium">按隧道筛选</p>
<Select
aria-label="筛选隧道"
className="w-full"
+8 -16
View File
@@ -9,6 +9,7 @@ import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives";
import { VersionFooter } from "@/components/version-footer";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session";
@@ -213,22 +214,13 @@ export default function IndexPage() {
{/* 版权信息 - 固定在底部,不占据布局空间 */}
<div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebView ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-4 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={isWebView ? siteConfig.app_version : siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
{/* 验证码弹层 */}
{showCaptcha && siteKey && (
+10 -16
View File
@@ -15,6 +15,7 @@ import {
import { Input } from "@/shadcn-bridge/heroui/input";
import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
@@ -312,22 +313,15 @@ export default function ProfilePage() {
</CardBody>
</Card>
<div className="fixed inset-x-0 bottom-20 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-20 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={
isWebViewFunc() ? siteConfig.app_version : siteConfig.version
}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
</div>
{/* 修改密码弹窗 */}
+47
View File
@@ -5,7 +5,13 @@ import toast from "react-hot-toast";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { reinitializeBaseURL } from "@/api/network";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import {
getPanelAddresses,
savePanelAddress,
@@ -25,6 +31,9 @@ export const SettingsPage = () => {
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState("");
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
@@ -79,6 +88,14 @@ export const SettingsPage = () => {
loadPanelAddresses();
}, []);
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-black">
{/* 顶部导航 */}
@@ -117,6 +134,36 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
更新设置
</h2>
<div className="space-y-2">
<Select
label="更新通道"
selectedKeys={[updateChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" textValue="stable">
稳定版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
开发版(含 alpha / beta / rc)
</SelectItem>
</Select>
<p className="text-xs text-gray-500 dark:text-gray-400">
版本提示会根据该通道检查最新版本。
</p>
</div>
</CardBody>
</Card>
{/* 添加新地址 */}
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
-1
View File
@@ -638,7 +638,6 @@ export default function UserPage() {
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
@@ -19,11 +19,18 @@ function mapColor(color: LinkColor) {
return "text-default-600 hover:text-default-700";
}
export function Link({ className, color = "default", ...props }: LinkProps) {
export function Link({
className,
color = "default",
children,
...props
}: LinkProps) {
return (
<a
className={cn("transition-colors", mapColor(color), className)}
{...props}
/>
>
{children}
</a>
);
}
+218
View File
@@ -0,0 +1,218 @@
export type UpdateReleaseChannel = "stable" | "dev";
export const UPDATE_CHANNEL_STORAGE_KEY = "update-release-channel";
export const UPDATE_CHANNEL_CHANGED_EVENT = "updateReleaseChannelChanged";
const CHANNEL_STABLE: UpdateReleaseChannel = "stable";
const CHANNEL_DEV: UpdateReleaseChannel = "dev";
const stableVersionPattern = /^\d+(?:\.\d+)+$/;
const testKeywordPattern = /(alpha|beta|rc)/i;
const VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
type ReleaseItem = {
tag_name?: string;
draft?: boolean;
};
type LatestVersionCacheEntry = {
value: string | null;
expiresAt: number;
};
const latestVersionCache: Record<
UpdateReleaseChannel,
LatestVersionCacheEntry
> = {
stable: { value: null, expiresAt: 0 },
dev: { value: null, expiresAt: 0 },
};
const normalizeChannel = (
value: string | null | undefined,
): UpdateReleaseChannel => {
return value === CHANNEL_DEV ? CHANNEL_DEV : CHANNEL_STABLE;
};
export const getUpdateReleaseChannel = (): UpdateReleaseChannel => {
if (typeof window === "undefined") {
return CHANNEL_STABLE;
}
return normalizeChannel(localStorage.getItem(UPDATE_CHANNEL_STORAGE_KEY));
};
export const setUpdateReleaseChannel = (
channel: UpdateReleaseChannel,
): void => {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(UPDATE_CHANNEL_STORAGE_KEY, normalizeChannel(channel));
window.dispatchEvent(new Event(UPDATE_CHANNEL_CHANGED_EVENT));
};
const normalizeTag = (tag: string): string => {
return tag.trim().replace(/^v/i, "");
};
type ReleaseTagChannel = UpdateReleaseChannel | null;
const releaseChannelFromTag = (tag: string): ReleaseTagChannel => {
const normalizedTag = normalizeTag(tag).toLowerCase();
if (!normalizedTag) {
return null;
}
if (stableVersionPattern.test(normalizedTag)) {
return CHANNEL_STABLE;
}
if (testKeywordPattern.test(normalizedTag)) {
return CHANNEL_DEV;
}
return null;
};
type VersionParts = {
numbers: number[];
stageRank: number;
stageNumber: number;
};
const parseVersionParts = (version: string): VersionParts => {
const normalized = normalizeTag(version).toLowerCase();
const numberMatches = normalized.match(/\d+/g) || [];
const numbers = numberMatches.map((item) => Number.parseInt(item, 10));
let stageRank = 0;
if (normalized.includes("rc")) {
stageRank = 3;
} else if (normalized.includes("beta")) {
stageRank = 2;
} else if (normalized.includes("alpha")) {
stageRank = 1;
} else if (stableVersionPattern.test(normalized)) {
stageRank = 4;
}
const stageNumberMatch = normalized.match(/(?:alpha|beta|rc)[.-]?(\d+)/);
const stageNumber = stageNumberMatch
? Number.parseInt(stageNumberMatch[1], 10)
: 0;
return {
numbers,
stageRank,
stageNumber,
};
};
export const compareVersions = (left: string, right: string): number => {
const a = parseVersionParts(left);
const b = parseVersionParts(right);
const maxLength = Math.max(a.numbers.length, b.numbers.length);
for (let i = 0; i < maxLength; i += 1) {
const aValue = a.numbers[i] || 0;
const bValue = b.numbers[i] || 0;
if (aValue !== bValue) {
return aValue - bValue;
}
}
if (a.stageRank !== b.stageRank) {
return a.stageRank - b.stageRank;
}
if (a.stageNumber !== b.stageNumber) {
return a.stageNumber - b.stageNumber;
}
return 0;
};
const repoPathFromUrl = (repoUrl: string): string | null => {
try {
const parsed = new URL(repoUrl);
const segments = parsed.pathname
.replace(/\.git$/i, "")
.split("/")
.filter(Boolean);
if (segments.length < 2) {
return null;
}
return `${segments[0]}/${segments[1]}`;
} catch {
return null;
}
};
export const getLatestVersionByChannel = async (
channel: UpdateReleaseChannel,
repoUrl: string,
): Promise<string | null> => {
const normalizedChannel = normalizeChannel(channel);
const now = Date.now();
const cached = latestVersionCache[normalizedChannel];
if (cached.value && cached.expiresAt > now) {
return cached.value;
}
const repoPath = repoPathFromUrl(repoUrl);
if (!repoPath) {
return null;
}
const response = await fetch(
`https://api.github.com/repos/${repoPath}/releases?per_page=50`,
{
headers: {
Accept: "application/vnd.github+json",
},
},
);
if (!response.ok) {
return null;
}
const releases = (await response.json()) as ReleaseItem[];
const candidateTags = releases
.filter((release) => !release.draft && typeof release.tag_name === "string")
.map((release) => (release.tag_name || "").trim())
.filter((tag) => releaseChannelFromTag(tag) === normalizedChannel);
if (candidateTags.length === 0) {
return null;
}
const latest = candidateTags.sort((a, b) => compareVersions(b, a))[0];
latestVersionCache[normalizedChannel] = {
value: latest,
expiresAt: now + VERSION_CACHE_TTL_MS,
};
return latest;
};
export const hasVersionUpdate = (
currentVersion: string,
latestVersion: string,
): boolean => {
return (
compareVersions(normalizeTag(currentVersion), normalizeTag(latestVersion)) <
0
);
};
+8
View File
@@ -9,3 +9,11 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:pwa-register" {
export function registerSW(options?: {
immediate?: boolean;
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
}): (reloadPage?: boolean) => Promise<void>;
}