- Change dialog surfaces, drawers, and confirm dialogs from off-white (#F4F3EB) to white background - Standardize border radius from 3xl/36px to 2xl across multiple components - Update default setting view from 'account' to 'general' for better user experience - Adjust input field heights and backgrounds for improved visual consistency
577 lines
19 KiB
TypeScript
577 lines
19 KiB
TypeScript
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-white 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 };
|