feat: profile page ui

This commit is contained in:
GyDi 2022-11-28 22:29:58 +08:00
parent 17f724748f
commit ab34044196
No known key found for this signature in database
GPG Key ID: 9C3AD40F1F99880A
11 changed files with 382 additions and 567 deletions

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -17,7 +17,6 @@ import { RefreshRounded } from "@mui/icons-material";
import { atomLoadingCache } from "@/services/states"; import { atomLoadingCache } from "@/services/states";
import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds"; import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
import { Notice } from "@/components/base"; import { Notice } from "@/components/base";
import { InfoViewer } from "./info-viewer";
import { EditorViewer } from "./editor-viewer"; import { EditorViewer } from "./editor-viewer";
import { ProfileBox } from "./profile-box"; import { ProfileBox } from "./profile-box";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
@ -31,10 +30,11 @@ interface Props {
selected: boolean; selected: boolean;
itemData: IProfileItem; itemData: IProfileItem;
onSelect: (force: boolean) => void; onSelect: (force: boolean) => void;
onEdit: () => void;
} }
export const ProfileItem = (props: Props) => { export const ProfileItem = (props: Props) => {
const { selected, itemData, onSelect } = props; const { selected, itemData, onSelect, onEdit } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null); const [anchorEl, setAnchorEl] = useState<any>(null);
@ -55,7 +55,7 @@ export const ProfileItem = (props: Props) => {
const loading = loadingCache[itemData.uid] ?? false; const loading = loadingCache[itemData.uid] ?? false;
// interval update from now field // interval update fromNow field
const [, setRefresh] = useState({}); const [, setRefresh] = useState({});
useEffect(() => { useEffect(() => {
if (!hasUrl) return; if (!hasUrl) return;
@ -83,12 +83,11 @@ export const ProfileItem = (props: Props) => {
}; };
}, [hasUrl, updated]); }, [hasUrl, updated]);
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false);
const onEditInfo = () => { const onEditInfo = () => {
setAnchorEl(null); setAnchorEl(null);
setEditOpen(true); onEdit();
}; };
const onEditFile = () => { const onEditFile = () => {
@ -298,12 +297,6 @@ export const ProfileItem = (props: Props) => {
))} ))}
</Menu> </Menu>
<InfoViewer
open={editOpen}
itemData={itemData}
onClose={() => setEditOpen(false)}
/>
<EditorViewer <EditorViewer
uid={uid} uid={uid}
open={fileOpen} open={fileOpen}

View File

@ -14,7 +14,6 @@ import {
import { FeaturedPlayListRounded } from "@mui/icons-material"; import { FeaturedPlayListRounded } from "@mui/icons-material";
import { viewProfile } from "@/services/cmds"; import { viewProfile } from "@/services/cmds";
import { Notice } from "@/components/base"; import { Notice } from "@/components/base";
import { InfoViewer } from "./info-viewer";
import { EditorViewer } from "./editor-viewer"; import { EditorViewer } from "./editor-viewer";
import { ProfileBox } from "./profile-box"; import { ProfileBox } from "./profile-box";
import { LogViewer } from "./log-viewer"; import { LogViewer } from "./log-viewer";
@ -29,6 +28,7 @@ interface Props {
onMoveTop: () => void; onMoveTop: () => void;
onMoveEnd: () => void; onMoveEnd: () => void;
onDelete: () => void; onDelete: () => void;
onEdit: () => void;
} }
// profile enhanced item // profile enhanced item
@ -43,19 +43,19 @@ export const ProfileMore = (props: Props) => {
onMoveTop, onMoveTop,
onMoveEnd, onMoveEnd,
onDelete, onDelete,
onEdit,
} = props; } = props;
const { uid, type } = itemData; const { uid, type } = itemData;
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null); const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 }); const [position, setPosition] = useState({ left: 0, top: 0 });
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false);
const [logOpen, setLogOpen] = useState(false); const [logOpen, setLogOpen] = useState(false);
const onEditInfo = () => { const onEditInfo = () => {
setAnchorEl(null); setAnchorEl(null);
setEditOpen(true); onEdit();
}; };
const onEditFile = () => { const onEditFile = () => {
@ -219,12 +219,6 @@ export const ProfileMore = (props: Props) => {
))} ))}
</Menu> </Menu>
<InfoViewer
open={editOpen}
itemData={itemData}
onClose={() => setEditOpen(false)}
/>
<EditorViewer <EditorViewer
uid={uid} uid={uid}
open={fileOpen} open={fileOpen}

