mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2024-11-16 03:32:36 +08:00
feat: profile page ui
This commit is contained in:
parent
17f724748f
commit
ab34044196
|
@ -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 (
|
||||
<Grid container spacing={{ xs: 2, lg: 3 }}>
|
||||
{items.map((item) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
|
||||
<ProfileMore
|
||||
selected={!!chain.includes(item.uid)}
|
||||
itemData={item}
|
||||
enableNum={chain.length}
|
||||
logInfo={chainLogs[item.uid]}
|
||||
onEnable={() => onEnhanceEnable(item.uid)}
|
||||
onDisable={() => onEnhanceDisable(item.uid)}
|
||||
onDelete={() => onEnhanceDelete(item.uid)}
|
||||
onMoveTop={() => onMoveTop(item.uid)}
|
||||
onMoveEnd={() => onMoveEnd(item.uid)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
|
@ -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 (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle sx={{ pb: 0.5 }}>{t("Edit Info")}</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ width: 336, pb: 1, userSelect: "text" }}>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
disabled
|
||||
label={t("Type")}
|
||||
value={type}
|
||||
sx={{ input: { textTransform: "capitalize" } }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
autoFocus
|
||||
label={t("Name")}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ name: e.target.value })}
|
||||
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label={t("Descriptions")}
|
||||
value={form.desc}
|
||||
onChange={(e) => setForm({ desc: e.target.value })}
|
||||
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
|
||||
/>
|
||||
|
||||
{type === "remote" && (
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label={t("Subscription URL")}
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ url: e.target.value })}
|
||||
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(type === "remote" || type === "local") && (
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label={t("Update Interval(mins)")}
|
||||
value={option.update_interval}
|
||||
onChange={(e) => {
|
||||
const str = e.target.value?.replace(/\D/, "");
|
||||
setOption({ update_interval: !!str ? +str : undefined });
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Collapse
|
||||
in={type === "remote" && showOpt}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label="User Agent"
|
||||
value={option.user_agent}
|
||||
placeholder={`clash-verge/v${version}`}
|
||||
onChange={(e) => setOption({ user_agent: e.target.value })}
|
||||
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
label={t("Use System Proxy")}
|
||||
labelPlacement="start"
|
||||
sx={{ ml: 0, my: 1 }}
|
||||
control={
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={option.with_proxy ?? false}
|
||||
onChange={(_e, c) =>
|
||||
setOption((o) => ({
|
||||
self_proxy: c ? false : o.self_proxy ?? false,
|
||||
with_proxy: c,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
label={t("Use Clash Proxy")}
|
||||
labelPlacement="start"
|
||||
sx={{ ml: 0, my: 1 }}
|
||||
control={
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={option.self_proxy ?? false}
|
||||
onChange={(_e, c) =>
|
||||
setOption((o) => ({
|
||||
with_proxy: c ? false : o.with_proxy ?? false,
|
||||
self_proxy: c,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Collapse>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 2, pb: 2, position: "relative" }}>
|
||||
{form.type === "remote" && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ position: "absolute", left: 18 }}
|
||||
onClick={() => setShowOpt((o) => !o)}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={onUpdate} variant="contained">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -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<any>(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) => {
|
|||
))}
|
||||
</Menu>
|
||||
|
||||
<InfoViewer
|
||||
open={editOpen}
|
||||
itemData={itemData}
|
||||
onClose={() => setEditOpen(false)}
|
||||
/>
|
||||
|
||||
<EditorViewer
|
||||
uid={uid}
|
||||
open={fileOpen}
|
||||
|
|
|
@ -14,7 +14,6 @@ import {
|
|||
import { FeaturedPlayListRounded } from "@mui/icons-material";
|
||||
import { 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 { LogViewer } from "./log-viewer";
|
||||
|
@ -29,6 +28,7 @@ interface Props {
|
|||
onMoveTop: () => 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<any>(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) => {
|
|||
))}
|
||||
</Menu>
|
||||
|
||||
<InfoViewer
|
||||
open={editOpen}
|
||||
itemData={itemData}
|
||||
onClose={() => setEditOpen(false)}
|
||||
/>
|
||||
|
||||
<EditorViewer
|
||||
uid={uid}
|
||||
open={fileOpen}
|
||||
|
|
|
@ -1,212 +0,0 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { mutate } from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLockFn, useSetState } from "ahooks";
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Switch,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { Settings } from "@mui/icons-material";
|
||||
import { createProfile } from "@/services/cmds";
|
||||
import { Notice } from "@/components/base";
|
||||
import { FileInput } from "./file-input";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => 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<string | null>(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 (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle sx={{ pb: 0.5 }}>{t("Create Profile")}</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ width: 336, pb: 1 }}>
|
||||
<FormControl size="small" fullWidth sx={{ mt: 2, mb: 1 }}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
autoFocus
|
||||
label={t("Type")}
|
||||
value={form.type}
|
||||
onChange={(e) => setForm({ type: e.target.value })}
|
||||
>
|
||||
<MenuItem value="remote">Remote</MenuItem>
|
||||
<MenuItem value="local">Local</MenuItem>
|
||||
<MenuItem value="script">Script</MenuItem>
|
||||
<MenuItem value="merge">Merge</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label={t("Name")}
|
||||
autoComplete="off"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ name: e.target.value })}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label={t("Descriptions")}
|
||||
autoComplete="off"
|
||||
value={form.desc}
|
||||
onChange={(e) => setForm({ desc: e.target.value })}
|
||||
/>
|
||||
|
||||
{form.type === "remote" && (
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label={t("Subscription URL")}
|
||||
autoComplete="off"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ url: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.type === "local" && (
|
||||
<FileInput onChange={(val) => (fileDataRef.current = val)} />
|
||||
)}
|
||||
|
||||
<Collapse
|
||||
in={form.type === "remote" && showOpt}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
label="User Agent"
|
||||
autoComplete="off"
|
||||
value={option.user_agent}
|
||||
onChange={(e) => setOption({ user_agent: e.target.value })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
label={t("Use System Proxy")}
|
||||
labelPlacement="start"
|
||||
sx={{ ml: 0, my: 1 }}
|
||||
control={
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={option.with_proxy}
|
||||
onChange={(_e, c) =>
|
||||
setOption((o) => ({
|
||||
self_proxy: c ? false : o.self_proxy,
|
||||
with_proxy: c,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
label={t("Use Clash Proxy")}
|
||||
labelPlacement="start"
|
||||
sx={{ ml: 0, my: 1 }}
|
||||
control={
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={option.self_proxy}
|
||||
onChange={(_e, c) =>
|
||||
setOption((o) => ({
|
||||
with_proxy: c ? false : o.with_proxy,
|
||||
self_proxy: c,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Collapse>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 2, pb: 2, position: "relative" }}>
|
||||
{form.type === "remote" && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ position: "absolute", left: 18 }}
|
||||
onClick={() => setShowOpt((o) => !o)}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={onCreate} variant="contained">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
274
src/components/profile/profile-viewer.tsx
Normal file
274
src/components/profile/profile-viewer.tsx
Normal file
|
@ -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<ProfileViewerRef, Props>(
|
||||
(props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openType, setOpenType] = useState<"new" | "edit">("new");
|
||||
|
||||
// file input
|
||||
const fileDataRef = useRef<string | null>(null);
|
||||
|
||||
const { control, watch, register, ...formIns } = useForm<IProfileItem>({
|
||||
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 (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={openType === "new" ? t("Create Profile") : t("Edit Profile")}
|
||||
contentSx={{ width: 375, pb: 0, maxHeight: 320 }}
|
||||
okBtn={t("Save")}
|
||||
cancelBtn={t("Cancel")}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
onOk={handleOk}
|
||||
>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl size="small" fullWidth sx={{ mt: 1, mb: 1 }}>
|
||||
<InputLabel>{t("Type")}</InputLabel>
|
||||
<Select {...field} autoFocus label={t("Type")}>
|
||||
<MenuItem value="remote">Remote</MenuItem>
|
||||
<MenuItem value="local">Local</MenuItem>
|
||||
<MenuItem value="script">Script</MenuItem>
|
||||
<MenuItem value="merge">Merge</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField {...text} {...field} label={t("Name")} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="desc"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField {...text} {...field} label={t("Descriptions")} />
|
||||
)}
|
||||
/>
|
||||
|
||||
{isRemote && (
|
||||
<>
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField {...text} {...field} label={t("Subscription URL")} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="option.user_agent"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...text}
|
||||
{...field}
|
||||
placeholder={`clash-verge/v${version}`}
|
||||
label="User Agent"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(isRemote || isLocal) && (
|
||||
<Controller
|
||||
name="option.update_interval"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...text}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value
|
||||
?.replace(/\D/, "")
|
||||
.slice(0, 10);
|
||||
field.onChange(e);
|
||||
}}
|
||||
label={t("Update Interval")}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">mins</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLocal && openType === "new" && (
|
||||
<FileInput onChange={(val) => (fileDataRef.current = val)} />
|
||||
)}
|
||||
|
||||
{isRemote && (
|
||||
<>
|
||||
<Controller
|
||||
name="option.with_proxy"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<StyledBox>
|
||||
<InputLabel>{t("Use System Proxy")}</InputLabel>
|
||||
<Switch checked={field.value} {...field} color="primary" />
|
||||
</StyledBox>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="option.self_proxy"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<StyledBox>
|
||||
<InputLabel>{t("Use Clash Proxy")}</InputLabel>
|
||||
<Switch checked={field.value} {...field} color="primary" />
|
||||
</StyledBox>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const StyledBox = styled(Box)(() => ({
|
||||
margin: "8px 0 8px 8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}));
|
|
@ -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<IProfilesConfig>) => {
|
||||
await patchProfilesConfig(value);
|
||||
mutate();
|
||||
mutateProfiles();
|
||||
};
|
||||
|
||||
const patchCurrent = async (value: Partial<IProfileItem>) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -50,7 +50,9 @@
|
|||
"Name": "名称",
|
||||
"Descriptions": "描述",
|
||||
"Subscription URL": "订阅链接",
|
||||
"Update Interval(mins)": "更新间隔(分钟)",
|
||||
"Update Interval": "更新间隔",
|
||||
"Use System Proxy": "使用系统代理更新",
|
||||
"Use Clash Proxy": "使用Clash代理更新",
|
||||
|
||||
"Settings": "设置",
|
||||
"Clash Setting": "Clash 设置",
|
||||
|
|
|
@ -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<ProfileViewerRef>(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 (
|
||||
<BasePage
|
||||
title={t("Profiles")}
|
||||
|
@ -191,7 +239,7 @@ const ProfilePage = () => {
|
|||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
onClick={viewerRef.current?.create}
|
||||
>
|
||||
{t("New")}
|
||||
</Button>
|
||||
|
@ -205,6 +253,7 @@ const ProfilePage = () => {
|
|||
selected={profiles.current === item.uid}
|
||||
itemData={item}
|
||||
onSelect={(f) => onSelect(item.uid, f)}
|
||||
onEdit={() => viewerRef.current?.edit(item)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
|
@ -212,10 +261,27 @@ const ProfilePage = () => {
|
|||
</Box>
|
||||
|
||||
{enhanceItems.length > 0 && (
|
||||
<EnhancedMode items={enhanceItems} chain={profiles.chain || []} />
|
||||
<Grid container spacing={{ xs: 2, lg: 3 }}>
|
||||
{enhanceItems.map((item) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
|
||||
<ProfileMore
|
||||
selected={!!chain.includes(item.uid)}
|
||||
itemData={item}
|
||||
enableNum={chain.length || 0}
|
||||
logInfo={chainLogs[item.uid]}
|
||||
onEnable={() => onEnable(item.uid)}
|
||||
onDisable={() => onDisable(item.uid)}
|
||||
onDelete={() => onDelete(item.uid)}
|
||||
onMoveTop={() => onMoveTop(item.uid)}
|
||||
onMoveEnd={() => onMoveEnd(item.uid)}
|
||||
onEdit={() => viewerRef.current?.edit(item)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<ProfileNew open={dialogOpen} onClose={() => setDialogOpen(false)} />
|
||||
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
|
4
src/services/types.d.ts
vendored
4
src/services/types.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user