Sline c8aa72186e
chore: i18n (#5276)
* chore: notice i18n

* feat: add script to clean up unused i18n keys

* chore: cleanup i18n keys

* refactor(i18n/proxies): migrate proxies UI to structured locale keys

* chore: i18n for rule module

* chore: i18n for profile module

* chore: i18n for connections module

* chore: i18n for settings module

* chore: i18n for verge settings

* chore: i18n for theme settings

* chore: i18n for theme

* chore(i18n): components.home.*

* chore(i18n): remove unused i18n keys

* chore(i18n): components.profile.*

* chore(i18n): components.connection

* chore(i18n): pages.logs.*

* chore(i18n): pages.*.provider

* chore(i18n): components.settings.externalCors.*

* chore(i18n): components.settings.clash.*

* chore(i18n): components.settings.liteMode.*

* chore(i18n): components.settings.backup.*

* chore(i18n): components.settings.clash.port.*

* chore(i18n): components.settings.misc.*

* chore(i18n): components.settings.update.*

* chore(i18n): components.settings.sysproxy.*

* chore(i18n): components.settings.sysproxy.*

* chore(i18n): pages.profiles.notices/components.providers.notices

* refactor(notice): unify showNotice usage

* refactor(notice): add typed showNotice shortcuts, centralize defaults, and simplify subscriptions

* refactor: unify showNotice usage

* refactor(notice): unify showNotice API

* refactor(notice): unify showNotice usage

* chore(i18n): components.test.*

* chore(i18n): components.settings.dns.*

* chore(i18n): components.home.clashInfo.*

* chore(i18n): components.home.systemInfo.*

* chore(i18n): components.home.ipInfo/traffic.*

* chore(i18n): navigation.*

* refactor(i18n): remove pages.* namespace and migrate route texts under module-level page keys

* chore(i18n): common.*

* chore(i18n): common.*

* fix: change error handling in patch_profiles_config to return false when a switch is in progress

* fix: improve error handling in patch_profiles_config to prevent requests during profile switching

* fix: change error handling in patch_profiles_config to return false when a switch is in progress

fix: ensure CURRENT_SWITCHING_PROFILE is reset after config updates in perform_config_update and patch_profiles_config

* chore(i18n): restructure root-level locale keys into namespaces

* chore(i18n): add missing i18n keys

* docs: i18n guide

* chore: adjust i18n

* refactor(i18n): align UI actions and status labels with common keys

* refactor(i18n): unify two-name locale namespaces

* refactor(i18n/components): unify locale keys and update component references

* chore(i18n): add shared and entities namespaces to all locale files

* refactor(i18n): consolidate shared and entity namespaces across features

* chore(deps): update npm dependencies to ^7.3.5 (#5310)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* refactor(i18n): migrate shared editor modes and consolidate entities namespaces

* tmp

* refactor(i18n): flatten locales and move theme/validation strings

* docs: CONTRIBUTING_i18n.md

* refactor(i18n): restructure feedback and profile namespaces for better organization

* refactor(i18n): unify settings locale structure and update references

* refactor(i18n): reorganize locale keys for home, proxies, rules, connections, logs, unlock, and tests

* refactor(i18n/feedback/layout): unify shared toasts & normalize layout namespace

* refactor(i18n): centralize common UI strings in shared

* refactor(i18n): flatten headers and unify locale schema

* refactor(i18n): consolidate duplicate per-feature translations into shared namespace

* refactor(i18n): split locales into per-namespace files

* style: lint

* refactor(i18n): unify unlock UI translations under tests namespace

* feat(i18n): add type-checked translation keys

* style: eslint import order

* feat(i18n): replace ad-hoc loader with rust-i18n backend bundles

* chore(prebuild): remove locale-copy step

* fix(i18n, notice): propagate runtime params and update cleanup script path

* fix(i18n,notice): make locale formatting idempotent and guard early notice translations

* fix(i18n): resolve locale aliases and match OS codes correctly

* fix(unlock): use i18next-compatible double-brace interpolation in failure notice

* fix(i18n): route unlock error notices through translation keys

* fix(i18n): i18n types

* feat(i18n): localize upgrade notice for Clash core viewer

* fix(notice): ensure runtime overrides apply to prefix translations

* chore(i18n): replace literal notices with translation keys

* chore(i18n): types

* chore(i18n): regen typings before formatting to keep keys in sync

* chore(i18n): simply labels

* chore(i18n): adjust translation

* chore: remove eslint-plugin-i18next

* chore(i18n): add/refine Korean translations across frontend scopes and Rust backend (#5341)

* chore(i18n): translate settings.json (missed in previous pass) (#5343)

* chore(i18n): add/refine Korean translations across frontend scopes and Rust backend

* chore(i18n): add/refine Korean translations across frontend scopes and Rust backend

* fix(i18n-tauri): quote placeholder-leading value in ko.yml to prevent rust_i18n parse panic

* chore(i18n): translate settings.json (forgot to include previously)

---------

Co-authored-by: rozan <34974262+thelojan@users.noreply.github.com>
2025-11-08 19:40:38 +08:00

927 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
RefreshRounded,
DragIndicatorRounded,
CheckBoxRounded,
CheckBoxOutlineBlankRounded,
} from "@mui/icons-material";
import {
Box,
Typography,
LinearProgress,
IconButton,
keyframes,
MenuItem,
Menu,
CircularProgress,
} from "@mui/material";
import { open } from "@tauri-apps/plugin-shell";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useEffect, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { mutate } from "swr";
import { ConfirmViewer } from "@/components/profile/confirm-viewer";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer";
import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
import {
viewProfile,
readProfileFile,
updateProfile,
saveProfileFile,
getNextUpdateTime,
} from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { useLoadingCache, useSetLoadingCache } from "@/services/states";
import type { TranslationKey } from "@/types/generated/i18n-keys";
import parseTraffic from "@/utils/parse-traffic";
import { ProfileBox } from "./profile-box";
import { ProxiesEditorViewer } from "./proxies-editor-viewer";
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
interface Props {
id: string;
selected: boolean;
activating: boolean;
itemData: IProfileItem;
onSelect: (force: boolean) => void;
onEdit: () => void;
onSave?: (prev?: string, curr?: string) => void;
onDelete: () => void;
batchMode?: boolean;
isSelected?: boolean;
onSelectionChange?: () => void;
}
export const ProfileItem = (props: Props) => {
const {
id,
selected,
activating,
itemData,
onSelect,
onEdit,
onSave,
onDelete,
batchMode,
isSelected,
onSelectionChange,
} = props;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id,
});
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const loadingCache = useLoadingCache();
const setLoadingCache = useSetLoadingCache();
// 新增状态:是否显示下次更新时间
const [showNextUpdate, setShowNextUpdate] = useState(false);
const [nextUpdateTime, setNextUpdateTime] = useState("");
const { uid, name = "Profile", extra, updated = 0, option } = itemData;
// 获取下次更新时间的函数
const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => {
if (
itemData.option?.update_interval &&
itemData.option.update_interval > 0
) {
try {
console.log(`尝试获取配置 ${itemData.uid} 的下次更新时间`);
// 如果需要强制刷新先触发Timer.refresh()
if (forceRefresh) {
// 这里可以通过一个新的API来触发刷新但目前我们依赖patch_profile中的刷新
console.log(`强制刷新定时器任务`);
}
const nextUpdate = await getNextUpdateTime(itemData.uid);
console.log(`获取到下次更新时间结果:`, nextUpdate);
if (nextUpdate) {
const nextUpdateDate = dayjs(nextUpdate * 1000);
const now = dayjs();
// 如果已经过期,显示"更新失败"
if (nextUpdateDate.isBefore(now)) {
setNextUpdateTime(
t("profiles.components.profileItem.status.lastUpdateFailed"),
);
} else {
// 否则显示剩余时间
const diffMinutes = nextUpdateDate.diff(now, "minute");
if (diffMinutes < 60) {
if (diffMinutes <= 0) {
setNextUpdateTime(
`${t("profiles.components.profileItem.status.nextUp")} <1m`,
);
} else {
setNextUpdateTime(
`${t("profiles.components.profileItem.status.nextUp")} ${diffMinutes}m`,
);
}
} else {
const hours = Math.floor(diffMinutes / 60);
const mins = diffMinutes % 60;
setNextUpdateTime(
`${t("profiles.components.profileItem.status.nextUp")} ${hours}h ${mins}m`,
);
}
}
} else {
console.log(`返回的下次更新时间为空`);
setNextUpdateTime(
t("profiles.components.profileItem.status.noSchedule"),
);
}
} catch (err) {
console.error(`获取下次更新时间出错:`, err);
setNextUpdateTime(t("profiles.components.profileItem.status.unknown"));
}
} else {
console.log(`该配置未设置更新间隔或间隔为0`);
setNextUpdateTime(
t("profiles.components.profileItem.status.autoUpdateDisabled"),
);
}
});
// 切换显示模式的函数
const toggleUpdateTimeDisplay = (e: React.MouseEvent) => {
e.stopPropagation();
if (!showNextUpdate) {
fetchNextUpdateTime();
}
setShowNextUpdate(!showNextUpdate);
};
// 当组件加载或更新间隔变化时更新下次更新时间
useEffect(() => {
if (showNextUpdate) {
fetchNextUpdateTime();
}
}, [
fetchNextUpdateTime,
showNextUpdate,
itemData.option?.update_interval,
updated,
]);
// 订阅定时器更新事件
useEffect(() => {
let refreshTimeout: number | undefined;
// 处理定时器更新事件 - 这个事件专门用于通知定时器变更
const handleTimerUpdate = (event: Event) => {
const source = event as CustomEvent<string> & { payload?: string };
const updatedUid = source.detail ?? source.payload;
// 只有当更新的是当前配置时才刷新显示
if (updatedUid === itemData.uid && showNextUpdate) {
console.log(`收到定时器更新事件: uid=${updatedUid}`);
if (refreshTimeout !== undefined) {
clearTimeout(refreshTimeout);
}
refreshTimeout = window.setTimeout(() => {
fetchNextUpdateTime(true);
}, 1000);
}
};
// 只注册定时器更新事件监听
window.addEventListener("verge://timer-updated", handleTimerUpdate);
return () => {
if (refreshTimeout !== undefined) {
clearTimeout(refreshTimeout);
}
// 清理事件监听
window.removeEventListener("verge://timer-updated", handleTimerUpdate);
};
}, [fetchNextUpdateTime, itemData.uid, showNextUpdate]);
// local file mode
// remote file mode
// remote file mode
const hasUrl = !!itemData.url;
const hasExtra = !!extra; // only subscription url has extra info
const hasHome = !!itemData.home; // only subscription url has home page
const { upload = 0, download = 0, total = 0 } = extra ?? {};
const from = parseUrl(itemData.url);
const description = itemData.desc;
const expire = parseExpire(extra?.expire);
const progress = Math.min(
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
100,
);
const loading = loadingCache[itemData.uid] ?? false;
// interval update fromNow field
const [, forceRefresh] = useReducer((value: number) => value + 1, 0);
useEffect(() => {
if (!hasUrl) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const handler = () => {
const now = Date.now();
const lastUpdate = updated * 1000;
// 大于一天的不管
if (now - lastUpdate >= 24 * 36e5) return;
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4;
timer = setTimeout(() => {
forceRefresh();
handler();
}, wait);
};
handler();
return () => {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
};
}, [forceRefresh, hasUrl, updated]);
const [fileOpen, setFileOpen] = useState(false);
const [rulesOpen, setRulesOpen] = useState(false);
const [proxiesOpen, setProxiesOpen] = useState(false);
const [groupsOpen, setGroupsOpen] = useState(false);
const [mergeOpen, setMergeOpen] = useState(false);
const [scriptOpen, setScriptOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const onOpenHome = () => {
setAnchorEl(null);
open(itemData.home ?? "");
};
const onEditInfo = () => {
setAnchorEl(null);
onEdit();
};
const onEditFile = () => {
setAnchorEl(null);
setFileOpen(true);
};
const onEditRules = () => {
setAnchorEl(null);
setRulesOpen(true);
};
const onEditProxies = () => {
setAnchorEl(null);
setProxiesOpen(true);
};
const onEditGroups = () => {
setAnchorEl(null);
setGroupsOpen(true);
};
const onEditMerge = () => {
setAnchorEl(null);
setMergeOpen(true);
};
const onEditScript = () => {
setAnchorEl(null);
setScriptOpen(true);
};
const onForceSelect = () => {
setAnchorEl(null);
onSelect(true);
};
const onOpenFile = useLockFn(async () => {
setAnchorEl(null);
try {
await viewProfile(itemData.uid);
} catch (err) {
showNotice.error(err);
}
});
/// 0 不使用任何代理
/// 1 使用订阅好的代理
/// 2 至少使用一个代理,根据订阅,如果没订阅,默认使用系统代理
const onUpdate = useLockFn(async (type: 0 | 1 | 2): Promise<void> => {
setAnchorEl(null);
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
// 根据类型设置初始更新选项
const option: Partial<IProfileOption> = {};
if (type === 0) {
option.with_proxy = false;
option.self_proxy = false;
} else if (type === 2) {
if (itemData.option?.self_proxy) {
option.with_proxy = false;
option.self_proxy = true;
} else {
option.with_proxy = true;
option.self_proxy = false;
}
}
try {
// 调用后端更新(后端会自动处理回退逻辑)
const payload = Object.keys(option).length > 0 ? option : undefined;
await updateProfile(itemData.uid, payload);
// 更新成功,刷新列表
mutate("getProfiles");
} catch {
// 更新完全失败(包括后端的回退尝试)
// 不需要做处理,后端会通过事件通知系统发送错误
} finally {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
}
});
type ContextMenuItem = {
label: string;
handler: () => void;
disabled: boolean;
};
const menuLabels: Record<string, TranslationKey> = {
home: "profiles.components.menu.home",
select: "profiles.components.menu.select",
editInfo: "profiles.components.menu.editInfo",
editFile: "profiles.components.menu.editFile",
editRules: "profiles.components.menu.editRules",
editProxies: "profiles.components.menu.editProxies",
editGroups: "profiles.components.menu.editGroups",
extendConfig: "profiles.components.menu.extendConfig",
extendScript: "profiles.components.menu.extendScript",
openFile: "profiles.components.menu.openFile",
update: "profiles.components.menu.update",
updateViaProxy: "profiles.components.menu.updateViaProxy",
delete: "shared.actions.delete",
} as const;
const urlModeMenu: ContextMenuItem[] = [
...(hasHome
? [
{
label: menuLabels.home,
handler: onOpenHome,
disabled: false,
} satisfies ContextMenuItem,
]
: []),
{
label: menuLabels.select,
handler: onForceSelect,
disabled: false,
},
{
label: menuLabels.editInfo,
handler: onEditInfo,
disabled: false,
},
{
label: menuLabels.editFile,
handler: onEditFile,
disabled: false,
},
{
label: menuLabels.editRules,
handler: onEditRules,
disabled: !option?.rules,
},
{
label: menuLabels.editProxies,
handler: onEditProxies,
disabled: !option?.proxies,
},
{
label: menuLabels.editGroups,
handler: onEditGroups,
disabled: !option?.groups,
},
{
label: menuLabels.extendConfig,
handler: onEditMerge,
disabled: !option?.merge,
},
{
label: menuLabels.extendScript,
handler: onEditScript,
disabled: !option?.script,
},
{
label: menuLabels.openFile,
handler: onOpenFile,
disabled: false,
},
{
label: menuLabels.update,
handler: () => onUpdate(0),
disabled: false,
},
{
label: menuLabels.updateViaProxy,
handler: () => onUpdate(2),
disabled: false,
},
{
label: menuLabels.delete,
handler: () => {
setAnchorEl(null);
if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
if (onSelectionChange) {
onSelectionChange();
}
} else {
setConfirmOpen(true);
}
},
disabled: false,
},
];
const fileModeMenu: ContextMenuItem[] = [
{
label: menuLabels.select,
handler: onForceSelect,
disabled: false,
},
{
label: menuLabels.editInfo,
handler: onEditInfo,
disabled: false,
},
{
label: menuLabels.editFile,
handler: onEditFile,
disabled: false,
},
{
label: menuLabels.editRules,
handler: onEditRules,
disabled: !option?.rules,
},
{
label: menuLabels.editProxies,
handler: onEditProxies,
disabled: !option?.proxies,
},
{
label: menuLabels.editGroups,
handler: onEditGroups,
disabled: !option?.groups,
},
{
label: menuLabels.extendConfig,
handler: onEditMerge,
disabled: !option?.merge,
},
{
label: menuLabels.extendScript,
handler: onEditScript,
disabled: !option?.script,
},
{
label: menuLabels.openFile,
handler: onOpenFile,
disabled: false,
},
{
label: menuLabels.delete,
handler: () => {
setAnchorEl(null);
if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
if (onSelectionChange) {
onSelectionChange();
}
} else {
setConfirmOpen(true);
}
},
disabled: false,
},
];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
// 监听自动更新事件
useEffect(() => {
const handleUpdateStarted = (event: Event) => {
const customEvent = event as CustomEvent<{ uid?: string }>;
if (customEvent.detail?.uid === itemData.uid) {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
}
};
const handleUpdateCompleted = (event: Event) => {
const customEvent = event as CustomEvent<{ uid?: string }>;
if (customEvent.detail?.uid === itemData.uid) {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
// 更新完成后刷新显示
if (showNextUpdate) {
fetchNextUpdateTime();
}
}
};
// 注册事件监听
window.addEventListener("profile-update-started", handleUpdateStarted);
window.addEventListener("profile-update-completed", handleUpdateCompleted);
return () => {
// 清理事件监听
window.removeEventListener("profile-update-started", handleUpdateStarted);
window.removeEventListener(
"profile-update-completed",
handleUpdateCompleted,
);
};
}, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]);
return (
<Box
sx={{
position: "relative",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
}}
>
<ProfileBox
aria-selected={selected}
onClick={(e) => {
// 如果正在激活中,阻止重复点击
if (activating) {
e.preventDefault();
e.stopPropagation();
return;
}
onSelect(false);
}}
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget as HTMLElement);
event.preventDefault();
}}
>
{activating && (
<Box
sx={{
position: "absolute",
display: "flex",
justifyContent: "center",
alignItems: "center",
top: 10,
left: 10,
right: 10,
bottom: 2,
zIndex: 10,
backdropFilter: "blur(2px)",
backgroundColor: "rgba(0, 0, 0, 0.1)",
}}
>
<CircularProgress
color="inherit"
size={20}
sx={{
animation: "pulse 1.5s ease-in-out infinite",
}}
/>
</Box>
)}
<Box position="relative">
<Box sx={{ display: "flex", justifyContent: "start" }}>
{batchMode && (
<IconButton
size="small"
sx={{ padding: "2px", marginRight: "4px", marginLeft: "-8px" }}
onClick={(e) => {
e.stopPropagation();
if (onSelectionChange) {
onSelectionChange();
}
}}
>
{isSelected ? (
<CheckBoxRounded color="primary" />
) : (
<CheckBoxOutlineBlankRounded />
)}
</IconButton>
)}
<Box
ref={setNodeRef}
sx={{
display: "flex",
margin: "auto 0",
...(batchMode && { marginLeft: "-4px" }),
}}
{...attributes}
{...listeners}
>
<DragIndicatorRounded
sx={[
{ cursor: "move", marginLeft: "-6px" },
({ palette: { text } }) => {
return { color: text.primary };
},
]}
/>
</Box>
<Typography
width={batchMode ? "calc(100% - 56px)" : "calc(100% - 36px)"}
sx={{ fontSize: "18px", fontWeight: "600", lineHeight: "26px" }}
variant="h6"
component="h2"
noWrap
title={name}
>
{name}
</Typography>
</Box>
{/* only if has url can it be updated */}
{hasUrl && (
<IconButton
title={t("shared.actions.refresh")}
sx={{
position: "absolute",
p: "3px",
top: -1,
right: -5,
animation: loading ? `1s linear infinite ${round}` : "none",
}}
size="small"
color="inherit"
disabled={loading}
onClick={(e) => {
e.stopPropagation();
// 如果正在激活或加载中,阻止更新操作
if (activating || loading) {
return;
}
onUpdate(1);
}}
>
<RefreshRounded color="inherit" />
</IconButton>
)}
</Box>
{/* the second line show url's info or description */}
<Box sx={boxStyle}>
{
<>
{description ? (
<Typography
noWrap
title={description}
sx={{ fontSize: "14px" }}
>
{description}
</Typography>
) : (
hasUrl && (
<Typography
noWrap
title={`${t("shared.labels.from")} ${from}`}
>
{from}
</Typography>
)
)}
{hasUrl && (
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
ml: "auto",
}}
>
<Typography
noWrap
component="span"
fontSize={14}
textAlign="right"
title={
showNextUpdate
? t("profiles.components.profileItem.tooltips.showLast")
: `${t("shared.labels.updateTime")}: ${parseExpire(updated)}\n${t("profiles.components.profileItem.tooltips.showNext")}`
}
sx={{
cursor: "pointer",
display: "inline-block",
borderBottom: "1px dashed transparent",
transition: "all 0.2s",
"&:hover": {
borderBottomColor: "primary.main",
color: "primary.main",
},
}}
onClick={toggleUpdateTimeDisplay}
>
{showNextUpdate
? nextUpdateTime
: updated > 0
? dayjs(updated * 1000).fromNow()
: ""}
</Typography>
</Box>
)}
</>
}
</Box>
{/* the third line show extra info or last updated time */}
{hasExtra ? (
<Box sx={{ ...boxStyle, fontSize: 14 }}>
<span title={t("shared.labels.usedTotal")}>
{parseTraffic(upload + download)} / {parseTraffic(total)}
</span>
<span title={t("shared.labels.expireTime")}>{expire}</span>
</Box>
) : (
<Box sx={{ ...boxStyle, fontSize: 12, justifyContent: "flex-end" }}>
<span title={t("shared.labels.updateTime")}>
{parseExpire(updated)}
</span>
</Box>
)}
<LinearProgress
variant="determinate"
value={progress}
style={{ opacity: total > 0 ? 1 : 0 }}
/>
</ProfileBox>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{(hasUrl ? urlModeMenu : fileModeMenu).map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
disabled={item.disabled}
sx={[
{
minWidth: 120,
},
(theme) => {
return {
color:
item.label === menuLabels.delete
? theme.palette.error.main
: undefined,
};
},
]}
dense
>
{t(item.label)}
</MenuItem>
))}
</Menu>
{fileOpen && (
<EditorViewer
open={true}
initialData={readProfileFile(uid)}
language="yaml"
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(uid, curr ?? "");
onSave?.(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>
)}
{rulesOpen && (
<RulesEditorViewer
groupsUid={option?.groups ?? ""}
mergeUid={option?.merge ?? ""}
profileUid={uid}
property={option?.rules ?? ""}
open={true}
onSave={onSave}
onClose={() => setRulesOpen(false)}
/>
)}
{proxiesOpen && (
<ProxiesEditorViewer
profileUid={uid}
property={option?.proxies ?? ""}
open={true}
onSave={onSave}
onClose={() => setProxiesOpen(false)}
/>
)}
{groupsOpen && (
<GroupsEditorViewer
mergeUid={option?.merge ?? ""}
proxiesUid={option?.proxies ?? ""}
profileUid={uid}
property={option?.groups ?? ""}
open={true}
onSave={onSave}
onClose={() => {
setGroupsOpen(false);
}}
/>
)}
{mergeOpen && (
<EditorViewer
open={true}
initialData={readProfileFile(option?.merge ?? "")}
language="yaml"
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(option?.merge ?? "", curr ?? "");
onSave?.(prev, curr);
}}
onClose={() => setMergeOpen(false)}
/>
)}
{scriptOpen && (
<EditorViewer
open={true}
initialData={readProfileFile(option?.script ?? "")}
language="javascript"
onSave={async (prev, curr) => {
await saveProfileFile(option?.script ?? "", curr ?? "");
onSave?.(prev, curr);
}}
onClose={() => setScriptOpen(false)}
/>
)}
<ConfirmViewer
title={t("profiles.modals.confirmDelete.title")}
message={t("profiles.modals.confirmDelete.message")}
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => {
onDelete();
setConfirmOpen(false);
}}
/>
</Box>
);
};
function parseUrl(url?: string) {
if (!url) return "";
const regex = /https?:\/\/(.+?)\//;
const result = url.match(regex);
return result ? result[1] : "local file";
}
function parseExpire(expire?: number) {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
}