View File

@ -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>
);
};

View 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",
}));

View File

@ -6,17 +6,20 @@ import {
} from "@/services/cmds"; } from "@/services/cmds";
export const useProfiles = () => { export const useProfiles = () => {
const { data: profiles, mutate } = useSWR("getProfiles", getProfiles); const { data: profiles, mutate: mutateProfiles } = useSWR(
"getProfiles",
getProfiles
);
const patchProfiles = async (value: Partial<IProfilesConfig>) => { const patchProfiles = async (value: Partial<IProfilesConfig>) => {
await patchProfilesConfig(value); await patchProfilesConfig(value);
mutate(); mutateProfiles();
}; };
const patchCurrent = async (value: Partial<IProfileItem>) => { const patchCurrent = async (value: Partial<IProfileItem>) => {
if (profiles?.current) { if (profiles?.current) {
await patchProfile(profiles.current, value); await patchProfile(profiles.current, value);
mutate(); mutateProfiles();
} }
}; };
@ -25,5 +28,6 @@ export const useProfiles = () => {
current: profiles?.items?.find((p) => p.uid === profiles.current), current: profiles?.items?.find((p) => p.uid === profiles.current),
patchProfiles, patchProfiles,
patchCurrent, patchCurrent,
mutateProfiles,
}; };
}; };

View File

@ -50,7 +50,7 @@
"Name": "Name", "Name": "Name",
"Descriptions": "Descriptions", "Descriptions": "Descriptions",
"Subscription URL": "Subscription URL", "Subscription URL": "Subscription URL",
"Update Interval(mins)": "Update Interval(mins)", "Update Interval": "Update Interval",
"Settings": "Settings", "Settings": "Settings",
"Clash Setting": "Clash Setting", "Clash Setting": "Clash Setting",

View File

@ -50,7 +50,9 @@
"Name": "名称", "Name": "名称",
"Descriptions": "描述", "Descriptions": "描述",
"Subscription URL": "订阅链接", "Subscription URL": "订阅链接",
"Update Interval(mins)": "更新间隔(分钟)", "Update Interval": "更新间隔",
"Use System Proxy": "使用系统代理更新",
"Use Clash Proxy": "使用Clash代理更新",
"Settings": "设置", "Settings": "设置",
"Clash Setting": "Clash 设置", "Clash Setting": "Clash 设置",

View File

