Files
zn-ai/src/pages/Scripts/components/ScriptDialogs.tsx
DEV_DSW 6b5e84b7d7 style: update UI components to use white background and consistent border radius
- 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
2026-04-20 15:01:33 +08:00

577 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };