clash-verge-rev/src/pages/profiles.tsx
2024-09-11 08:15:03 +08:00

438 lines
12 KiB
TypeScript

import useSWR, { mutate } from "swr";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { Box, Button, Grid, IconButton, Stack, Divider } from "@mui/material";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { LoadingButton } from "@mui/lab";
import {
ClearRounded,
ContentPasteRounded,
LocalFireDepartmentRounded,
RefreshRounded,
TextSnippetOutlined,
} from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import {
getProfiles,
importProfile,
enhanceProfiles,
getRuntimeLogs,
deleteProfile,
updateProfile,
reorderProfile,
createProfile,
} from "@/services/cmds";
import { useSetLoadingCache, useThemeMode } from "@/services/states";
import { closeAllConnections } from "@/services/api";
import { BasePage, DialogRef, Notice } from "@/components/base";
import {
ProfileViewer,
ProfileViewerRef,
} from "@/components/profile/profile-viewer";
import { ProfileMore } from "@/components/profile/profile-more";
import { ProfileItem } from "@/components/profile/profile-item";
import { useProfiles } from "@/hooks/use-profiles";
import { ConfigViewer } from "@/components/setting/mods/config-viewer";
import { throttle } from "lodash-es";
import { BaseStyledTextField } from "@/components/base/base-styled-text-field";
import { listen } from "@tauri-apps/api/event";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
const ProfilePage = () => {
const { t } = useTranslation();
const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false);
const [activatings, setActivatings] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
useEffect(() => {
const unlisten = listen("tauri://file-drop", async (event) => {
const fileList = event.payload as string[];
for (let file of fileList) {
if (!file.endsWith(".yaml") && !file.endsWith(".yml")) {
Notice.error(t("Only YAML Files Supported"));
continue;
}
const item = {
type: "local",
name: file.split(/\/|\\/).pop() ?? "New Profile",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
},
} as IProfileItem;
let data = await readTextFile(file);
await createProfile(item, data);
await mutateProfiles();
}
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const {
profiles = {},
activateSelected,
patchProfiles,
mutateProfiles,
} = useProfiles();
const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
"getRuntimeLogs",
getRuntimeLogs
);
const viewerRef = useRef<ProfileViewerRef>(null);
const configRef = useRef<DialogRef>(null);
// distinguish type
const profileItems = useMemo(() => {
const items = profiles.items || [];
const type1 = ["local", "remote"];
const profileItems = items.filter((i) => i && type1.includes(i.type!));
return profileItems;
}, [profiles]);
const currentActivatings = () => {
return [...new Set([profiles.current ?? ""])].filter(Boolean);
};
const onImport = async () => {
if (!url) return;
setLoading(true);
try {
await importProfile(url);
Notice.success(t("Profile Imported Successfully"));
setUrl("");
setLoading(false);
getProfiles().then(async (newProfiles) => {
mutate("getProfiles", newProfiles);
const remoteItem = newProfiles.items?.find((e) => e.type === "remote");
const profilesCount = newProfiles.items?.filter(
(e) => e.type === "remote" || e.type === "local"
).length as number;
if (remoteItem && (profilesCount == 1 || !newProfiles.current)) {
const current = remoteItem.uid;
await patchProfiles({ current });
mutateLogs();
setTimeout(() => activateSelected(), 2000);
}
});
} catch (err: any) {
Notice.error(err.message || err.toString());
setLoading(false);
} finally {
setDisabled(false);
setLoading(false);
}
};
const onDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
await reorderProfile(active.id.toString(), over.id.toString());
mutateProfiles();
}
}
};
const onSelect = useLockFn(async (current: string, force: boolean) => {
if (!force && current === profiles.current) return;
// 避免大多数情况下loading态闪烁
const reset = setTimeout(() => {
setActivatings([...currentActivatings(), current]);
}, 100);
try {
await patchProfiles({ current });
await mutateLogs();
closeAllConnections();
activateSelected().then(() => {
Notice.success(t("Profile Switched"), 1000);
});
} catch (err: any) {
Notice.error(err?.message || err.toString(), 4000);
} finally {
clearTimeout(reset);
setActivatings([]);
}
});
const onEnhance = useLockFn(async () => {
setActivatings(currentActivatings());
try {
await enhanceProfiles();
mutateLogs();
Notice.success(t("Profile Reactivated"), 1000);
} catch (err: any) {
Notice.error(err.message || err.toString(), 3000);
} finally {
setActivatings([]);
}
});
const onDelete = useLockFn(async (uid: string) => {
const current = profiles.current === uid;
try {
setActivatings([...(current ? currentActivatings() : []), uid]);
await deleteProfile(uid);
mutateProfiles();
mutateLogs();
current && (await onEnhance());
} catch (err: any) {
Notice.error(err?.message || err.toString());
} finally {
setActivatings([]);
}
});
// 更新所有订阅
const setLoadingCache = useSetLoadingCache();
const onUpdateAll = useLockFn(async () => {
const throttleMutate = throttle(mutateProfiles, 2000, {
trailing: true,
});
const updateOne = async (uid: string) => {
try {
await updateProfile(uid);
throttleMutate();
} finally {
setLoadingCache((cache) => ({ ...cache, [uid]: false }));
}
};
return new Promise((resolve) => {
setLoadingCache((cache) => {
// 获取没有正在更新的订阅
const items = profileItems.filter(
(e) => e.type === "remote" && !cache[e.uid]
);
const change = Object.fromEntries(items.map((e) => [e.uid, true]));
Promise.allSettled(items.map((e) => updateOne(e.uid))).then(resolve);
return { ...cache, ...change };
});
});
});
const onCopyLink = async () => {
const text = await readText();
if (text) setUrl(text);
};
const mode = useThemeMode();
const islight = mode === "light" ? true : false;
const dividercolor = islight
? "rgba(0, 0, 0, 0.06)"
: "rgba(255, 255, 255, 0.06)";
return (
<BasePage
full
title={t("Profiles")}
contentStyle={{ height: "100%" }}
header={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
size="small"
color="inherit"
title={t("Update All Profiles")}
onClick={onUpdateAll}
>
<RefreshRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("View Runtime Config")}
onClick={() => configRef.current?.open()}
>
<TextSnippetOutlined />
</IconButton>
<IconButton
size="small"
color="primary"
title={t("Reactivate Profiles")}
onClick={onEnhance}
>
<LocalFireDepartmentRounded />
</IconButton>
</Box>
}
>
<Stack
direction="row"
spacing={1}
sx={{
pt: 1,
mb: 0.5,
mx: "10px",
height: "36px",
display: "flex",
alignItems: "center",
}}
>
<BaseStyledTextField
value={url}
variant="outlined"
onChange={(e) => setUrl(e.target.value)}
placeholder={t("Profile URL")}
InputProps={{
sx: { pr: 1 },
endAdornment: !url ? (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t("Paste")}
onClick={onCopyLink}
>
<ContentPasteRounded fontSize="inherit" />
</IconButton>
) : (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t("Clear")}
onClick={() => setUrl("")}
>
<ClearRounded fontSize="inherit" />
</IconButton>
),
}}
/>
<LoadingButton
disabled={!url || disabled}
loading={loading}
variant="contained"
size="small"
sx={{ borderRadius: "6px" }}
onClick={onImport}
>
{t("Import")}
</LoadingButton>
<Button
variant="contained"
size="small"
sx={{ borderRadius: "6px" }}
onClick={() => viewerRef.current?.create()}
>
{t("New")}
</Button>
</Stack>
<Box
sx={{
pt: 1,
mb: 0.5,
pl: "10px",
mr: "10px",
height: "calc(100% - 68px)",
overflowY: "auto",
}}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<Box sx={{ mb: 1.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<SortableContext
items={profileItems.map((x) => {
return x.uid;
})}
>
{profileItems.map((item) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
<ProfileItem
id={item.uid}
selected={profiles.current === item.uid}
activating={activatings.includes(item.uid)}
itemData={item}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance();
}
}}
onDelete={() => onDelete(item.uid)}
/>
</Grid>
))}
</SortableContext>
</Grid>
</Box>
</DndContext>
<Divider
variant="middle"
flexItem
sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
></Divider>
<Box sx={{ mt: 1.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<Grid item xs={12} sm={6} md={6} lg={6}>
<ProfileMore
id="Merge"
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance();
}
}}
/>
</Grid>
<Grid item xs={12} sm={6} md={6} lg={6}>
<ProfileMore
id="Script"
logInfo={chainLogs["Script"]}
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance();
}
}}
/>
</Grid>
</Grid>
</Box>
</Box>
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
<ConfigViewer ref={configRef} />
</BasePage>
);
};
export default ProfilePage;