@ -1,6 +1,6 @@
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useSetRecoilState } from "recoil"; import { useSetRecoilState } from "recoil";
import { Box, Button, Grid, IconButton, Stack, TextField } from "@mui/material"; import { Box, Button, Grid, IconButton, Stack, TextField } from "@mui/material";
import { CachedRounded } from "@mui/icons-material"; import { CachedRounded } from "@mui/icons-material";
@ -8,27 +8,39 @@ import { useTranslation } from "react-i18next";
import { import {
getProfiles, getProfiles,
patchProfile, patchProfile,
patchProfilesConfig,
importProfile, importProfile,
enhanceProfiles, enhanceProfiles,
getRuntimeLogs,
deleteProfile,
} from "@/services/cmds"; } from "@/services/cmds";
import { closeAllConnections, getProxies, updateProxy } from "@/services/api"; import { closeAllConnections, getProxies, updateProxy } from "@/services/api";
import { atomCurrentProfile } from "@/services/states"; import { atomCurrentProfile } from "@/services/states";
import { BasePage, Notice } from "@/components/base"; 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 { 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 ProfilePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const setCurrentProfile = useSetRecoilState(atomCurrentProfile); 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 // distinguish type
const { regularItems, enhanceItems } = useMemo(() => { const { regularItems, enhanceItems } = useMemo(() => {
@ -40,9 +52,7 @@ const ProfilePage = () => {
const regularItems = items.filter((i) => type1.includes(i.type!)); const regularItems = items.filter((i) => type1.includes(i.type!));
const restItems = items.filter((i) => type2.includes(i.type!)); const restItems = items.filter((i) => type2.includes(i.type!));
const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i])); const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i]));
const enhanceItems = chain const enhanceItems = chain
.map((i) => restMap[i]!) .map((i) => restMap[i]!)
.concat(restItems.filter((i) => !chain.includes(i.uid))); .concat(restItems.filter((i) => !chain.includes(i.uid)));
@ -75,8 +85,9 @@ const ProfilePage = () => {
const { global, groups } = proxiesData; const { global, groups } = proxiesData;
[global, ...groups].forEach((group) => { [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 (!now || selectedMap[name] === now) return;
if (selectedMap[name] == null) { if (selectedMap[name] == null) {
selectedMap[name] = now!; selectedMap[name] = now!;
@ -114,13 +125,13 @@ const ProfilePage = () => {
if (!newProfiles.current && remoteItem) { if (!newProfiles.current && remoteItem) {
const current = remoteItem.uid; const current = remoteItem.uid;
patchProfilesConfig({ current }); patchProfiles({ current });
mutate("getProfiles", { ...newProfiles, current }, true); mutateProfiles();
mutate("getRuntimeLogs"); mutateLogs();
} }
}); });
} catch { } catch (err: any) {
Notice.error("Failed to import profile."); Notice.error(err.message || err.toString());
} finally { } finally {
setDisabled(false); setDisabled(false);
} }
@ -128,12 +139,10 @@ const ProfilePage = () => {
const onSelect = useLockFn(async (current: string, force: boolean) => { const onSelect = useLockFn(async (current: string, force: boolean) => {
if (!force && current === profiles.current) return; if (!force && current === profiles.current) return;
try { try {
await patchProfilesConfig({ current }); await patchProfiles({ current });
setCurrentProfile(current); setCurrentProfile(current);
mutate("getProfiles", { ...profiles, current: current }, true); mutateLogs();
mutate("getRuntimeLogs");
closeAllConnections(); closeAllConnections();
Notice.success("Refresh clash config", 1000); Notice.success("Refresh clash config", 1000);
} catch (err: any) { } catch (err: any) {
@ -144,13 +153,52 @@ const ProfilePage = () => {
const onEnhance = useLockFn(async () => { const onEnhance = useLockFn(async () => {
try { try {
await enhanceProfiles(); await enhanceProfiles();
mutate("getRuntimeLogs"); mutateLogs();
// Notice.success("Refresh clash config", 1000); Notice.success("Refresh clash config", 1000);
} catch (err: any) { } catch (err: any) {
Notice.error(err.message || err.toString(), 3000); 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 ( return (
<BasePage <BasePage
title={t("Profiles")} title={t("Profiles")}
@ -191,7 +239,7 @@ const ProfilePage = () => {
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
onClick={() => setDialogOpen(true)} onClick={viewerRef.current?.create}
> >
{t("New")} {t("New")}
</Button> </Button>
@ -205,6 +253,7 @@ const ProfilePage = () => {
selected={profiles.current === item.uid} selected={profiles.current === item.uid}
itemData={item} itemData={item}
onSelect={(f) => onSelect(item.uid, f)} onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
/> />
</Grid> </Grid>
))} ))}
@ -212,10 +261,27 @@ const ProfilePage = () => {
</Box> </Box>
{enhanceItems.length > 0 && ( {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> </BasePage>
); );
}; };

View File

@ -91,8 +91,6 @@ interface IConnections {
* Some interface for command * Some interface for command
*/ */
type IProfileType = "local" | "remote" | "merge" | "script";
interface IClashInfo { interface IClashInfo {
// status: string; // status: string;
port?: number; // clash mixed port port?: number; // clash mixed port
@ -102,7 +100,7 @@ interface IClashInfo {
interface IProfileItem { interface IProfileItem {
uid: string; uid: string;
type?: IProfileType | string; type?: "local" | "remote" | "merge" | "script";
name?: string; name?: string;
desc?: string; desc?: string;
file?: string; file?: string;