feat: support web ui

This commit is contained in:
GyDi
2022-08-06 21:56:54 +08:00
parent 0891b5e7b7
commit 5564c966a5
12 changed files with 361 additions and 52 deletions

View File

@@ -0,0 +1,31 @@
import { alpha, Box, Typography } from "@mui/material";
import { BlurOnRounded } from "@mui/icons-material";
interface Props {
text?: React.ReactNode;
extra?: React.ReactNode;
}
const BaseEmpty = (props: Props) => {
const { text = "Empty", extra } = props;
return (
<Box
sx={({ palette }) => ({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: alpha(palette.text.secondary, 0.75),
})}
>
<BlurOnRounded sx={{ fontSize: "4em" }} />
<Typography sx={{ fontSize: "1.25em" }}>{text}</Typography>
{extra}
</Box>
);
};
export default BaseEmpty;

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import { IconButton, Stack, TextField, Typography } from "@mui/material";
import {
CheckRounded,
CloseRounded,
DeleteRounded,
EditRounded,
OpenInNewRounded,
} from "@mui/icons-material";
interface Props {
value?: string;
onlyEdit?: boolean;
onChange: (value?: string) => void;
onOpenUrl?: (value?: string) => void;
onDelete?: () => void;
onCancel?: () => void;
}
const WebUIItem = (props: Props) => {
const {
value,
onlyEdit = false,
onChange,
onDelete,
onOpenUrl,
onCancel,
} = props;
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
if (editing || onlyEdit) {
return (
<Stack spacing={1} direction="row" mt={1} mb={2} alignItems="center">
<TextField
fullWidth
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder={`Support %host %port %secret`}
autoComplete="off"
/>
<IconButton
size="small"
title="Save"
onClick={() => {
onChange(editValue);
setEditing(false);
}}
>
<CheckRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Cancel"
onClick={() => {
onCancel?.();
setEditing(false);
}}
>
<CloseRounded fontSize="inherit" />
</IconButton>
</Stack>
);
}
return (
<Stack spacing={1} direction="row" alignItems="center" mt={1} mb={2}>
<Typography
component="div"
width="100%"
title={value}
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value || "NULL"}
</Typography>
<IconButton
size="small"
title="Open URL"
onClick={() => onOpenUrl?.(value)}
>
<OpenInNewRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Edit"
onClick={() => {
setEditing(true);
setEditValue(value);
}}
>
<EditRounded fontSize="inherit" />
</IconButton>
<IconButton size="small" title="Delete" onClick={onDelete}>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Stack>
);
};
export default WebUIItem;

View File

@@ -0,0 +1,154 @@
import useSWR from "swr";
import { useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from "@mui/material";
import {
getClashInfo,
getVergeConfig,
openWebUrl,
patchVergeConfig,
} from "@/services/cmds";
import { ModalHandler } from "@/hooks/use-modal-handler";
import BaseEmpty from "@/components/base/base-empty";
import WebUIItem from "./web-ui-item";
interface Props {
handler: ModalHandler;
onError: (err: Error) => void;
}
const WebUIViewer = ({ handler, onError }: Props) => {
const { t } = useTranslation();
const { data: vergeConfig, mutate: mutateVerge } = useSWR(
"getVergeConfig",
getVergeConfig
);
const webUIList = vergeConfig?.web_ui_list || [];
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
if (handler) {
handler.current = {
open: () => setOpen(true),
close: () => setOpen(false),
};
}
const handleAdd = useLockFn(async (value: string) => {
const newList = [value, ...webUIList];
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVergeConfig({ web_ui_list: newList });
await mutateVerge();
});
const handleChange = useLockFn(async (index: number, value?: string) => {
const newList = [...webUIList];
newList[index] = value ?? "";
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVergeConfig({ web_ui_list: newList });
await mutateVerge();
});
const handleDelete = useLockFn(async (index: number) => {
const newList = [...webUIList];
newList.splice(index, 1);
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVergeConfig({ web_ui_list: newList });
await mutateVerge();
});
const { data: clashInfo } = useSWR("getClashInfo", getClashInfo);
const handleOpenUrl = useLockFn(async (value?: string) => {
if (!value) return;
try {
let url = value.trim().replaceAll("%host", "127.0.0.1");
if (url.includes("%port") || url.includes("%secret")) {
if (!clashInfo) throw new Error("failed to get clash info");
url = url.replaceAll("%port", clashInfo.port || "9090");
url = url.replaceAll("%secret", clashInfo.secret || "");
}
await openWebUrl(url);
} catch (e: any) {
onError(e);
}
});
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle display="flex" justifyContent="space-between">
{t("Web UI")}
<Button
variant="contained"
size="small"
disabled={editing}
onClick={() => setEditing(true)}
>
{t("New")}
</Button>
</DialogTitle>
<DialogContent
sx={{
width: 450,
height: 300,
pb: 1,
overflowY: "auto",
userSelect: "text",
}}
>
{editing && (
<WebUIItem
value=""
onlyEdit
onChange={(v) => {
setEditing(false);
handleAdd(v || "");
}}
onCancel={() => setEditing(false)}
/>
)}
{!editing && webUIList.length === 0 && (
<BaseEmpty
text="Empty List"
extra={
<Typography mt={2} sx={{ fontSize: "12px" }}>
Replace host, port, secret with "%host" "%port" "%secret"
</Typography>
}
/>
)}
{webUIList.map((item, index) => (
<WebUIItem
key={index}
value={item}
onChange={(v) => handleChange(index, v)}
onDelete={() => handleDelete(index)}
onOpenUrl={handleOpenUrl}
/>
))}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>{t("Back")}</Button>
</DialogActions>
</Dialog>
);
};
export default WebUIViewer;

