Files
clash-verge-rev-lite/src/pages/unlock.tsx

427 lines
13 KiB
TypeScript

import {
AccessTimeOutlined,
CancelOutlined,
CheckCircleOutlined,
HelpOutline,
PendingOutlined,
RefreshRounded,
} from "@mui/icons-material";
import {
Box,
Button,
Card,
Chip,
CircularProgress,
Divider,
Grid,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import { invoke } from "@tauri-apps/api/core";
import { useLockFn } from "ahooks";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseEmpty, BasePage } from "@/components/base";
import { showNotice } from "@/services/noticeService";
interface UnlockItem {
name: string;
status: string;
region?: string | null;
check_time?: string | null;
}
const UNLOCK_RESULTS_STORAGE_KEY = "clash_verge_unlock_results";
const UNLOCK_RESULTS_TIME_KEY = "clash_verge_unlock_time";
const UnlockPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const [unlockItems, setUnlockItems] = useState<UnlockItem[]>([]);
const [isCheckingAll, setIsCheckingAll] = useState(false);
const [loadingItems, setLoadingItems] = useState<string[]>([]);
const sortItemsByName = useCallback((items: UnlockItem[]) => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, []);
const mergeUnlockItems = useCallback(
(defaults: UnlockItem[], existing?: UnlockItem[] | null) => {
if (!existing || existing.length === 0) {
return defaults;
}
const existingMap = new Map(existing.map((item) => [item.name, item]));
const merged = defaults.map((item) => existingMap.get(item.name) ?? item);
const mergedNameSet = new Set(merged.map((item) => item.name));
existing.forEach((item) => {
if (!mergedNameSet.has(item.name)) {
merged.push(item);
}
});
return merged;
},
[],
);
// 保存测试结果到本地存储
const saveResultsToStorage = useCallback(
(items: UnlockItem[], time: string | null) => {
try {
localStorage.setItem(UNLOCK_RESULTS_STORAGE_KEY, JSON.stringify(items));
if (time) {
localStorage.setItem(UNLOCK_RESULTS_TIME_KEY, time);
}
} catch (err) {
console.error("Failed to save results to storage:", err);
}
},
[],
);
const loadResultsFromStorage = useCallback((): {
items: UnlockItem[] | null;
time: string | null;
} => {
try {
const itemsJson = localStorage.getItem(UNLOCK_RESULTS_STORAGE_KEY);
const time = localStorage.getItem(UNLOCK_RESULTS_TIME_KEY);
if (itemsJson) {
return {
items: JSON.parse(itemsJson) as UnlockItem[],
time,
};
}
} catch (err) {
console.error("Failed to load results from storage:", err);
}
return { items: null, time: null };
}, []);
const getUnlockItems = useCallback(
async (
existingItems: UnlockItem[] | null = null,
existingTime: string | null = null,
) => {
try {
const defaultItems = await invoke<UnlockItem[]>("get_unlock_items");
const mergedItems = mergeUnlockItems(defaultItems, existingItems);
const sortedItems = sortItemsByName(mergedItems);
setUnlockItems(sortedItems);
saveResultsToStorage(
sortedItems,
existingItems && existingItems.length > 0 ? existingTime : null,
);
} catch (err: any) {
console.error("Failed to get unlock items:", err);
}
},
[mergeUnlockItems, saveResultsToStorage, sortItemsByName],
);
useEffect(() => {
void (async () => {
const { items: storedItems, time: storedTime } = loadResultsFromStorage();
if (storedItems && storedItems.length > 0) {
setUnlockItems(sortItemsByName(storedItems));
await getUnlockItems(storedItems, storedTime);
} else {
await getUnlockItems();
}
})();
}, [getUnlockItems, loadResultsFromStorage, sortItemsByName]);
const invokeWithTimeout = async <T,>(
cmd: string,
args?: any,
timeout = 15000,
): Promise<T> => {
return Promise.race([
invoke<T>(cmd, args),
new Promise<T>((_, reject) =>
setTimeout(
() => reject(new Error(t("Detection timeout or failed"))),
timeout,
),
),
]);
};
// 执行全部项目检测
const checkAllMedia = useLockFn(async () => {
try {
setIsCheckingAll(true);
const result =
await invokeWithTimeout<UnlockItem[]>("check_media_unlock");
const sortedItems = sortItemsByName(result);
setUnlockItems(sortedItems);
const currentTime = new Date().toLocaleString();
saveResultsToStorage(sortedItems, currentTime);
setIsCheckingAll(false);
} catch (err: any) {
setIsCheckingAll(false);
showNotice(
"error",
err?.message || err?.toString() || t("Detection timeout or failed"),
);
console.error("Failed to check media unlock:", err);
}
});
// 检测单个流媒体服务
const checkSingleMedia = useLockFn(async (name: string) => {
try {
setLoadingItems((prev) => [...prev, name]);
const result =
await invokeWithTimeout<UnlockItem[]>("check_media_unlock");
const targetItem = result.find((item: UnlockItem) => item.name === name);
if (targetItem) {
const updatedItems = sortItemsByName(
unlockItems.map((item: UnlockItem) =>
item.name === name ? targetItem : item,
),
);
setUnlockItems(updatedItems);
const currentTime = new Date().toLocaleString();
saveResultsToStorage(updatedItems, currentTime);
}
setLoadingItems((prev) => prev.filter((item) => item !== name));
} catch (err: any) {
setLoadingItems((prev) => prev.filter((item) => item !== name));
showNotice(
"error",
err?.message ||
err?.toString() ||
t("Detection failed for {name}").replace("{name}", name),
);
console.error(`Failed to check ${name}:`, err);
}
});
// 状态颜色
const getStatusColor = (status: string) => {
if (status === "Pending") return "default";
if (status === "Yes") return "success";
if (status === "No") return "error";
if (status === "Soon") return "warning";
if (status.includes("Failed")) return "error";
if (status === "Completed") return "info";
if (
status === "Disallowed ISP" ||
status === "Blocked" ||
status === "Unsupported Country/Region"
) {
return "error";
}
return "default";
};
// 状态图标
const getStatusIcon = (status: string) => {
if (status === "Pending") return <PendingOutlined />;
if (status === "Yes") return <CheckCircleOutlined />;
if (status === "No") return <CancelOutlined />;
if (status === "Soon") return <AccessTimeOutlined />;
if (status.includes("Failed")) return <HelpOutline />;
return <HelpOutline />;
};
// 边框色
const getStatusBorderColor = (status: string) => {
if (status === "Yes") return theme.palette.success.main;
if (status === "No") return theme.palette.error.main;
if (status === "Soon") return theme.palette.warning.main;
if (status.includes("Failed")) return theme.palette.error.main;
if (status === "Completed") return theme.palette.info.main;
return theme.palette.divider;
};
const isDark = theme.palette.mode === "dark";
return (
<BasePage
title={t("Unlock Test")}
header={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
variant="contained"
size="small"
disabled={isCheckingAll}
onClick={checkAllMedia}
startIcon={
isCheckingAll ? (
<CircularProgress size={16} color="inherit" />
) : (
<RefreshRounded />
)
}
>
{isCheckingAll ? t("Testing...") : t("Test All")}
</Button>
</Box>
}
>
{unlockItems.length === 0 ? (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "50%",
}}
>
<BaseEmpty text={t("No unlock test items")} />
</Box>
) : (
<Grid container spacing={1.5} columns={{ xs: 1, sm: 2, md: 3 }}>
{unlockItems.map((item) => (
<Grid size={1} key={item.name}>
<Card
variant="outlined"
sx={{
height: "100%",
borderRadius: 2,
borderLeft: `4px solid ${getStatusBorderColor(item.status)}`,
backgroundColor: isDark ? "#282a36" : "#ffffff",
position: "relative",
overflow: "hidden",
"&:hover": {
backgroundColor: isDark
? alpha(theme.palette.primary.dark, 0.05)
: alpha(theme.palette.primary.light, 0.05),
},
display: "flex",
flexDirection: "column",
}}
>
<Box sx={{ p: 1.3, flex: 1 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
fontSize: "1rem",
color: "text.primary",
}}
>
{item.name}
</Typography>
<Tooltip title={t("Test")}>
<span>
<Button
size="small"
variant="outlined"
color="primary"
disabled={
loadingItems.includes(item.name) || isCheckingAll
}
sx={{
minWidth: "32px",
width: "32px",
height: "32px",
borderRadius: "50%",
}}
onClick={() => checkSingleMedia(item.name)}
>
<RefreshRounded
sx={{
animation: loadingItems.includes(item.name)
? "spin 1s linear infinite"
: "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}}
/>
</Button>
</span>
</Tooltip>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 1,
}}
>
<Chip
label={t(item.status)}
color={getStatusColor(item.status)}
size="small"
icon={getStatusIcon(item.status)}
sx={{
fontWeight:
item.status === "Pending" ? "normal" : "bold",
}}
/>
{item.region && (
<Chip
label={item.region}
size="small"
variant="outlined"
color="info"
/>
)}
</Box>
</Box>
<Divider
sx={{
borderStyle: "dashed",
borderColor: alpha(theme.palette.divider, 0.2),
mx: 1,
}}
/>
<Box sx={{ px: 1.5, py: 0.2 }}>
<Typography
variant="caption"
sx={{
display: "block",
color: "text.secondary",
fontSize: "0.7rem",
textAlign: "right",
}}
>
{item.check_time || "-- --"}
</Typography>
</Box>
</Card>
</Grid>
))}
</Grid>
)}
</BasePage>
);
};
export default UnlockPage;