feat: optimize profile page

This commit is contained in:
GyDi 2022-08-14 23:10:19 +08:00
parent f1a68ece01
commit 33ce235713
No known key found for this signature in database
GPG Key ID: 1C95E0D3467B3084
12 changed files with 291 additions and 211 deletions

View File

@ -21,12 +21,16 @@ const EnhancedMode = (props: Props) => {
const { items, chain } = props; const { items, chain } = props;
const { mutate: mutateProfiles } = useSWR("getProfiles", getProfiles); const { mutate: mutateProfiles } = useSWR("getProfiles", getProfiles);
const { data: chainLogs = {} } = useSWR("getRuntimeLogs", getRuntimeLogs); const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
"getRuntimeLogs",
getRuntimeLogs
);
// handler // handler
const onEnhance = useLockFn(async () => { const onEnhance = useLockFn(async () => {
try { try {
await enhanceProfiles(); await enhanceProfiles();
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()); Notice.error(err.message || err.toString());
@ -39,6 +43,7 @@ const EnhancedMode = (props: Props) => {
const newChain = [...chain, uid]; const newChain = [...chain, uid];
await changeProfileChain(newChain); await changeProfileChain(newChain);
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
}); });
const onEnhanceDisable = useLockFn(async (uid: string) => { const onEnhanceDisable = useLockFn(async (uid: string) => {
@ -47,6 +52,7 @@ const EnhancedMode = (props: Props) => {
const newChain = chain.filter((i) => i !== uid); const newChain = chain.filter((i) => i !== uid);
await changeProfileChain(newChain); await changeProfileChain(newChain);
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
}); });
const onEnhanceDelete = useLockFn(async (uid: string) => { const onEnhanceDelete = useLockFn(async (uid: string) => {
@ -54,6 +60,7 @@ const EnhancedMode = (props: Props) => {
await onEnhanceDisable(uid); await onEnhanceDisable(uid);
await deleteProfile(uid); await deleteProfile(uid);
mutateProfiles(); mutateProfiles();
mutateLogs();
} catch (err: any) { } catch (err: any) {
Notice.error(err?.message || err.toString()); Notice.error(err?.message || err.toString());
} }
@ -65,6 +72,7 @@ const EnhancedMode = (props: Props) => {
const newChain = [uid].concat(chain.filter((i) => i !== uid)); const newChain = [uid].concat(chain.filter((i) => i !== uid));
await changeProfileChain(newChain); await changeProfileChain(newChain);
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
}); });
const onMoveEnd = useLockFn(async (uid: string) => { const onMoveEnd = useLockFn(async (uid: string) => {
@ -73,10 +81,11 @@ const EnhancedMode = (props: Props) => {
const newChain = chain.filter((i) => i !== uid).concat([uid]); const newChain = chain.filter((i) => i !== uid).concat([uid]);
await changeProfileChain(newChain); await changeProfileChain(newChain);
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true); mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
}); });
return ( return (
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 2 }}>
<Stack <Stack
spacing={1} spacing={1}
direction="row" direction="row"

View File

@ -82,7 +82,9 @@ const FileEditor = (props: Props) => {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}>{t("Cancel")}</Button> <Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={onSave} variant="contained"> <Button onClick={onSave} variant="contained">
{t("Save")} {t("Save")}
</Button> </Button>

View File

@ -1,5 +1,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Box, Button, Typography } from "@mui/material"; import { Box, Button, Typography } from "@mui/material";
interface Props { interface Props {
@ -9,6 +10,7 @@ interface Props {
const FileInput = (props: Props) => { const FileInput = (props: Props) => {
const { onChange } = props; const { onChange } = props;
const { t } = useTranslation();
// file input // file input
const inputRef = useRef<any>(); const inputRef = useRef<any>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -40,7 +42,7 @@ const FileInput = (props: Props) => {
sx={{ flex: "none" }} sx={{ flex: "none" }}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
Choose File {t("Choose File")}
</Button> </Button>
<input <input

View File

@ -23,7 +23,7 @@ interface Props {
// edit the profile item // edit the profile item
// remote / local file / merge / script // remote / local file / merge / script
const ProfileEdit = (props: Props) => { const InfoEditor = (props: Props) => {
const { open, itemData, onClose } = props; const { open, itemData, onClose } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@ -56,7 +56,6 @@ const ProfileEdit = (props: Props) => {
} }
await patchProfile(uid, { uid, name, desc, url, option: option_ }); await patchProfile(uid, { uid, name, desc, url, option: option_ });
setShowOpt(false);
mutate("getProfiles"); mutate("getProfiles");
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
@ -133,7 +132,7 @@ const ProfileEdit = (props: Props) => {
value={option.update_interval} value={option.update_interval}
onChange={(e) => { onChange={(e) => {
const str = e.target.value?.replace(/\D/, ""); const str = e.target.value?.replace(/\D/, "");
setOption({ update_interval: str != null ? +str : str }); setOption({ update_interval: !!str ? +str : undefined });
}} }}
onKeyDown={(e) => e.key === "Enter" && onUpdate()} onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/> />
@ -144,6 +143,7 @@ const ProfileEdit = (props: Props) => {
{form.type === "remote" && ( {form.type === "remote" && (
<IconButton <IconButton
size="small" size="small"
color="inherit"
sx={{ position: "absolute", left: 18 }} sx={{ position: "absolute", left: 18 }}
onClick={() => setShowOpt((o) => !o)} onClick={() => setShowOpt((o) => !o)}
> >
@ -151,13 +151,15 @@ const ProfileEdit = (props: Props) => {
</IconButton> </IconButton>
)} )}
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={onUpdate} variant="contained"> <Button onClick={onUpdate} variant="contained">
Update {t("Save")}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
}; };
export default ProfileEdit; export default InfoEditor;

View File

@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Typography,
} from "@mui/material";
import BaseEmpty from "../base/base-empty";
import { Fragment } from "react";
interface Props {
open: boolean;
logInfo: [string, string][];
onClose: () => void;
}
const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props;
const { t } = useTranslation();
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("Script Console")}</DialogTitle>
<DialogContent
sx={{
width: 400,
height: 300,
overflowX: "hidden",
userSelect: "text",
pb: 1,
}}
>
{logInfo.map(([level, log], index) => (
<Fragment key={index.toString()}>
<Typography color="text.secondary" component="div">
<Chip
label={level}
size="small"
variant="outlined"
color={
level === "error" || level === "exception"
? "error"
: "default"
}
sx={{ mr: 1 }}
/>
{log}
</Typography>
<Divider sx={{ my: 0.5 }} />
</Fragment>
))}
{logInfo.length === 0 && <BaseEmpty />}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Back")}
</Button>
</DialogActions>
</Dialog>
);
};
export default LogViewer;

View File

@ -0,0 +1,43 @@
import { alpha, Box, styled } from "@mui/material";
const ProfileBox = styled(Box)(({ theme, "aria-selected": selected }) => {
const { mode, primary, text, grey, background } = theme.palette;
const key = `${mode}-${!!selected}`;
const backgroundColor = {
"light-true": alpha(primary.main, 0.2),
"light-false": alpha(background.paper, 0.75),
"dark-true": alpha(primary.main, 0.45),
"dark-false": alpha(grey[700], 0.45),
}[key]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.85),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return {
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
padding: "8px 16px",
boxSizing: "border-box",
backgroundColor,
color,
"& h2": { color: h2color },
};
});
export default ProfileBox;

View File

@ -1,13 +1,11 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { mutate } from "swr";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useSWRConfig } from "swr";
import { useRecoilState } from "recoil"; import { useRecoilState } from "recoil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
alpha,
Box, Box,
styled,
Typography, Typography,
LinearProgress, LinearProgress,
IconButton, IconButton,
@ -19,21 +17,11 @@ 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 parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import ProfileEdit from "./profile-edit"; import ProfileBox from "./profile-box";
import InfoEditor from "./info-editor";
import FileEditor from "./file-editor"; import FileEditor from "./file-editor";
import Notice from "../base/base-notice"; import Notice from "../base/base-notice";
const Wrapper = styled(Box)(({ theme }) => ({
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
padding: "8px 16px",
boxSizing: "border-box",
}));
const round = keyframes` const round = keyframes`
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
@ -49,7 +37,6 @@ const ProfileItem = (props: Props) => {
const { selected, itemData, onSelect } = props; const { selected, itemData, onSelect } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { mutate } = useSWRConfig();
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 [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache); const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
@ -58,7 +45,6 @@ const ProfileItem = (props: Props) => {
// local file mode // local file mode
// remote file mode // remote file mode
// subscription url mode
const hasUrl = !!itemData.url; const hasUrl = !!itemData.url;
const hasExtra = !!extra; // only subscription url has extra info const hasExtra = !!extra; // only subscription url has extra info
@ -79,7 +65,6 @@ const ProfileItem = (props: Props) => {
const handler = () => { const handler = () => {
const now = Date.now(); const now = Date.now();
const lastUpdate = updated * 1000; const lastUpdate = updated * 1000;
// 大于一天的不管 // 大于一天的不管
if (now - lastUpdate >= 24 * 36e5) return; if (now - lastUpdate >= 24 * 36e5) return;
@ -152,13 +137,6 @@ const ProfileItem = (props: Props) => {
} }
}); });
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
const urlModeMenu = [ const urlModeMenu = [
{ label: "Select", handler: onForceSelect }, { label: "Select", handler: onForceSelect },
{ label: "Edit Info", handler: onEditInfo }, { label: "Edit Info", handler: onEditInfo },
@ -176,36 +154,17 @@ const ProfileItem = (props: Props) => {
{ label: "Delete", handler: onDelete }, { label: "Delete", handler: onDelete },
]; ];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
return ( return (
<> <>
<Wrapper <ProfileBox
sx={({ palette }) => { aria-selected={selected}
const { mode, primary, text, grey } = palette;
const key = `${mode}-${selected}`;
const bgcolor = {
"light-true": alpha(primary.main, 0.15),
"light-false": palette.background.paper,
"dark-true": alpha(primary.main, 0.35),
"dark-false": alpha(grey[700], 0.35),
}[key]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.75),
"dark-false": alpha(text.secondary, 0.75),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return { bgcolor, color, "& h2": { color: h2color } };
}}
onClick={() => onSelect(false)} onClick={() => onSelect(false)}
onContextMenu={(event) => { onContextMenu={(event) => {
const { clientX, clientY } = event; const { clientX, clientY } = event;
@ -214,9 +173,9 @@ const ProfileItem = (props: Props) => {
event.preventDefault(); event.preventDefault();
}} }}
> >
<Box display="flex" justifyContent="space-between"> <Box position="relative">
<Typography <Typography
width="calc(100% - 40px)" width="calc(100% - 36px)"
variant="h6" variant="h6"
component="h2" component="h2"
noWrap noWrap
@ -229,10 +188,13 @@ const ProfileItem = (props: Props) => {
{hasUrl && ( {hasUrl && (
<IconButton <IconButton
sx={{ sx={{
width: 26, position: "absolute",
height: 26, p: "3px",
top: -1,
right: -5,
animation: loading ? `1s linear infinite ${round}` : "none", animation: loading ? `1s linear infinite ${round}` : "none",
}} }}
size="small"
color="inherit" color="inherit"
disabled={loading} disabled={loading}
onClick={(e) => { onClick={(e) => {
@ -240,14 +202,15 @@ const ProfileItem = (props: Props) => {
onUpdate(false); onUpdate(false);
}} }}
> >
<RefreshRounded /> <RefreshRounded color="inherit" />
</IconButton> </IconButton>
)} )}
</Box> </Box>
{/* the second line show url's info or description */} {/* the second line show url's info or description */}
{hasUrl ? (
<Box sx={boxStyle}> <Box sx={boxStyle}>
{hasUrl ? (
<>
<Typography noWrap title={`From: ${from}`}> <Typography noWrap title={`From: ${from}`}>
{from} {from}
</Typography> </Typography>
@ -257,30 +220,29 @@ const ProfileItem = (props: Props) => {
flex="1 0 auto" flex="1 0 auto"
fontSize={14} fontSize={14}
textAlign="right" textAlign="right"
title="updated time" title={`Updated Time: ${parseExpire(updated)}`}
> >
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""} {updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
</Typography> </Typography>
</Box> </>
) : ( ) : (
<Box sx={boxStyle}>
<Typography noWrap title={itemData.desc}> <Typography noWrap title={itemData.desc}>
{itemData.desc} {itemData.desc}
</Typography> </Typography>
</Box>
)} )}
</Box>
{/* the third line show extra info or last updated time */} {/* the third line show extra info or last updated time */}
{hasExtra ? ( {hasExtra ? (
<Box sx={{ ...boxStyle, fontSize: 14 }}> <Box sx={{ ...boxStyle, fontSize: 14 }}>
<span title="used / total"> <span title="Used / Total">
{parseTraffic(upload + download)} / {parseTraffic(total)} {parseTraffic(upload + download)} / {parseTraffic(total)}
</span> </span>
<span title="expire time">{expire}</span> <span title="Expire Time">{expire}</span>
</Box> </Box>
) : ( ) : (
<Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}> <Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
<span title="updated time">{parseExpire(updated)}</span> <span title="Updated Time">{parseExpire(updated)}</span>
</Box> </Box>
)} )}
@ -289,7 +251,7 @@ const ProfileItem = (props: Props) => {
value={progress} value={progress}
color="inherit" color="inherit"
/> />
</Wrapper> </ProfileBox>
<Menu <Menu
open={!!anchorEl} open={!!anchorEl}
@ -314,22 +276,18 @@ const ProfileItem = (props: Props) => {
))} ))}
</Menu> </Menu>
{editOpen && ( <InfoEditor
<ProfileEdit
open={editOpen} open={editOpen}
itemData={itemData} itemData={itemData}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
/> />
)}
{fileOpen && (
<FileEditor <FileEditor
uid={uid} uid={uid}
open={fileOpen} open={fileOpen}
mode="yaml" mode="yaml"
onClose={() => setFileOpen(false)} onClose={() => setFileOpen(false)}
/> />
)}
</> </>
); );
}; };

View File

@ -1,32 +1,24 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { import {
alpha,
Box, Box,
Badge,
Chip, Chip,
styled,
Typography, Typography,
MenuItem, MenuItem,
Menu, Menu,
IconButton,
} from "@mui/material"; } from "@mui/material";
import { FeaturedPlayListRounded } from "@mui/icons-material";
import { viewProfile } from "@/services/cmds"; import { viewProfile } from "@/services/cmds";
import ProfileEdit from "./profile-edit"; import InfoEditor from "./info-editor";
import FileEditor from "./file-editor"; import FileEditor from "./file-editor";
import ProfileBox from "./profile-box";
import LogViewer from "./log-viewer";
import Notice from "../base/base-notice"; import Notice from "../base/base-notice";
const Wrapper = styled(Box)(({ theme }) => ({
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
padding: "8px 16px",
boxSizing: "border-box",
}));
interface Props { interface Props {
selected: boolean; selected: boolean;
itemData: CmdType.ProfileItem; itemData: CmdType.ProfileItem;
@ -55,18 +47,11 @@ const ProfileMore = (props: Props) => {
const { uid, type } = itemData; const { uid, type } = itemData;
const { t } = useTranslation(); const { t } = 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 [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false);
// const [status, setStatus] = useState(enhance.status(uid)); const [logOpen, setLogOpen] = useState(false);
// unlisten when unmount
// useEffect(() => enhance.listen(uid, setStatus), [uid]);
// error during enhanced mode
const hasError = !!logInfo.find((e) => e[0] === "exception"); // selected && status?.status === "error";
const onEditInfo = () => { const onEditInfo = () => {
setAnchorEl(null); setAnchorEl(null);
@ -92,6 +77,7 @@ const ProfileMore = (props: Props) => {
return fn(); return fn();
}; };
const hasError = !!logInfo.find((e) => e[0] === "exception");
const showMove = enableNum > 1 && !hasError; const showMove = enableNum > 1 && !hasError;
const enableMenu = [ const enableMenu = [
@ -122,39 +108,8 @@ const ProfileMore = (props: Props) => {
return ( return (
<> <>
<Wrapper <ProfileBox
sx={({ palette }) => { aria-selected={selected}
// todo
// 区分 selected 和 error 和 mode 下各种颜色的排列组合
const { mode, primary, text, grey, error } = palette;
const key = `${mode}-${selected}`;
const bgkey = hasError ? `${mode}-err` : key;
const bgcolor = {
"light-true": alpha(primary.main, 0.15),
"light-false": palette.background.paper,
"dark-true": alpha(primary.main, 0.35),
"dark-false": alpha(grey[700], 0.35),
"light-err": alpha(error.main, 0.12),
"dark-err": alpha(error.main, 0.3),
}[bgkey]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.6),
"dark-false": alpha(text.secondary, 0.6),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return { bgcolor, color, "& h2": { color: h2color } };
}}
// onClick={() => onSelect(false)} // onClick={() => onSelect(false)}
onContextMenu={(event) => { onContextMenu={(event) => {
const { clientX, clientY } = event; const { clientX, clientY } = event;
@ -163,7 +118,12 @@ const ProfileMore = (props: Props) => {
event.preventDefault(); event.preventDefault();
}} }}
> >
<Box display="flex" justifyContent="space-between" alignItems="center"> <Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb={0.5}
>
<Typography <Typography
width="calc(100% - 52px)" width="calc(100% - 52px)"
variant="h6" variant="h6"
@ -179,22 +139,33 @@ const ProfileMore = (props: Props) => {
color="primary" color="primary"
size="small" size="small"
variant="outlined" variant="outlined"
sx={{ textTransform: "capitalize" }} sx={{ height: 20, textTransform: "capitalize" }}
/> />
</Box> </Box>
<Box sx={boxStyle}> <Box sx={boxStyle}>
{hasError ? ( {selected ? (
<Typography hasError ? (
noWrap <Badge color="primary" variant="dot" overlap="circular">
<IconButton
size="small"
edge="start"
color="error" color="error"
sx={{ width: "calc(100% - 75px)" }} onClick={() => setLogOpen(true)}
// title={status.message}
title="error"
> >
{/* {status.message} */} <FeaturedPlayListRounded fontSize="inherit" />
error </IconButton>
</Typography> </Badge>
) : (
<IconButton
size="small"
edge="start"
color="inherit"
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
</IconButton>
)
) : ( ) : (
<Typography <Typography
noWrap noWrap
@ -207,13 +178,15 @@ const ProfileMore = (props: Props) => {
<Typography <Typography
component="span" component="span"
title="updated time" title={`Updated Time: ${parseExpire(itemData.updated)}`}
style={{ fontSize: 14 }} style={{ fontSize: 14 }}
> >
{parseExpire(itemData.updated)} {!!itemData.updated
? dayjs(itemData.updated! * 1000).fromNow()
: ""}
</Typography> </Typography>
</Box> </Box>
</Wrapper> </ProfileBox>
<Menu <Menu
open={!!anchorEl} open={!!anchorEl}
@ -240,21 +213,25 @@ const ProfileMore = (props: Props) => {
))} ))}
</Menu> </Menu>
{editOpen && ( <InfoEditor
<ProfileEdit
open={editOpen} open={editOpen}
itemData={itemData} itemData={itemData}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
/> />
)}
{fileOpen && (
<FileEditor <FileEditor
uid={uid} uid={uid}
open={fileOpen} open={fileOpen}
mode={type === "merge" ? "yaml" : "javascript"} mode={type === "merge" ? "yaml" : "javascript"}
onClose={() => setFileOpen(false)} onClose={() => setFileOpen(false)}
/> />
{selected && (
<LogViewer
open={logOpen}
logInfo={logInfo}
onClose={() => setLogOpen(false)}
/>
)} )}
</> </>
); );

View File

@ -1,5 +1,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useSWRConfig } from "swr"; import { mutate } from "swr";
import { useTranslation } from "react-i18next";
import { useLockFn, useSetState } from "ahooks"; import { useLockFn, useSetState } from "ahooks";
import { import {
Button, Button,
@ -29,7 +30,7 @@ interface Props {
const ProfileNew = (props: Props) => { const ProfileNew = (props: Props) => {
const { open, onClose } = props; const { open, onClose } = props;
const { mutate } = useSWRConfig(); const { t } = useTranslation();
const [form, setForm] = useSetState({ const [form, setForm] = useSetState({
type: "remote", type: "remote",
name: "", name: "",
@ -83,7 +84,7 @@ const ProfileNew = (props: Props) => {
return ( return (
<Dialog open={open} onClose={onClose}> <Dialog open={open} onClose={onClose}>
<DialogTitle sx={{ pb: 0.5 }}>Create Profile</DialogTitle> <DialogTitle sx={{ pb: 0.5 }}>{t("Create Profile")}</DialogTitle>
<DialogContent sx={{ width: 336, pb: 1 }}> <DialogContent sx={{ width: 336, pb: 1 }}>
<FormControl size="small" fullWidth sx={{ mt: 2, mb: 1 }}> <FormControl size="small" fullWidth sx={{ mt: 2, mb: 1 }}>
@ -120,7 +121,7 @@ const ProfileNew = (props: Props) => {
{form.type === "remote" && ( {form.type === "remote" && (
<TextField <TextField
{...textFieldProps} {...textFieldProps}
label="Subscription Url" label="Subscription URL"
autoComplete="off" autoComplete="off"
value={form.url} value={form.url}
onChange={(e) => setForm({ url: e.target.value })} onChange={(e) => setForm({ url: e.target.value })}
@ -146,6 +147,7 @@ const ProfileNew = (props: Props) => {
{form.type === "remote" && ( {form.type === "remote" && (
<IconButton <IconButton
size="small" size="small"
color="inherit"
sx={{ position: "absolute", left: 18 }} sx={{ position: "absolute", left: 18 }}
onClick={() => setShowOpt((o) => !o)} onClick={() => setShowOpt((o) => !o)}
> >
@ -153,9 +155,11 @@ const ProfileNew = (props: Props) => {
</IconButton> </IconButton>
)} )}
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={onCreate} variant="contained"> <Button onClick={onCreate} variant="contained">
Create {t("Save")}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@ -18,6 +18,8 @@
"Profile URL": "Profile URL", "Profile URL": "Profile URL",
"Import": "Import", "Import": "Import",
"New": "New", "New": "New",
"Create Profile": "Create Profile",
"Choose File": "Choose File",
"Close All": "Close All", "Close All": "Close All",
"Select": "Select", "Select": "Select",
"Edit Info": "Edit Info", "Edit Info": "Edit Info",

View File

@ -18,6 +18,8 @@
"Profile URL": "配置文件链接", "Profile URL": "配置文件链接",
"Import": "导入", "Import": "导入",
"New": "新建", "New": "新建",
"Create Profile": "新建配置",
"Choose File": "选择文件",
"Close All": "关闭全部", "Close All": "关闭全部",
"Select": "使用", "Select": "使用",
"Edit Info": "编辑信息", "Edit Info": "编辑信息",

View File

@ -1,8 +1,8 @@
import useSWR, { useSWRConfig } from "swr"; import useSWR, { mutate } from "swr";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSetRecoilState } from "recoil"; import { useSetRecoilState } from "recoil";
import { Box, Button, Grid, TextField } from "@mui/material"; import { Button, Grid, Stack, TextField } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
getProfiles, getProfiles,
@ -20,7 +20,6 @@ import EnhancedMode from "@/components/profile/enhanced";
const ProfilePage = () => { const ProfilePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { mutate } = useSWRConfig();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
@ -110,10 +109,13 @@ const ProfilePage = () => {
getProfiles().then((newProfiles) => { getProfiles().then((newProfiles) => {
mutate("getProfiles", newProfiles); mutate("getProfiles", newProfiles);
if (!newProfiles.current && newProfiles.items?.length) { const remoteItem = newProfiles.items?.find((e) => e.type === "remote");
const current = newProfiles.items[0].uid;
if (!newProfiles.current && remoteItem) {
const current = remoteItem.uid;
selectProfile(current); selectProfile(current);
mutate("getProfiles", { ...newProfiles, current }, true); mutate("getProfiles", { ...newProfiles, current }, true);
mutate("getRuntimeLogs");
} }
}); });
} catch { } catch {
@ -130,6 +132,7 @@ const ProfilePage = () => {
await selectProfile(uid); await selectProfile(uid);
setCurrentProfile(uid); setCurrentProfile(uid);
mutate("getProfiles", { ...profiles, current: uid }, true); mutate("getProfiles", { ...profiles, current: uid }, true);
mutate("getRuntimeLogs");
if (force) Notice.success("Refresh clash config", 1000); if (force) Notice.success("Refresh clash config", 1000);
} catch (err: any) { } catch (err: any) {
Notice.error(err?.message || err.toString()); Notice.error(err?.message || err.toString());
@ -138,29 +141,34 @@ const ProfilePage = () => {
return ( return (
<BasePage title={t("Profiles")}> <BasePage title={t("Profiles")}>
<Box sx={{ display: "flex", mb: 2.5 }}> <Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<TextField <TextField
id="clas_verge_profile_url" hiddenLabel
name="profile_url"
label={t("Profile URL")}
size="small"
fullWidth fullWidth
size="small"
value={url} value={url}
variant="outlined"
autoComplete="off"
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
sx={{ mr: 1 }} sx={{ input: { py: 0.65, px: 1.25 } }}
placeholder={t("Profile URL")}
/> />
<Button <Button
disabled={!url || disabled} disabled={!url || disabled}
variant="contained" variant="contained"
size="small"
onClick={onImport} onClick={onImport}
sx={{ mr: 1 }}
> >
{t("Import")} {t("Import")}
</Button> </Button>
<Button variant="contained" onClick={() => setDialogOpen(true)}> <Button
variant="contained"
size="small"
onClick={() => setDialogOpen(true)}
>
{t("New")} {t("New")}
</Button> </Button>
</Box> </Stack>
<Grid container spacing={2}> <Grid container spacing={2}>
{regularItems.map((item) => ( {regularItems.map((item) => (