View File

@@ -11,12 +11,14 @@ import {
} from "@mui/material";
import { atomClashPort } from "@/services/states";
import { ArrowForward } from "@mui/icons-material";
import { openWebUrl, patchClashConfig } from "@/services/cmds";
import { patchClashConfig } from "@/services/cmds";
import { SettingList, SettingItem } from "./setting";
import { getClashConfig, getVersion, updateConfigs } from "@/services/api";
import useModalHandler from "@/hooks/use-modal-handler";
import Notice from "../base/base-notice";
import GuardState from "./mods/guard-state";
import CoreSwitch from "./mods/core-switch";
import WebUIViewer from "./mods/web-ui-viewer";
interface Props {
onError: (err: Error) => void;
@@ -37,6 +39,8 @@ const SettingClash = ({ onError }: Props) => {
const setGlobalClashPort = useSetRecoilState(atomClashPort);
const webUIHandler = useModalHandler();
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<ApiType.ConfigData>) => {
mutate("getClashConfig", { ...clashConfig, ...patch }, false);
@@ -68,6 +72,8 @@ const SettingClash = ({ onError }: Props) => {
return (
<SettingList title={t("Clash Setting")}>
<WebUIViewer handler={webUIHandler} onError={onError} />
<SettingItem label={t("Allow Lan")}>
<GuardState
value={allowLan ?? false}
@@ -94,6 +100,17 @@ const SettingClash = ({ onError }: Props) => {
</GuardState>
</SettingItem>
<SettingItem label={t("Web UI")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => webUIHandler.current.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Log Level")}>
<GuardState
value={logLevel ?? "info"}
@@ -132,12 +149,6 @@ const SettingClash = ({ onError }: Props) => {
<SettingItem label={t("Clash Core")} extra={<CoreSwitch />}>
<Typography sx={{ py: "7px" }}>{clashVer}</Typography>
</SettingItem>
{/* <SettingItem label={t("Web UI")}>
<IconButton color="inherit" size="small" sx={{ my: "2px" }}>
<ArrowForward />
</IconButton>
</SettingItem> */}
</SettingList>
);
};

View File

@@ -0,0 +1,14 @@
import { MutableRefObject, useRef } from "react";
interface Handler {
open: () => void;
close: () => void;
}
export type ModalHandler = MutableRefObject<Handler>;
const useModalHandler = (): ModalHandler => {
return useRef({ open: () => {}, close: () => {} });
};
export default useModalHandler;

View File

@@ -60,6 +60,7 @@
"theme.dark": "Dark",
"theme.system": "System",
"Back": "Back",
"Save": "Save",
"Cancel": "Cancel"
}

View File

@@ -60,6 +60,7 @@
"theme.dark": "深色",
"theme.system": "系统",
"Back": "返回",
"Save": "保存",
"Cancel": "取消"
}

View File

@@ -113,6 +113,10 @@ export async function openLogsDir() {
);
}
export async function openWebUrl(url: string) {
return invoke<void>("open_web_url", { url });
}
/// service mode
export async function startService() {

View File

@@ -137,6 +137,7 @@ declare namespace CmdType {
enable_system_proxy?: boolean;
enable_proxy_guard?: boolean;
system_proxy_bypass?: string;
web_ui_list?: string[];
theme_setting?: {
primary_color?: string;
secondary_color?: string;