proxies-editor-viewer.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. import { ReactNode, useEffect, useMemo, useState } from "react";
  2. import { useLockFn } from "ahooks";
  3. import yaml from "js-yaml";
  4. import { useTranslation } from "react-i18next";
  5. import {
  6. DndContext,
  7. closestCenter,
  8. KeyboardSensor,
  9. PointerSensor,
  10. useSensor,
  11. useSensors,
  12. DragEndEvent,
  13. } from "@dnd-kit/core";
  14. import {
  15. SortableContext,
  16. sortableKeyboardCoordinates,
  17. } from "@dnd-kit/sortable";
  18. import {
  19. Autocomplete,
  20. Box,
  21. Button,
  22. Dialog,
  23. DialogActions,
  24. DialogContent,
  25. DialogTitle,
  26. List,
  27. ListItem,
  28. ListItemText,
  29. TextField,
  30. styled,
  31. } from "@mui/material";
  32. import { ProxyItem } from "@/components/profile/proxy-item";
  33. import { readProfileFile, saveProfileFile } from "@/services/cmds";
  34. import { Notice, Switch } from "@/components/base";
  35. import getSystem from "@/utils/get-system";
  36. import { BaseSearchBox } from "../base/base-search-box";
  37. import { Virtuoso } from "react-virtuoso";
  38. import MonacoEditor from "react-monaco-editor";
  39. import { useThemeMode } from "@/services/states";
  40. import { Controller, useForm } from "react-hook-form";
  41. interface Props {
  42. profileUid: string;
  43. property: string;
  44. open: boolean;
  45. onClose: () => void;
  46. onSave?: (prev?: string, curr?: string) => void;
  47. }
  48. const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
  49. export const ProxiesEditorViewer = (props: Props) => {
  50. const { profileUid, property, open, onClose, onSave } = props;
  51. const { t } = useTranslation();
  52. const themeMode = useThemeMode();
  53. const [prevData, setPrevData] = useState("");
  54. const [currData, setCurrData] = useState("");
  55. const [visualization, setVisualization] = useState(true);
  56. const [match, setMatch] = useState(() => (_: string) => true);
  57. const { control, watch, register, ...formIns } = useForm<IProxyConfig>({
  58. defaultValues: {
  59. type: "ss",
  60. name: "",
  61. },
  62. });
  63. const [proxyList, setProxyList] = useState<IProxyConfig[]>([]);
  64. const [prependSeq, setPrependSeq] = useState<IProxyConfig[]>([]);
  65. const [appendSeq, setAppendSeq] = useState<IProxyConfig[]>([]);
  66. const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
  67. const filteredProxyList = useMemo(
  68. () => proxyList.filter((proxy) => match(proxy.name)),
  69. [proxyList, match]
  70. );
  71. const sensors = useSensors(
  72. useSensor(PointerSensor),
  73. useSensor(KeyboardSensor, {
  74. coordinateGetter: sortableKeyboardCoordinates,
  75. })
  76. );
  77. const reorder = (
  78. list: IProxyConfig[],
  79. startIndex: number,
  80. endIndex: number
  81. ) => {
  82. const result = Array.from(list);
  83. const [removed] = result.splice(startIndex, 1);
  84. result.splice(endIndex, 0, removed);
  85. return result;
  86. };
  87. const onPrependDragEnd = async (event: DragEndEvent) => {
  88. const { active, over } = event;
  89. if (over) {
  90. if (active.id !== over.id) {
  91. let activeIndex = 0;
  92. let overIndex = 0;
  93. prependSeq.forEach((item, index) => {
  94. if (item.name === active.id) {
  95. activeIndex = index;
  96. }
  97. if (item.name === over.id) {
  98. overIndex = index;
  99. }
  100. });
  101. setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
  102. }
  103. }
  104. };
  105. const onAppendDragEnd = async (event: DragEndEvent) => {
  106. const { active, over } = event;
  107. if (over) {
  108. if (active.id !== over.id) {
  109. let activeIndex = 0;
  110. let overIndex = 0;
  111. appendSeq.forEach((item, index) => {
  112. if (item.name === active.id) {
  113. activeIndex = index;
  114. }
  115. if (item.name === over.id) {
  116. overIndex = index;
  117. }
  118. });
  119. setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
  120. }
  121. }
  122. };
  123. const fetchProfile = async () => {
  124. let data = await readProfileFile(profileUid);
  125. let originProxiesObj = yaml.load(data) as {
  126. proxies: IProxyConfig[];
  127. } | null;
  128. setProxyList(originProxiesObj?.proxies || []);
  129. };
  130. const fetchContent = async () => {
  131. let data = await readProfileFile(property);
  132. let obj = yaml.load(data) as ISeqProfileConfig | null;
  133. setPrependSeq(obj?.prepend || []);
  134. setAppendSeq(obj?.append || []);
  135. setDeleteSeq(obj?.delete || []);
  136. setPrevData(data);
  137. setCurrData(data);
  138. };
  139. useEffect(() => {
  140. if (currData === "") return;
  141. if (visualization !== true) return;
  142. let obj = yaml.load(currData) as {
  143. prepend: [];
  144. append: [];
  145. delete: [];
  146. } | null;
  147. setPrependSeq(obj?.prepend || []);
  148. setAppendSeq(obj?.append || []);
  149. setDeleteSeq(obj?.delete || []);
  150. }, [visualization]);
  151. useEffect(() => {
  152. if (prependSeq && appendSeq && deleteSeq)
  153. setCurrData(
  154. yaml.dump(
  155. { prepend: prependSeq, append: appendSeq, delete: deleteSeq },
  156. {
  157. forceQuotes: true,
  158. }
  159. )
  160. );
  161. }, [prependSeq, appendSeq, deleteSeq]);
  162. useEffect(() => {
  163. if (!open) return;
  164. fetchContent();
  165. fetchProfile();
  166. }, [open]);
  167. const handleSave = useLockFn(async () => {
  168. try {
  169. await saveProfileFile(property, currData);
  170. onSave?.(prevData, currData);
  171. onClose();
  172. } catch (err: any) {
  173. Notice.error(err.message || err.toString());
  174. }
  175. });
  176. return (
  177. <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
  178. <DialogTitle>
  179. {
  180. <Box display="flex" justifyContent="space-between">
  181. {t("Edit Proxies")}
  182. <Box>
  183. <Button
  184. variant="contained"
  185. size="small"
  186. onClick={() => {
  187. setVisualization((prev) => !prev);
  188. }}
  189. >
  190. {visualization ? t("Advanced") : t("Visualization")}
  191. </Button>
  192. </Box>
  193. </Box>
  194. }
  195. </DialogTitle>
  196. <DialogContent
  197. sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
  198. >
  199. {visualization ? (
  200. <>
  201. <List
  202. sx={{
  203. width: "50%",
  204. padding: "0 10px",
  205. }}
  206. >
  207. <Box
  208. sx={{
  209. height: "calc(100% - 80px)",
  210. overflowY: "auto",
  211. }}
  212. >
  213. <Controller
  214. name="type"
  215. control={control}
  216. render={({ field }) => (
  217. <Item>
  218. <ListItemText primary={t("Proxy Type")} />
  219. <Autocomplete
  220. size="small"
  221. sx={{ minWidth: "240px" }}
  222. options={[
  223. "ss",
  224. "ssr",
  225. "direct",
  226. "dns",
  227. "snell",
  228. "http",
  229. "trojan",
  230. "hysteria",
  231. "hysteria2",
  232. "tuic",
  233. "wireguard",
  234. "ssh",
  235. "socks5",
  236. "vmess",
  237. "vless",
  238. ]}
  239. value={field.value}
  240. onChange={(_, value) => value && field.onChange(value)}
  241. renderInput={(params) => <TextField {...params} />}
  242. />
  243. </Item>
  244. )}
  245. />
  246. <Controller
  247. name="name"
  248. control={control}
  249. render={({ field }) => (
  250. <Item>
  251. <ListItemText primary={t("Proxy Name")} />
  252. <TextField
  253. autoComplete="off"
  254. size="small"
  255. sx={{ minWidth: "240px" }}
  256. {...field}
  257. required={true}
  258. />
  259. </Item>
  260. )}
  261. />
  262. <Controller
  263. name="server"
  264. control={control}
  265. render={({ field }) => (
  266. <Item>
  267. <ListItemText primary={t("Proxy Server")} />
  268. <TextField
  269. autoComplete="off"
  270. size="small"
  271. sx={{ minWidth: "240px" }}
  272. {...field}
  273. />
  274. </Item>
  275. )}
  276. />
  277. <Controller
  278. name="port"
  279. control={control}
  280. render={({ field }) => (
  281. <Item>
  282. <ListItemText primary={t("Proxy Port")} />
  283. <TextField
  284. autoComplete="off"
  285. type="number"
  286. size="small"
  287. sx={{ minWidth: "240px" }}
  288. onChange={(e) => {
  289. field.onChange(parseInt(e.target.value));
  290. }}
  291. />
  292. </Item>
  293. )}
  294. />
  295. </Box>
  296. <Item>
  297. <Button
  298. fullWidth
  299. variant="contained"
  300. onClick={() => {
  301. try {
  302. for (const item of prependSeq) {
  303. if (item.name === formIns.getValues().name) {
  304. throw new Error(t("Proxy Name Already Exists"));
  305. }
  306. }
  307. setPrependSeq([...prependSeq, formIns.getValues()]);
  308. } catch (err: any) {
  309. Notice.error(err.message || err.toString());
  310. }
  311. }}
  312. >
  313. {t("Prepend Proxy")}
  314. </Button>
  315. </Item>
  316. <Item>
  317. <Button
  318. fullWidth
  319. variant="contained"
  320. onClick={() => {
  321. try {
  322. for (const item of appendSeq) {
  323. if (item.name === formIns.getValues().name) {
  324. throw new Error(t("Proxy Name Already Exists"));
  325. }
  326. }
  327. setAppendSeq([...appendSeq, formIns.getValues()]);
  328. } catch (err: any) {
  329. Notice.error(err.message || err.toString());
  330. }
  331. }}
  332. >
  333. {t("Append Proxy")}
  334. </Button>
  335. </Item>
  336. </List>
  337. <List
  338. sx={{
  339. width: "50%",
  340. padding: "0 10px",
  341. }}
  342. >
  343. <BaseSearchBox
  344. matchCase={false}
  345. onSearch={(match) => setMatch(() => match)}
  346. />
  347. <Virtuoso
  348. style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
  349. totalCount={
  350. filteredProxyList.length +
  351. (prependSeq.length > 0 ? 1 : 0) +
  352. (appendSeq.length > 0 ? 1 : 0)
  353. }
  354. increaseViewportBy={256}
  355. itemContent={(index) => {
  356. let shift = prependSeq.length > 0 ? 1 : 0;
  357. if (prependSeq.length > 0 && index === 0) {
  358. return (
  359. <DndContext
  360. sensors={sensors}
  361. collisionDetection={closestCenter}
  362. onDragEnd={onPrependDragEnd}
  363. >
  364. <SortableContext
  365. items={prependSeq.map((x) => {
  366. return x.name;
  367. })}
  368. >
  369. {prependSeq.map((item, index) => {
  370. return (
  371. <ProxyItem
  372. key={`${item.name}-${index}`}
  373. type="prepend"
  374. proxy={item}
  375. onDelete={() => {
  376. setPrependSeq(
  377. prependSeq.filter(
  378. (v) => v.name !== item.name
  379. )
  380. );
  381. }}
  382. />
  383. );
  384. })}
  385. </SortableContext>
  386. </DndContext>
  387. );
  388. } else if (index < filteredProxyList.length + shift) {
  389. let newIndex = index - shift;
  390. return (
  391. <ProxyItem
  392. key={`${filteredProxyList[newIndex].name}-${index}`}
  393. type={
  394. deleteSeq.includes(filteredProxyList[newIndex].name)
  395. ? "delete"
  396. : "original"
  397. }
  398. proxy={filteredProxyList[newIndex]}
  399. onDelete={() => {
  400. if (
  401. deleteSeq.includes(filteredProxyList[newIndex].name)
  402. ) {
  403. setDeleteSeq(
  404. deleteSeq.filter(
  405. (v) => v !== filteredProxyList[newIndex].name
  406. )
  407. );
  408. } else {
  409. setDeleteSeq((prev) => [
  410. ...prev,
  411. filteredProxyList[newIndex].name,
  412. ]);
  413. }
  414. }}
  415. />
  416. );
  417. } else {
  418. return (
  419. <DndContext
  420. sensors={sensors}
  421. collisionDetection={closestCenter}
  422. onDragEnd={onAppendDragEnd}
  423. >
  424. <SortableContext
  425. items={appendSeq.map((x) => {
  426. return x.name;
  427. })}
  428. >
  429. {appendSeq.map((item, index) => {
  430. return (
  431. <ProxyItem
  432. key={`${item.name}-${index}`}
  433. type="append"
  434. proxy={item}
  435. onDelete={() => {
  436. setAppendSeq(
  437. appendSeq.filter(
  438. (v) => v.name !== item.name
  439. )
  440. );
  441. }}
  442. />
  443. );
  444. })}
  445. </SortableContext>
  446. </DndContext>
  447. );
  448. }
  449. }}
  450. />
  451. </List>
  452. </>
  453. ) : (
  454. <MonacoEditor
  455. height="100%"
  456. language="yaml"
  457. value={currData}
  458. theme={themeMode === "light" ? "vs" : "vs-dark"}
  459. options={{
  460. tabSize: 2, // 根据语言类型设置缩进大小
  461. minimap: {
  462. enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
  463. },
  464. mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
  465. quickSuggestions: {
  466. strings: true, // 字符串类型的建议
  467. comments: true, // 注释类型的建议
  468. other: true, // 其他类型的建议
  469. },
  470. padding: {
  471. top: 33, // 顶部padding防止遮挡snippets
  472. },
  473. fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
  474. getSystem() === "windows" ? ", twemoji mozilla" : ""
  475. }`,
  476. fontLigatures: true, // 连字符
  477. smoothScrolling: true, // 平滑滚动
  478. }}
  479. onChange={(value) => setCurrData(value)}
  480. />
  481. )}
  482. </DialogContent>
  483. <DialogActions>
  484. <Button onClick={onClose} variant="outlined">
  485. {t("Cancel")}
  486. </Button>
  487. <Button onClick={handleSave} variant="contained">
  488. {t("Save")}
  489. </Button>
  490. </DialogActions>
  491. </Dialog>
  492. );
  493. };
  494. const Item = styled(ListItem)(() => ({
  495. padding: "5px 2px",
  496. }));