Refactor UUID generation, remove unused logger and encryption utilities, and clean up request handling
- Updated `generateUUID` function for improved readability and performance. - Deleted `logger.ts`, `other.ts`, `request.ts`, `storage.ts`, `tansParams.ts`, and `validate.ts` as they were no longer needed. - Simplified TypeScript configuration by removing unnecessary paths and aliases. - Enhanced Vite configuration for better project structure and maintainability.
This commit is contained in:
576
src/pages/Scripts/components/ScriptDialogs.tsx
Normal file
576
src/pages/Scripts/components/ScriptDialogs.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AutomationScript, ScriptRecordingResult, ScriptSaveInput } from '../../../lib/script-types';
|
||||
import {
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
} from './icons';
|
||||
|
||||
type ScriptDraft = {
|
||||
name: string;
|
||||
description: string;
|
||||
channel: string;
|
||||
code: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type CreateScriptDialogProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: ScriptSaveInput) => Promise<void>;
|
||||
};
|
||||
|
||||
type EditScriptDialogProps = {
|
||||
open: boolean;
|
||||
script: AutomationScript | null;
|
||||
saving: boolean;
|
||||
recordingStatus: 'idle' | 'recording' | 'stopped';
|
||||
onClose: () => void;
|
||||
onSubmit: (input: ScriptSaveInput) => Promise<void>;
|
||||
onStartRecording: (url: string) => Promise<void>;
|
||||
onStopRecording: () => Promise<ScriptRecordingResult>;
|
||||
onFeedback: (message: string, tone?: 'info' | 'success' | 'error') => void;
|
||||
};
|
||||
|
||||
type ConfirmDeleteDialogProps = {
|
||||
open: boolean;
|
||||
scriptName?: string;
|
||||
deleting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
};
|
||||
|
||||
const CHANNEL_URLS = [
|
||||
'https://hotel.fliggy.com/ebooking/hotelBaseInfoUv.htm#/ebk/homeV1',
|
||||
'https://me.meituan.com/ebooking/merchant/product#/index',
|
||||
'https://life.douyin.com/p/travel-ari/hotel/price_amount_state?groupid=1816249020842116',
|
||||
];
|
||||
|
||||
const DEFAULT_SCRIPT_TEMPLATE = `import { chromium } from 'playwright';
|
||||
import { preparePage, safeDisconnectBrowser } from './common/tabs.js';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
|
||||
const { page } = await preparePage(browser, {
|
||||
targetUrl: 'about:blank',
|
||||
});
|
||||
|
||||
// Your automation code here
|
||||
|
||||
await safeDisconnectBrowser(browser);
|
||||
process.exit(0);
|
||||
})();
|
||||
`;
|
||||
|
||||
function buildDefaultScript(channel: string) {
|
||||
return DEFAULT_SCRIPT_TEMPLATE.replace('about:blank', channel.trim() || 'about:blank');
|
||||
}
|
||||
|
||||
function ModalFrame({
|
||||
open,
|
||||
title,
|
||||
subtitle,
|
||||
widthClassName,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
widthClassName: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex items-center justify-center bg-black/30 px-4 py-8 backdrop-blur-[1px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'max-h-full w-full overflow-hidden rounded-[24px] bg-[#f4f3eb] shadow-[0_24px_80px_rgba(15,23,42,0.18)] dark:bg-[#1f1f22]',
|
||||
widthClassName,
|
||||
].join(' ')}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-black/5 px-6 py-5 dark:border-white/5">
|
||||
<div>
|
||||
<h2
|
||||
className="mb-2 text-[24px] font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-[14px] text-[#99A0AE] dark:text-gray-500">{subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-180px)] overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<label className="mb-2 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function baseFieldClassName(multiline = false) {
|
||||
return [
|
||||
'w-full rounded-[14px] border border-transparent bg-[#eceae1] px-4 text-[13px] text-[#171717] outline-none transition-colors',
|
||||
'placeholder:text-[#171717]/45 focus:border-[#2B7FFF]/50 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500',
|
||||
multiline ? 'min-h-[108px] py-3 leading-6' : 'h-[46px]',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={[
|
||||
'relative inline-flex h-7 w-12 shrink-0 items-center rounded-full border transition-colors',
|
||||
checked ? 'border-[#2B7FFF] bg-[#2B7FFF]' : 'border-black/10 bg-black/10 dark:border-white/10 dark:bg-white/10',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'h-5 w-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-[22px]' : 'translate-x-[3px]',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateScriptDialog({
|
||||
open,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: CreateScriptDialogProps) {
|
||||
const [draft, setDraft] = useState<ScriptDraft>({
|
||||
name: '',
|
||||
description: '',
|
||||
channel: '',
|
||||
code: '',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDraft({
|
||||
name: '',
|
||||
description: '',
|
||||
channel: '',
|
||||
code: '',
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!draft.name.trim()) return;
|
||||
|
||||
await onSubmit({
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
channel: draft.channel.trim(),
|
||||
code: buildDefaultScript(draft.channel),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalFrame
|
||||
open={open}
|
||||
title="创建脚本"
|
||||
subtitle="先创建一个可保存的脚本,再继续录制或编辑 Playwright 代码。"
|
||||
widthClassName="max-w-[560px]"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>脚本名称</FieldLabel>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="例如:飞猪房态采集"
|
||||
className={baseFieldClassName()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>脚本描述</FieldLabel>
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))}
|
||||
placeholder="简要描述这个脚本做什么。"
|
||||
className={baseFieldClassName(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>渠道链接</FieldLabel>
|
||||
<input
|
||||
value={draft.channel}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, channel: event.target.value }))}
|
||||
placeholder="粘贴目标渠道 URL,可留空后续再补。"
|
||||
className={baseFieldClassName()}
|
||||
/>
|
||||
<p className="mt-2 text-[12px] text-[#99A0AE] dark:text-gray-500">
|
||||
创建后会尝试启动 codegen,并把录制结果带回编辑器。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[42px] items-center justify-center rounded-full bg-[#e8e6de] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#dfddd4] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[42px] items-center justify-center rounded-full bg-[#2B7FFF] px-6 text-[13px] font-semibold text-white transition-colors hover:bg-[#1769e0] disabled:cursor-wait disabled:opacity-70"
|
||||
onClick={() => {
|
||||
void handleSubmit();
|
||||
}}
|
||||
disabled={saving || !draft.name.trim()}
|
||||
>
|
||||
{saving ? '创建中...' : '创建并录制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditScriptDialog({
|
||||
open,
|
||||
script,
|
||||
saving,
|
||||
recordingStatus,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onFeedback,
|
||||
}: EditScriptDialogProps) {
|
||||
const [draft, setDraft] = useState<ScriptDraft>({
|
||||
name: '',
|
||||
description: '',
|
||||
channel: '',
|
||||
code: '',
|
||||
enabled: true,
|
||||
});
|
||||
const [recordingBusy, setRecordingBusy] = useState(false);
|
||||
const previousChannelRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !script) return;
|
||||
|
||||
const nextDraft = {
|
||||
name: script.name || '',
|
||||
description: script.description || '',
|
||||
channel: script.channel || '',
|
||||
code: script.code || '',
|
||||
enabled: script.enabled ?? true,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
previousChannelRef.current = nextDraft.channel;
|
||||
}, [open, script]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousChannel = previousChannelRef.current;
|
||||
if (!previousChannel || previousChannel === draft.channel) return;
|
||||
|
||||
setDraft((current) => {
|
||||
if (!current.code.includes(previousChannel)) {
|
||||
previousChannelRef.current = draft.channel;
|
||||
return current;
|
||||
}
|
||||
|
||||
previousChannelRef.current = draft.channel;
|
||||
return {
|
||||
...current,
|
||||
code: current.code.split(previousChannel).join(draft.channel),
|
||||
};
|
||||
});
|
||||
}, [draft.channel]);
|
||||
|
||||
const replaceChannelDisabled = useMemo(() => {
|
||||
if (!draft.channel.trim()) return true;
|
||||
return !CHANNEL_URLS.some((url) => draft.code.includes(url));
|
||||
}, [draft.channel, draft.code]);
|
||||
|
||||
if (!script) return null;
|
||||
|
||||
async function handleSave() {
|
||||
if (!draft.name.trim() || !draft.code.trim()) return;
|
||||
|
||||
await onSubmit({
|
||||
id: script.id,
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
channel: draft.channel.trim(),
|
||||
code: draft.code,
|
||||
enabled: draft.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
if (!draft.channel.trim()) {
|
||||
onFeedback('开始录制前请先填写渠道链接。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingBusy(true);
|
||||
try {
|
||||
await onStartRecording(draft.channel.trim());
|
||||
} finally {
|
||||
setRecordingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopRecording() {
|
||||
setRecordingBusy(true);
|
||||
try {
|
||||
const result = await onStopRecording();
|
||||
if (result.code) {
|
||||
setDraft((current) => ({ ...current, code: result.code || current.code }));
|
||||
}
|
||||
} finally {
|
||||
setRecordingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceKnownChannelUrls() {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
code: CHANNEL_URLS.reduce((source, url) => source.split(url).join(current.channel.trim()), current.code),
|
||||
}));
|
||||
onFeedback('已将代码中的已知渠道链接替换为当前输入值。', 'success');
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalFrame
|
||||
open={open}
|
||||
title="编辑脚本"
|
||||
subtitle="修改脚本信息、维护 Playwright 代码,并在需要时重新录制。"
|
||||
widthClassName="max-w-[820px]"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<FieldLabel>脚本名称</FieldLabel>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="输入脚本名称"
|
||||
className={baseFieldClassName()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>脚本描述</FieldLabel>
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))}
|
||||
placeholder="补充脚本用途、依赖页面或注意事项。"
|
||||
className={baseFieldClassName(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<FieldLabel>渠道链接</FieldLabel>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[12px] font-medium text-[#2B7FFF] transition-colors hover:text-[#1769e0] disabled:cursor-not-allowed disabled:text-[#99A0AE]"
|
||||
disabled={replaceChannelDisabled}
|
||||
onClick={replaceKnownChannelUrls}
|
||||
>
|
||||
同步替换代码中的渠道 URL
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={draft.channel}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, channel: event.target.value }))}
|
||||
placeholder="粘贴渠道 URL"
|
||||
className={baseFieldClassName()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<FieldLabel>脚本代码</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
{recordingStatus === 'recording' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center justify-center rounded-full bg-red-500 px-4 text-[12px] font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-wait disabled:opacity-70"
|
||||
onClick={() => {
|
||||
void handleStopRecording();
|
||||
}}
|
||||
disabled={recordingBusy}
|
||||
>
|
||||
<StopIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{recordingBusy ? '停止中...' : '停止录制'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-black/10 px-4 text-[12px] font-semibold text-[#171717] transition-colors hover:bg-black/5 dark:border-gray-700 dark:text-[#f3f4f6] dark:hover:bg-white/5 disabled:cursor-wait disabled:opacity-70"
|
||||
onClick={() => {
|
||||
void handleStartRecording();
|
||||
}}
|
||||
disabled={recordingBusy}
|
||||
>
|
||||
<PlayIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{recordingBusy ? '启动中...' : '开始录制'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recordingStatus === 'recording' ? (
|
||||
<p className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-700 dark:border-amber-900/60 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
录制进行中,请在新窗口中完成操作,结束后点击“停止录制”回填代码。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
value={draft.code}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, code: event.target.value }))}
|
||||
placeholder="// 在这里编写 Playwright 自动化脚本"
|
||||
spellCheck={false}
|
||||
className="min-h-[340px] w-full rounded-[18px] border border-transparent bg-[#eceae1] px-4 py-4 font-mono text-[13px] leading-6 text-[#171717] outline-none transition-colors placeholder:text-[#171717]/45 focus:border-[#2B7FFF]/50 dark:bg-[#0f0f10] dark:text-[#f3f4f6] dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-[18px] border border-black/5 bg-[#e8e6de]/60 px-4 py-4 dark:border-white/5 dark:bg-[#222225]">
|
||||
<div>
|
||||
<p className="text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">启用脚本</p>
|
||||
<p className="mt-1 text-[13px] text-[#99A0AE] dark:text-gray-500">保存后立即保持脚本可用状态。</p>
|
||||
</div>
|
||||
<Switch checked={draft.enabled} onChange={(value) => setDraft((current) => ({ ...current, enabled: value }))} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[42px] items-center justify-center rounded-full bg-[#e8e6de] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#dfddd4] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[42px] items-center justify-center rounded-full bg-[#2B7FFF] px-6 text-[13px] font-semibold text-white transition-colors hover:bg-[#1769e0] disabled:cursor-wait disabled:opacity-70"
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
}}
|
||||
disabled={saving || !draft.name.trim() || !draft.code.trim()}
|
||||
>
|
||||
<CheckIcon className="mr-1.5 h-4 w-4" />
|
||||
{saving ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDeleteDialog({
|
||||
open,
|
||||
scriptName,
|
||||
deleting,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
return (
|
||||
<ModalFrame
|
||||
open={open}
|
||||
title="删除脚本"
|
||||
subtitle="删除后脚本文件也会一并移除,请确认这就是你想要的操作。"
|
||||
widthClassName="max-w-[520px]"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[18px] border border-red-500/20 bg-red-500/8 px-4 py-4 text-[14px] leading-6 text-[#171717] dark:text-gray-200">
|
||||
{scriptName ? `确认删除 “${scriptName}” 吗?此操作无法撤销。` : '确认删除这个脚本吗?此操作无法撤销。'}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[42px] items-center justify-center rounded-full bg-[#e8e6de] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#dfddd4] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]"
|
||||
onClick={onClose}
|
||||
disabled={deleting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-[42px] items-center justify-center rounded-full bg-red-500 px-6 text-[13px] font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-wait disabled:opacity-70"
|
||||
onClick={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
Reference in New Issue
Block a user