diff --git a/src/components/profile/enhanced.tsx b/src/components/profile/enhanced.tsx deleted file mode 100644 index 6c20eb8..0000000 --- a/src/components/profile/enhanced.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import useSWR from "swr"; -import { useLockFn } from "ahooks"; -import { Grid } from "@mui/material"; -import { - getProfiles, - deleteProfile, - patchProfilesConfig, - getRuntimeLogs, -} from "@/services/cmds"; -import { Notice } from "@/components/base"; -import { ProfileMore } from "./profile-more"; - -interface Props { - items: IProfileItem[]; - chain: string[]; -} - -export const EnhancedMode = (props: Props) => { - const { items, chain } = props; - - const { mutate: mutateProfiles } = useSWR("getProfiles", getProfiles); - const { data: chainLogs = {}, mutate: mutateLogs } = useSWR( - "getRuntimeLogs", - getRuntimeLogs - ); - - const onEnhanceEnable = useLockFn(async (uid: string) => { - if (chain.includes(uid)) return; - - const newChain = [...chain, uid]; - await patchProfilesConfig({ chain: newChain }); - mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); - mutateLogs(); - }); - - const onEnhanceDisable = useLockFn(async (uid: string) => { - if (!chain.includes(uid)) return; - - const newChain = chain.filter((i) => i !== uid); - await patchProfilesConfig({ chain: newChain }); - mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); - mutateLogs(); - }); - - const onEnhanceDelete = useLockFn(async (uid: string) => { - try { - await onEnhanceDisable(uid); - await deleteProfile(uid); - mutateProfiles(); - mutateLogs(); - } catch (err: any) { - Notice.error(err?.message || err.toString()); - } - }); - - const onMoveTop = useLockFn(async (uid: string) => { - if (!chain.includes(uid)) return; - - const newChain = [uid].concat(chain.filter((i) => i !== uid)); - await patchProfilesConfig({ chain: newChain }); - mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); - mutateLogs(); - }); - - const onMoveEnd = useLockFn(async (uid: string) => { - if (!chain.includes(uid)) return; - - const newChain = chain.filter((i) => i !== uid).concat([uid]); - await patchProfilesConfig({ chain: newChain }); - mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); - mutateLogs(); - }); - - return ( - - {items.map((item) => ( - - onEnhanceEnable(item.uid)} - onDisable={() => onEnhanceDisable(item.uid)} - onDelete={() => onEnhanceDelete(item.uid)} - onMoveTop={() => onMoveTop(item.uid)} - onMoveEnd={() => onMoveEnd(item.uid)} - /> - - ))} - - ); -}; diff --git a/src/components/profile/info-viewer.tsx b/src/components/profile/info-viewer.tsx deleted file mode 100644 index ffeb3af..0000000 --- a/src/components/profile/info-viewer.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { mutate } from "swr"; -import { useEffect, useState } from "react"; -import { useLockFn, useSetState } from "ahooks"; -import { useTranslation } from "react-i18next"; -import { - Button, - Collapse, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - IconButton, - Switch, - TextField, -} from "@mui/material"; -import { Settings } from "@mui/icons-material"; -import { patchProfile } from "@/services/cmds"; -import { version } from "@root/package.json"; -import { Notice } from "@/components/base"; - -interface Props { - open: boolean; - itemData: IProfileItem; - onClose: () => void; -} - -// edit the profile item -// remote / local file / merge / script -export const InfoViewer = (props: Props) => { - const { open, itemData, onClose } = props; - - const { t } = useTranslation(); - const [form, setForm] = useSetState({ ...itemData }); - const [option, setOption] = useSetState(itemData.option ?? {}); - const [showOpt, setShowOpt] = useState(!!itemData.option); - - useEffect(() => { - if (itemData) { - const { option } = itemData; - setForm({ ...itemData }); - setOption(option ?? {}); - setShowOpt( - itemData.type === "remote" && - (!!option?.user_agent || - !!option?.update_interval || - !!option?.self_proxy || - !!option?.with_proxy) - ); - } - }, [itemData]); - - const onUpdate = useLockFn(async () => { - try { - const { uid } = itemData; - const { name, desc, url } = form; - const option_ = - itemData.type === "remote" || itemData.type === "local" - ? option - : undefined; - - if (itemData.type === "remote" && !url) { - throw new Error("Remote URL should not be null"); - } - - await patchProfile(uid, { uid, name, desc, url, option: option_ }); - mutate("getProfiles"); - onClose(); - } catch (err: any) { - Notice.error(err?.message || err.toString()); - } - }); - - const textFieldProps = { - fullWidth: true, - size: "small", - margin: "normal", - variant: "outlined", - } as const; - - const type = - form.type || - (form.url ? "remote" : form.file?.endsWith(".js") ? "script" : "local"); - - return ( - - {t("Edit Info")} - - - - - setForm({ name: e.target.value })} - onKeyDown={(e) => e.key === "Enter" && onUpdate()} - /> - - setForm({ desc: e.target.value })} - onKeyDown={(e) => e.key === "Enter" && onUpdate()} - /> - - {type === "remote" && ( - setForm({ url: e.target.value })} - onKeyDown={(e) => e.key === "Enter" && onUpdate()} - /> - )} - - {(type === "remote" || type === "local") && ( - { - const str = e.target.value?.replace(/\D/, ""); - setOption({ update_interval: !!str ? +str : undefined }); - }} - onKeyDown={(e) => e.key === "Enter" && onUpdate()} - /> - )} - - - setOption({ user_agent: e.target.value })} - onKeyDown={(e) => e.key === "Enter" && onUpdate()} - /> - - - setOption((o) => ({ - self_proxy: c ? false : o.self_proxy ?? false, - with_proxy: c, - })) - } - /> - } - /> - - - setOption((o) => ({ - with_proxy: c ? false : o.with_proxy ?? false, - self_proxy: c, - })) - } - /> - } - /> - - - - - {form.type === "remote" && ( - setShowOpt((o) => !o)} - > - - - )} - - - - - - ); -}; diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 2840017..b370307 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -17,7 +17,6 @@ import { RefreshRounded } from "@mui/icons-material"; import { atomLoadingCache } from "@/services/states"; import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds"; import { Notice } from "@/components/base"; -import { InfoViewer } from "./info-viewer"; import { EditorViewer } from "./editor-viewer"; import { ProfileBox } from "./profile-box"; import parseTraffic from "@/utils/parse-traffic"; @@ -31,10 +30,11 @@ interface Props { selected: boolean; itemData: IProfileItem; onSelect: (force: boolean) => void; + onEdit: () => void; } export const ProfileItem = (props: Props) => { - const { selected, itemData, onSelect } = props; + const { selected, itemData, onSelect, onEdit } = props; const { t } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); @@ -55,7 +55,7 @@ export const ProfileItem = (props: Props) => { const loading = loadingCache[itemData.uid] ?? false; - // interval update from now field + // interval update fromNow field const [, setRefresh] = useState({}); useEffect(() => { if (!hasUrl) return; @@ -83,12 +83,11 @@ export const ProfileItem = (props: Props) => { }; }, [hasUrl, updated]); - const [editOpen, setEditOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false); const onEditInfo = () => { setAnchorEl(null); - setEditOpen(true); + onEdit(); }; const onEditFile = () => { @@ -298,12 +297,6 @@ export const ProfileItem = (props: Props) => { ))} - setEditOpen(false)} - /> - void; onMoveEnd: () => void; onDelete: () => void; + onEdit: () => void; } // profile enhanced item @@ -43,19 +43,19 @@ export const ProfileMore = (props: Props) => { onMoveTop, onMoveEnd, onDelete, + onEdit, } = props; const { uid, type } = itemData; const { t, i18n } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); const [position, setPosition] = useState({ left: 0, top: 0 }); - const [editOpen, setEditOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false); const [logOpen, setLogOpen] = useState(false); const onEditInfo = () => { setAnchorEl(null); - setEditOpen(true); + onEdit(); }; const onEditFile = () => { @@ -219,12 +219,6 @@ export const ProfileMore = (props: Props) => { ))} - setEditOpen(false)} - /> - void; -} - -// create a new profile -// remote / local file / merge / script -export const ProfileNew = (props: Props) => { - const { open, onClose } = props; - - const { t } = useTranslation(); - const [form, setForm] = useSetState({ - type: "remote", - name: "", - desc: "", - url: "", - }); - - const [showOpt, setShowOpt] = useState(false); - // can add more option - const [option, setOption] = useSetState({ - user_agent: "", - with_proxy: false, - self_proxy: false, - }); - // file input - const fileDataRef = useRef(null); - - const onCreate = useLockFn(async () => { - if (!form.type) { - Notice.error("`Type` should not be null"); - return; - } - - try { - const name = form.name || `${form.type} file`; - - if (form.type === "remote" && !form.url) { - throw new Error("The URL should not be null"); - } - - const option_ = form.type === "remote" ? option : undefined; - const item = { ...form, name, option: option_ }; - const fileData = form.type === "local" ? fileDataRef.current : null; - - await createProfile(item, fileData); - - setForm({ type: "remote", name: "", desc: "", url: "" }); - setOption({ user_agent: "" }); - setShowOpt(false); - fileDataRef.current = null; - - mutate("getProfiles"); - onClose(); - } catch (err: any) { - Notice.error(err.message || err.toString()); - } - }); - - const textFieldProps = { - fullWidth: true, - size: "small", - margin: "normal", - variant: "outlined", - } as const; - - return ( - - {t("Create Profile")} - - - - Type - - - - setForm({ name: e.target.value })} - /> - - setForm({ desc: e.target.value })} - /> - - {form.type === "remote" && ( - setForm({ url: e.target.value })} - /> - )} - - {form.type === "local" && ( - (fileDataRef.current = val)} /> - )} - - - setOption({ user_agent: e.target.value })} - /> - - setOption((o) => ({ - self_proxy: c ? false : o.self_proxy, - with_proxy: c, - })) - } - /> - } - /> - - setOption((o) => ({ - with_proxy: c ? false : o.with_proxy, - self_proxy: c, - })) - } - /> - } - /> - - - - - {form.type === "remote" && ( - setShowOpt((o) => !o)} - > - - - )} - - - - - - ); -}; diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx new file mode 100644 index 0000000..563dbca --- /dev/null +++ b/src/components/profile/profile-viewer.tsx @@ -0,0 +1,274 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { useLockFn } from "ahooks"; +import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { + Box, + FormControl, + InputAdornment, + InputLabel, + MenuItem, + Select, + Switch, + styled, + TextField, +} from "@mui/material"; +import { createProfile, patchProfile } from "@/services/cmds"; +import { BaseDialog, Notice } from "@/components/base"; +import { version } from "@root/package.json"; +import { FileInput } from "./file-input"; + +interface Props { + onChange: () => void; +} + +export interface ProfileViewerRef { + create: () => void; + edit: (item: IProfileItem) => void; +} + +// create or edit the profile +// remote / local / merge / script +export const ProfileViewer = forwardRef( + (props, ref) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [openType, setOpenType] = useState<"new" | "edit">("new"); + + // file input + const fileDataRef = useRef(null); + + const { control, watch, register, ...formIns } = useForm({ + defaultValues: { + type: "remote", + name: "Remote File", + desc: "", + url: "", + option: { + // user_agent: "", + with_proxy: false, + self_proxy: false, + }, + }, + }); + + useImperativeHandle(ref, () => ({ + create: () => { + setOpenType("new"); + setOpen(true); + }, + edit: (item) => { + if (item) { + Object.entries(item).forEach(([key, value]) => { + formIns.setValue(key as any, value); + }); + } + setOpenType("edit"); + setOpen(true); + }, + })); + + const selfProxy = watch("option.self_proxy"); + const withProxy = watch("option.with_proxy"); + + useEffect(() => { + if (selfProxy) formIns.setValue("option.with_proxy", false); + }, [selfProxy]); + + useEffect(() => { + if (withProxy) formIns.setValue("option.self_proxy", false); + }, [withProxy]); + + const handleOk = useLockFn( + formIns.handleSubmit(async (form) => { + try { + if (!form.type) throw new Error("`Type` should not be null"); + if (form.type === "remote" && !form.url) { + throw new Error("The URL should not be null"); + } + if (form.type !== "remote" && form.type !== "local") { + delete form.option; + } + if (form.option?.update_interval) { + form.option.update_interval = +form.option.update_interval; + } + const name = form.name || `${form.type} file`; + const item = { ...form, name }; + + // 创建 + if (openType === "new") { + await createProfile(item, fileDataRef.current); + } + // 编辑 + else { + if (!form.uid) throw new Error("UID not found"); + await patchProfile(form.uid, item); + } + setOpen(false); + setTimeout(() => formIns.reset(), 500); + fileDataRef.current = null; + props.onChange(); + } catch (err: any) { + Notice.error(err.message); + } + }) + ); + + const handleClose = () => { + setOpen(false); + fileDataRef.current = null; + setTimeout(() => formIns.reset(), 500); + }; + + const text = { + fullWidth: true, + size: "small", + margin: "normal", + variant: "outlined", + autoComplete: "off", + autoCorrect: "off", + } as const; + + const formType = watch("type"); + const isRemote = formType === "remote"; + const isLocal = formType === "local"; + + return ( + + ( + + {t("Type")} + + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + {isRemote && ( + <> + ( + + )} + /> + + ( + + )} + /> + + )} + + {(isRemote || isLocal) && ( + ( + { + e.target.value = e.target.value + ?.replace(/\D/, "") + .slice(0, 10); + field.onChange(e); + }} + label={t("Update Interval")} + InputProps={{ + endAdornment: ( + mins + ), + }} + /> + )} + /> + )} + + {isLocal && openType === "new" && ( + (fileDataRef.current = val)} /> + )} + + {isRemote && ( + <> + ( + + {t("Use System Proxy")} + + + )} + /> + + ( + + {t("Use Clash Proxy")} + + + )} + /> + + )} + + ); + } +); + +const StyledBox = styled(Box)(() => ({ + margin: "8px 0 8px 8px", + display: "flex", + alignItems: "center", + justifyContent: "space-between", +})); diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 17b2886..9b6c5fe 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -6,17 +6,20 @@ import { } from "@/services/cmds"; export const useProfiles = () => { - const { data: profiles, mutate } = useSWR("getProfiles", getProfiles); + const { data: profiles, mutate: mutateProfiles } = useSWR( + "getProfiles", + getProfiles + ); const patchProfiles = async (value: Partial) => { await patchProfilesConfig(value); - mutate(); + mutateProfiles(); }; const patchCurrent = async (value: Partial) => { if (profiles?.current) { await patchProfile(profiles.current, value); - mutate(); + mutateProfiles(); } }; @@ -25,5 +28,6 @@ export const useProfiles = () => { current: profiles?.items?.find((p) => p.uid === profiles.current), patchProfiles, patchCurrent, + mutateProfiles, }; }; diff --git a/src/locales/en.json b/src/locales/en.json index e6e7774..9c139c9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -50,7 +50,7 @@ "Name": "Name", "Descriptions": "Descriptions", "Subscription URL": "Subscription URL", - "Update Interval(mins)": "Update Interval(mins)", + "Update Interval": "Update Interval", "Settings": "Settings", "Clash Setting": "Clash Setting", diff --git a/src/locales/zh.json b/src/locales/zh.json index eb9d627..d836e5c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -50,7 +50,9 @@ "Name": "名称", "Descriptions": "描述", "Subscription URL": "订阅链接", - "Update Interval(mins)": "更新间隔(分钟)", + "Update Interval": "更新间隔", + "Use System Proxy": "使用系统代理更新", + "Use Clash Proxy": "使用Clash代理更新", "Settings": "设置", "Clash Setting": "Clash 设置", diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 8e618f0..ff4ab8b 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -1,6 +1,6 @@ import useSWR, { mutate } from "swr"; import { useLockFn } from "ahooks"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSetRecoilState } from "recoil"; import { Box, Button, Grid, IconButton, Stack, TextField } from "@mui/material"; import { CachedRounded } from "@mui/icons-material"; @@ -8,27 +8,39 @@ import { useTranslation } from "react-i18next"; import { getProfiles, patchProfile, - patchProfilesConfig, importProfile, enhanceProfiles, + getRuntimeLogs, + deleteProfile, } from "@/services/cmds"; import { closeAllConnections, getProxies, updateProxy } from "@/services/api"; import { atomCurrentProfile } from "@/services/states"; import { BasePage, Notice } from "@/components/base"; -import { ProfileNew } from "@/components/profile/profile-new"; +import { + ProfileViewer, + ProfileViewerRef, +} from "@/components/profile/profile-viewer"; import { ProfileItem } from "@/components/profile/profile-item"; -import { EnhancedMode } from "@/components/profile/enhanced"; +import { ProfileMore } from "@/components/profile/profile-more"; +import { useProfiles } from "@/hooks/use-profiles"; const ProfilePage = () => { const { t } = useTranslation(); const [url, setUrl] = useState(""); const [disabled, setDisabled] = useState(false); - const [dialogOpen, setDialogOpen] = useState(false); const setCurrentProfile = useSetRecoilState(atomCurrentProfile); - const { data: profiles = {} } = useSWR("getProfiles", getProfiles); + const { profiles = {}, patchProfiles, mutateProfiles } = useProfiles(); + + const { data: chainLogs = {}, mutate: mutateLogs } = useSWR( + "getRuntimeLogs", + getRuntimeLogs + ); + + const chain = profiles.chain || []; + const viewerRef = useRef(null); // distinguish type const { regularItems, enhanceItems } = useMemo(() => { @@ -40,9 +52,7 @@ const ProfilePage = () => { const regularItems = items.filter((i) => type1.includes(i.type!)); const restItems = items.filter((i) => type2.includes(i.type!)); - const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i])); - const enhanceItems = chain .map((i) => restMap[i]!) .concat(restItems.filter((i) => !chain.includes(i.uid))); @@ -75,8 +85,9 @@ const ProfilePage = () => { const { global, groups } = proxiesData; [global, ...groups].forEach((group) => { - const { name, now } = group; + const { type, name, now } = group; + if (type !== "Selector" && type !== "Fallback") return; if (!now || selectedMap[name] === now) return; if (selectedMap[name] == null) { selectedMap[name] = now!; @@ -114,13 +125,13 @@ const ProfilePage = () => { if (!newProfiles.current && remoteItem) { const current = remoteItem.uid; - patchProfilesConfig({ current }); - mutate("getProfiles", { ...newProfiles, current }, true); - mutate("getRuntimeLogs"); + patchProfiles({ current }); + mutateProfiles(); + mutateLogs(); } }); - } catch { - Notice.error("Failed to import profile."); + } catch (err: any) { + Notice.error(err.message || err.toString()); } finally { setDisabled(false); } @@ -128,12 +139,10 @@ const ProfilePage = () => { const onSelect = useLockFn(async (current: string, force: boolean) => { if (!force && current === profiles.current) return; - try { - await patchProfilesConfig({ current }); + await patchProfiles({ current }); setCurrentProfile(current); - mutate("getProfiles", { ...profiles, current: current }, true); - mutate("getRuntimeLogs"); + mutateLogs(); closeAllConnections(); Notice.success("Refresh clash config", 1000); } catch (err: any) { @@ -144,13 +153,52 @@ const ProfilePage = () => { const onEnhance = useLockFn(async () => { try { await enhanceProfiles(); - mutate("getRuntimeLogs"); - // Notice.success("Refresh clash config", 1000); + mutateLogs(); + Notice.success("Refresh clash config", 1000); } catch (err: any) { Notice.error(err.message || err.toString(), 3000); } }); + const onEnable = useLockFn(async (uid: string) => { + if (chain.includes(uid)) return; + const newChain = [...chain, uid]; + await patchProfiles({ chain: newChain }); + mutateLogs(); + }); + + const onDisable = useLockFn(async (uid: string) => { + if (!chain.includes(uid)) return; + const newChain = chain.filter((i) => i !== uid); + await patchProfiles({ chain: newChain }); + mutateLogs(); + }); + + const onDelete = useLockFn(async (uid: string) => { + try { + await onDisable(uid); + await deleteProfile(uid); + mutateProfiles(); + mutateLogs(); + } catch (err: any) { + Notice.error(err?.message || err.toString()); + } + }); + + const onMoveTop = useLockFn(async (uid: string) => { + if (!chain.includes(uid)) return; + const newChain = [uid].concat(chain.filter((i) => i !== uid)); + await patchProfiles({ chain: newChain }); + mutateLogs(); + }); + + const onMoveEnd = useLockFn(async (uid: string) => { + if (!chain.includes(uid)) return; + const newChain = chain.filter((i) => i !== uid).concat([uid]); + await patchProfiles({ chain: newChain }); + mutateLogs(); + }); + return ( { @@ -205,6 +253,7 @@ const ProfilePage = () => { selected={profiles.current === item.uid} itemData={item} onSelect={(f) => onSelect(item.uid, f)} + onEdit={() => viewerRef.current?.edit(item)} /> ))} @@ -212,10 +261,27 @@ const ProfilePage = () => { {enhanceItems.length > 0 && ( - + + {enhanceItems.map((item) => ( + + onEnable(item.uid)} + onDisable={() => onDisable(item.uid)} + onDelete={() => onDelete(item.uid)} + onMoveTop={() => onMoveTop(item.uid)} + onMoveEnd={() => onMoveEnd(item.uid)} + onEdit={() => viewerRef.current?.edit(item)} + /> + + ))} + )} - setDialogOpen(false)} /> + mutateProfiles()} /> ); }; diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 17fb5bf..35fb437 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -91,8 +91,6 @@ interface IConnections { * Some interface for command */ -type IProfileType = "local" | "remote" | "merge" | "script"; - interface IClashInfo { // status: string; port?: number; // clash mixed port @@ -102,7 +100,7 @@ interface IClashInfo { interface IProfileItem { uid: string; - type?: IProfileType | string; + type?: "local" | "remote" | "merge" | "script"; name?: string; desc?: string; file?: string;