feat: 新增脚本录制功能

This commit is contained in:
duanshuwen
2026-04-12 15:46:28 +08:00
parent 66bb07faf2
commit c16fc93685
38 changed files with 3336 additions and 51 deletions

View File

@@ -0,0 +1,185 @@
<template>
<div
class="group flex flex-col p-5 rounded-2xl bg-white border border-black/[0.08] hover:border-black/[0.12] hover:bg-black/[0.03] transition-all relative overflow-hidden"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-4">
<div class="h-[46px] w-[46px] shrink-0 flex items-center justify-center text-[#171717] bg-black/5 border border-black/5 rounded-full shadow-sm group-hover:scale-105 transition-transform">
<RiCodeLine :class="['h-5 w-5', script.enabled ? 'text-[#171717]' : 'text-[#99A0AE]']" />
</div>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-[16px] font-semibold text-[#171717] truncate">{{ script.name }}</h3>
<div
:class="[
'w-2 h-2 rounded-full shrink-0',
script.enabled ? 'bg-green-500' : 'bg-[#99A0AE]'
]"
:title="script.enabled ? t('script.stats.active') : t('script.stats.total')"
/>
</div>
<p v-if="props.script.channel && props.script.channel !== 'common'" class="text-[13px] text-[#525866] flex items-center gap-1.5">
<RiMapPinLine class="h-3.5 w-3.5" />
{{ channelLabel }}
</p>
</div>
</div>
<div class="flex items-center gap-2" @click.stop>
<el-switch
:model-value="script.enabled"
@update:model-value="handleToggle"
/>
</div>
</div>
<div class="flex-1 flex flex-col justify-end mt-2 pl-[62px]">
<div class="flex items-start gap-2 mb-3">
<RiFileTextLine class="h-3.5 w-3.5 mt-0.5 text-[#99A0AE] shrink-0" />
<p class="text-[13.5px] text-[#525866] line-clamp-2 leading-[1.5]">
{{ script.description || t('script.dialog.descriptionPlaceholder') }}
</p>
</div>
<!-- Metadata -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-[12px] text-[#99A0AE] font-medium mb-3">
<span v-if="script.lastRun" class="flex items-center gap-1.5">
<RiHistoryLine class="h-3.5 w-3.5" />
{{ t('script.card.last') }}: {{ formatRelativeTime(script.lastRun.time) }}
<RiCheckLine v-if="script.lastRun.success" class="h-3.5 w-3.5 text-green-500" />
<RiCloseLine v-else class="h-3.5 w-3.5 text-red-500" />
</span>
</div>
<!-- Execution Log -->
<div v-if="executionLog" class="mb-3">
<div
:class="[
'p-2.5 rounded-xl text-[12px] font-mono overflow-y-auto max-h-[120px] border',
executionLog.success ? 'bg-green-500/5 border-green-500/20 text-green-700' : 'bg-red-500/10 border-red-500/20 text-red-600'
]"
>
<pre class="whitespace-pre-wrap break-all">{{ executionLog.stdoutTail || executionLog.stderrTail || executionLog.error || '-' }}</pre>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-auto">
<el-button
text
class="!h-8 !px-3 !text-[13px] !font-medium !rounded-lg"
@click.stop="handleTest"
:loading="testing"
>
<template #icon>
<RiPlayLine class=" mr-1.5" />
</template>
{{ t('script.card.test') }}
</el-button>
<el-button
text
class="!h-8 !px-3 !text-[13px] !font-medium !rounded-lg"
@click.stop="handleEdit"
>
<template #icon>
<RiPencilLine class=" mr-1.5" />
</template>
{{ t('common.edit', 'Edit') }}
</el-button>
<el-button
text
type="danger"
class="!h-8 !px-3 !text-[13px] !font-medium !rounded-lg"
@click.stop="handleDelete"
>
<template #icon>
<RiDeleteBinLine class="mr-1.5" />
</template>
{{ t('common.delete', 'Delete') }}
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RiCodeLine,
RiMapPinLine,
RiFileTextLine,
RiHistoryLine,
RiCheckLine,
RiCloseLine,
RiPlayLine,
RiDeleteBinLine,
RiPencilLine,
} from '@remixicon/vue';
import type { AutomationScript, ScriptExecutionResult } from '@lib/script-types';
const { t } = useI18n();
const props = defineProps<{
script: AutomationScript;
executionLog?: ScriptExecutionResult;
}>();
const emit = defineEmits<{
(e: 'toggle', enabled: boolean): void;
(e: 'edit'): void;
(e: 'delete'): void;
(e: 'test'): Promise<void>;
}>();
const testing = ref(false);
const channelLabel = computed(() => {
const map: Record<string, string> = {
fliggy: '飞猪',
meituan: '美团',
douyin: '抖音',
common: t('script.card.common'),
};
return map[props.script.channel] || props.script.channel;
});
function handleEdit() {
emit('edit');
}
function handleToggle(enabled: boolean) {
emit('toggle', enabled);
}
function handleDelete() {
emit('delete');
}
async function handleTest() {
testing.value = true;
try {
await emit('test');
} finally {
testing.value = false;
}
}
function formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
const diffHour = Math.round(diffMin / 60);
const diffDay = Math.round(diffHour / 24);
if (diffSec < 10) return t('common.timeAgo.justNow', 'just now');
if (diffMin < 1) return t('common.timeAgo.seconds', { count: diffSec }, `${diffSec}秒前`);
if (diffHour < 1) return t('common.timeAgo.minutes', { count: diffMin }, `${diffMin}分钟前`);
if (diffDay < 1) return t('common.timeAgo.hours', { count: diffHour }, `${diffHour}小时前`);
if (diffDay < 30) return t('common.timeAgo.days', { count: diffDay }, `${diffDay}天前`);
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(date);
}
</script>

View File

@@ -0,0 +1,223 @@
<template>
<el-dialog
v-model="visible"
width="520px"
:show-close="false"
class="custom-script-dialog"
@closed="handleClosed"
>
<!-- Header -->
<template #header>
<div class="sticky top-0 z-10 bg-[#F4F3EB] flex justify-between items-start">
<div>
<h2 class="text-[24px] font-serif text-[#171717] mb-[8px] font-normal tracking-tight">
{{ t('script.dialog.createTitle') }}
</h2>
<p class="text-[14px] text-[#99A0AE]">
{{ t('script.dialog.description') }}
</p>
</div>
<button @click="visible = false" class="text-[#99A0AE] hover:text-[#171717] transition-colors mt-[4px]">
<el-icon class="text-[20px] cursor-pointer"><Close /></el-icon>
</button>
</div>
</template>
<!-- Form -->
<div class="px-[30px] pb-[30px] pt-[30px] space-y-6">
<!-- Name -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('script.dialog.name') }}</label>
<el-input
v-model="form.name"
:placeholder="t('script.dialog.namePlaceholder')"
class="!h-[44px]"
/>
</div>
<!-- Description -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('script.dialog.descriptionLabel') }}</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
:placeholder="t('script.dialog.descriptionPlaceholder')"
resize="none"
/>
</div>
<!-- Channel -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('script.dialog.channel') }}</label>
<el-input
v-model="form.channel"
:placeholder="t('script.dialog.channel')"
class="!h-[44px]"
/>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<el-button
@click="visible = false"
class="!rounded-full !px-6 !h-[42px] !text-[13px] !font-semibold cancel-btn"
>
{{ t('common.dialog.cancel') }}
</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="saving"
class="!rounded-full !px-6 !h-[42px] !text-[13px] !font-semibold"
>
{{ saving ? t('common.saving', 'Saving...') : t('script.dialog.createTitle') }}
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Close } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import type { ScriptSaveInput } from '@lib/script-types';
const { t } = useI18n();
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'closed'): void;
(e: 'save', payload: ScriptSaveInput): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const saving = ref(false);
const defaultScriptTemplate = `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);
})();
`;
const form = ref({
name: '',
description: '',
channel: '',
enabled: true,
});
function resetForm() {
form.value = {
name: '',
description: '',
channel: '',
enabled: true,
};
}
function handleSubmit() {
if (!form.value.name.trim()) {
ElMessage.error(t('script.toast.nameRequired'));
return;
}
saving.value = true;
try {
const payload: ScriptSaveInput = {
name: form.value.name.trim(),
description: form.value.description.trim(),
code: defaultScriptTemplate,
channel: form.value.channel,
enabled: form.value.enabled,
};
emit('save', payload);
visible.value = false;
} finally {
saving.value = false;
}
}
function handleClosed() {
resetForm();
emit('closed');
}
</script>
<style>
.custom-script-dialog {
background-color: #F4F3EB !important;
border-radius: 20px !important;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
}
.custom-script-dialog .el-dialog__body {
padding: 0 !important;
max-height: calc(100vh - 360px);
overflow-y: auto;
}
.custom-script-dialog .el-dialog__body::-webkit-scrollbar {
width: 6px;
}
.custom-script-dialog .el-dialog__body::-webkit-scrollbar-thumb {
background-color: #D1CFC7;
border-radius: 3px;
}
/* Input / Textarea styling */
.custom-script-dialog .el-input__wrapper,
.custom-script-dialog .el-textarea__inner {
background-color: #EDECE4 !important;
border-radius: 12px !important;
box-shadow: none !important;
border: 1px solid transparent !important;
color: #171717 !important;
}
.custom-script-dialog .el-input__wrapper.is-focus,
.custom-script-dialog .el-textarea__inner:focus {
border-color: #3B6DE8 !important;
}
.custom-script-dialog .el-input__inner {
color: #171717 !important;
}
.custom-script-dialog .el-input__inner::placeholder,
.custom-script-dialog .el-textarea__inner::placeholder {
color: #99A0AE !important;
}
/* Select styling */
.custom-script-dialog .el-select .el-input__wrapper {
background-color: #EDECE4 !important;
}
/* Cancel button */
.cancel-btn {
background-color: #EDECE4 !important;
border-color: transparent !important;
color: #4B4B4B !important;
}
.cancel-btn:hover {
background-color: #E5E4DC !important;
color: #171717 !important;
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<el-dialog
v-model="visible"
width="720px"
:show-close="false"
class="custom-script-dialog"
@closed="handleClosed"
>
<!-- Header -->
<template #header>
<div class="sticky top-0 z-10 bg-[#F4F3EB] flex justify-between items-start">
<div>
<h2 class="text-[24px] font-serif text-[#171717] mb-[8px] font-normal tracking-tight">
{{ t('script.dialog.editTitle') }}
</h2>
<p class="text-[14px] text-[#99A0AE]">
{{ t('script.dialog.description') }}
</p>
</div>
<button @click="visible = false" class="text-[#99A0AE] hover:text-[#171717] transition-colors mt-[4px]">
<el-icon class="text-[20px] cursor-pointer"><Close /></el-icon>
</button>
</div>
</template>
<!-- Form -->
<div class="px-[30px] pb-[30px] pt-[30px] space-y-6">
<!-- Name -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('script.dialog.name') }}</label>
<el-input
v-model="form.name"
:placeholder="t('script.dialog.namePlaceholder')"
class="!h-[44px]"
/>
</div>
<!-- Description -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('script.dialog.descriptionLabel') }}</label>
<el-input
v-model="form.description"
type="textarea"
:rows="2"
:placeholder="t('script.dialog.descriptionPlaceholder')"
resize="none"
/>
</div>
<!-- Channel -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('script.dialog.channel') }}</label>
<el-input
v-model="form.channel"
:placeholder="t('script.dialog.channelPlaceholder') || '粘贴渠道链接 / Paste channel URL'"
class="!h-[44px]"
/>
</div>
<!-- Code Editor -->
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('script.dialog.code') }}</label>
<div class="flex items-center gap-2">
<el-button
v-if="store.recordingStatus !== 'recording'"
size="small"
class="!rounded-full !px-4 !h-8 !text-[12px] !font-medium"
@click="handleStartRecording"
:loading="startingRecording"
>
<template #icon>
<RiVideoLine class="h-3.5 w-3.5 mr-1" />
</template>
{{ t('script.dialog.recordStart') }}
</el-button>
<el-button
v-else
size="small"
type="danger"
class="!rounded-full !px-4 !h-8 !text-[12px] !font-medium"
@click="handleStopRecording"
:loading="stoppingRecording"
>
<template #icon>
<RiStopLine class="h-3.5 w-3.5 mr-1" />
</template>
{{ t('script.dialog.recordStop') }}
</el-button>
</div>
</div>
<p v-if="store.recordingStatus === 'recording'" class="text-[12px] text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
{{ t('script.dialog.recordTip') }}
</p>
<div class="rounded-[12px] overflow-hidden border border-transparent h-[320px] script-editor-container">
<Codemirror
v-model="form.code"
placeholder="// Write your Playwright script here..."
:style="{ height: '100%' }"
:autofocus="false"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
/>
</div>
</div>
<!-- Enabled -->
<div class="flex items-center justify-between bg-[#E8E6DE]/50 p-4 rounded-2xl border border-black/5">
<div>
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('cron.dialog.enableImmediately') }}</label>
<p class="text-[13px] text-[#99A0AE] mt-0.5">
{{ t('cron.dialog.enableImmediatelyDesc') }}
</p>
</div>
<el-switch v-model="form.enabled" />
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<el-button
@click="visible = false"
class="!rounded-full !px-6 !h-[42px] !text-[13px] !font-semibold cancel-btn"
>
{{ t('common.dialog.cancel') }}
</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="saving"
class="!rounded-full !px-6 !h-[42px] !text-[13px] !font-semibold"
>
{{ saving ? t('common.saving', 'Saving...') : t('script.dialog.saveChanges') }}
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Close } from '@element-plus/icons-vue';
import { RiVideoLine, RiStopLine } from '@remixicon/vue';
import { ElMessage } from 'element-plus';
import { Codemirror } from 'vue-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import type { AutomationScript, ScriptSaveInput } from '@lib/script-types';
import { useScriptStore } from '@src/store/script';
import { channels } from '@constant/channel';
const { t } = useI18n();
const store = useScriptStore();
const props = defineProps<{
modelValue: boolean;
script: AutomationScript;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'closed'): void;
(e: 'save', payload: ScriptSaveInput): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const saving = ref(false);
const startingRecording = ref(false);
const stoppingRecording = ref(false);
const extensions = [javascript(), oneDark];
const form = ref({
name: props.script.name || '',
description: props.script.description || '',
channel: getChannelUrl(props.script.channel) || props.script.channel || '',
code: props.script.code || '',
enabled: props.script.enabled ?? true,
});
watch(() => props.script, (script) => {
form.value = {
name: script.name || '',
description: script.description || '',
channel: getChannelUrl(script.channel) || script.channel || '',
code: script.code || '',
enabled: script.enabled ?? true,
};
}, { immediate: true });
watch(() => form.value.channel, (newUrl) => {
if (!newUrl) return;
channels.forEach((c: any) => {
if (form.value.code.includes(c.channelUrl)) {
form.value.code = form.value.code.split(c.channelUrl).join(newUrl);
}
});
});
function getChannelUrl(channel: string): string | undefined {
const item = channels.find((c) => c.channelName === channel);
return item?.channelUrl;
}
async function handleStartRecording() {
startingRecording.value = true;
try {
const url = form.value.channel;
if (!url) {
ElMessage.error(t('script.toast.channelRequired', '请输入渠道链接地址'));
return;
}
const result = await store.startRecording(url);
if (!result.success) {
ElMessage.error(result.error || t('script.toast.runFailed', { error: '' }));
} else {
ElMessage.info(t('script.dialog.recordTip'));
}
} catch (err: any) {
ElMessage.error(err?.message || t('script.toast.runFailed', { error: '' }));
} finally {
startingRecording.value = false;
}
}
async function handleStopRecording() {
stoppingRecording.value = true;
try {
await store.stopRecording();
} catch (err: any) {
ElMessage.error(err?.message || t('script.toast.runFailed', { error: '' }));
} finally {
stoppingRecording.value = false;
}
}
function handleSubmit() {
if (!form.value.name.trim()) {
ElMessage.error(t('script.toast.nameRequired'));
return;
}
if (!form.value.code.trim()) {
ElMessage.error(t('script.toast.codeRequired'));
return;
}
saving.value = true;
try {
const payload: ScriptSaveInput = {
id: props.script.id,
name: form.value.name.trim(),
description: form.value.description.trim(),
code: form.value.code,
channel: form.value.channel,
enabled: form.value.enabled,
};
emit('save', payload);
visible.value = false;
} finally {
saving.value = false;
}
}
function handleClosed() {
emit('closed');
}
</script>
<style>
.custom-script-dialog {
background-color: #F4F3EB !important;
border-radius: 20px !important;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
}
.custom-script-dialog .el-dialog__body {
padding: 0 !important;
max-height: calc(100vh - 360px);
overflow-y: auto;
}
.custom-script-dialog .el-dialog__body::-webkit-scrollbar {
width: 6px;
}
.custom-script-dialog .el-dialog__body::-webkit-scrollbar-thumb {
background-color: #D1CFC7;
border-radius: 3px;
}
/* Input / Textarea styling */
.custom-script-dialog .el-input__wrapper,
.custom-script-dialog .el-textarea__inner {
background-color: #EDECE4 !important;
border-radius: 12px !important;
box-shadow: none !important;
border: 1px solid transparent !important;
color: #171717 !important;
}
.custom-script-dialog .el-input__wrapper.is-focus,
.custom-script-dialog .el-textarea__inner:focus {
border-color: #3B6DE8 !important;
}
.custom-script-dialog .el-input__inner {
color: #171717 !important;
}
.custom-script-dialog .el-input__inner::placeholder,
.custom-script-dialog .el-textarea__inner::placeholder {
color: #99A0AE !important;
}
/* Select styling */
.custom-script-dialog .el-select .el-input__wrapper {
background-color: #EDECE4 !important;
}
/* Toggle switch */
.custom-script-dialog .el-switch.is-checked .el-switch__core {
background-color: #3B6DE8 !important;
border-color: #3B6DE8 !important;
}
/* Cancel button */
.cancel-btn {
background-color: #EDECE4 !important;
border-color: transparent !important;
color: #4B4B4B !important;
}
.cancel-btn:hover {
background-color: #E5E4DC !important;
color: #171717 !important;
}
/* CodeMirror container */
.script-editor-container {
background-color: #EDECE4;
}
.script-editor-container .cm-editor {
height: 100%;
background-color: #EDECE4;
}
.script-editor-container .cm-editor.cm-focused {
outline: none;
}
.script-editor-container .cm-scroller {
font-family: 'Fira Code', Consolas, Monaco, 'Courier New', monospace;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-8">
<div class="p-5 rounded-[24px] bg-[#F4F3EB]/60 border border-black/5">
<div class="text-[12px] text-[#525866] font-medium mb-1">{{ t('script.stats.total') }}</div>
<div class="text-[28px] font-serif text-[#171717]">{{ total }}</div>
</div>
<div class="p-5 rounded-[24px] bg-[#F4F3EB]/60 border border-black/5">
<div class="text-[12px] text-[#525866] font-medium mb-1">{{ t('script.stats.active') }}</div>
<div class="text-[28px] font-serif text-[#171717]">{{ active }}</div>
</div>
<div class="p-5 rounded-[24px] bg-[#F4F3EB]/60 border border-black/5">
<div class="text-[12px] text-[#525866] font-medium mb-1">{{ t('script.stats.failed') }}</div>
<div class="text-[28px] font-serif text-[#171717]">{{ failed }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
defineProps<{
total: number;
active: number;
failed: number;
}>();
const { t } = useI18n();
</script>

265
src/pages/scripts/index.vue Normal file
View File

@@ -0,0 +1,265 @@
<template>
<layout>
<div class="bg-white box-border w-full h-full flex rounded-[16px] overflow-hidden">
<div class="w-full flex flex-col h-full p-10 pt-12">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-start justify-between mb-6 shrink-0 gap-4">
<div>
<h1
class="text-5xl md:text-6xl font-serif text-[#171717] mb-3 font-normal tracking-tight"
style="font-family: Georgia, Cambria, 'Times New Roman', Times, serif"
>
{{ t('script.title') }}
</h1>
<p class="text-[17px] text-[#171717]/70 font-medium">
{{ t('script.subtitle') }}
</p>
</div>
</div>
<!-- Sub Navigation and Actions -->
<div class="flex flex-col md:flex-row md:items-center justify-between border-b border-black/10 pb-4 mb-4 shrink-0 gap-4">
<div class="flex items-center flex-wrap gap-4 text-[14px]">
<div class="relative group flex items-center bg-black/5 rounded-full px-3 py-1.5 focus-within:bg-black/10 transition-colors border border-transparent focus-within:border-black/10 mr-2">
<RiSearchLine class="h-4 w-4 shrink-0 text-[#525866]" />
<input
:placeholder="t('script.search', 'Search scripts...')"
v-model="searchQuery"
class="ml-2 bg-transparent outline-none w-28 md:w-40 font-normal placeholder:text-[#171717]/50 text-[13px] text-[#171717]"
/>
<button
v-if="searchQuery"
type="button"
@click="searchQuery = ''"
class="text-[#171717]/50 hover:text-[#171717] shrink-0 ml-1"
>
<RiCloseLine class="h-3.5 w-3.5" />
</button>
</div>
<div class="flex items-center gap-4">
<span class="font-medium text-[#171717]">
{{ t('script.stats.total', 'Total') }} {{ store.safeScripts.length }}
</span>
<span class="text-[#525866]">
{{ t('script.stats.active', 'Active') }} {{ store.enabledScripts.length }}
</span>
<span class="text-[#525866]">
{{ t('script.stats.failed', 'Failed') }} {{ store.safeScripts.filter((s) => s.lastRun && !s.lastRun.success).length }}
</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="openCreateDialog"
class="shrink-0 text-[13px] font-medium px-4 h-8 rounded-md border border-black/10 bg-transparent hover:bg-black/5 text-[#171717]/80 hover:text-[#171717] flex items-center justify-center transition-colors"
>
<RiAddLine class="h-4 w-4 mr-2" />
{{ t('script.newScript') }}
</button>
<button
@click="store.fetchScripts()"
class="shrink-0 text-[13px] font-medium h-8 w-8 rounded-md border border-black/10 bg-transparent hover:bg-black/5 text-[#525866] hover:text-[#171717] flex items-center justify-center transition-colors"
:title="t('script.refresh')"
>
<RiRefreshLine :class="['h-4 w-4', store.loading && 'animate-spin']" />
</button>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2">
<!-- Error Display -->
<div
v-if="store.error"
class="mb-4 p-4 rounded-xl border border-red-500/50 bg-red-500/10 text-red-600 text-sm font-medium flex items-center gap-2"
>
<RiErrorWarningLine class="h-5 w-5 shrink-0" />
<span>{{ store.error }}</span>
</div>
<!-- Loading -->
<div
v-if="store.loading && store.safeScripts.length === 0"
class="flex flex-col items-center justify-center h-full text-[#525866]"
>
<RiRefreshLine class="h-10 w-10 animate-spin mb-4" />
<p>{{ t('common.loading', 'Loading...') }}</p>
</div>
<template v-else>
<!-- Scripts List -->
<div v-if="filteredScripts.length === 0" class="flex flex-col items-center justify-center py-20 text-[#525866]">
<RiCodeLine class="h-10 w-10 mb-4 opacity-50" />
<h3 class="text-lg font-medium mb-2 text-[#171717]">{{ t('script.empty.title') }}</h3>
<p class="text-[14px] text-center mb-6 max-w-md">
{{ t('script.empty.description') }}
</p>
<button
@click="openCreateDialog"
class="rounded-full px-6 h-10 bg-[#2B7FFF] hover:bg-[#2B7FFF]/90 text-white flex items-center justify-center text-[13px] font-medium"
>
<RiAddLine class="h-4 w-4 mr-2" />
{{ t('script.empty.create') }}
</button>
</div>
<div v-else class="flex flex-col gap-1">
<ScriptCard
v-for="script in filteredScripts"
:key="script.id"
:script="script"
:execution-log="store.executionLogs[script.id]"
@toggle="(enabled) => handleToggle(script.id, enabled)"
@edit="() => openEditDialog(script)"
@delete="() => confirmDelete(script.id)"
@test="() => handleTest(script.id)"
/>
</div>
</template>
</div>
</div>
</div>
<!-- Create Dialog -->
<ScriptCreateDialog
v-model="createDialogVisible"
@save="handleSave"
@closed="handleCreateDialogClose"
/>
<!-- Edit Dialog -->
<ScriptEditorDialog
v-if="editingScript"
v-model="editDialogVisible"
:script="editingScript"
@save="handleSave"
@closed="handleEditDialogClose"
/>
</layout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
RiRefreshLine,
RiAddLine,
RiSearchLine,
RiCloseLine,
RiErrorWarningLine,
RiCodeLine,
} from '@remixicon/vue';
import { useScriptStore } from '@src/store/script';
import type { AutomationScript, ScriptSaveInput } from '@lib/script-types';
import ScriptCard from './components/ScriptCard.vue';
import ScriptEditorDialog from './components/ScriptEditorDialog.vue';
import ScriptCreateDialog from './components/ScriptCreateDialog.vue';
const { t } = useI18n();
const store = useScriptStore();
const searchQuery = ref('');
const createDialogVisible = ref(false);
const editDialogVisible = ref(false);
const editingScript = ref<AutomationScript | undefined>(undefined);
const filteredScripts = computed(() => {
const q = searchQuery.value.toLowerCase().trim();
if (q.length === 0) return store.safeScripts;
return store.safeScripts.filter((script: AutomationScript) => {
return (
script.name?.toLowerCase().includes(q) ||
script.description?.toLowerCase().includes(q) ||
script.id?.toLowerCase().includes(q)
);
});
});
onMounted(() => {
store.fetchScripts();
});
function openCreateDialog() {
createDialogVisible.value = true;
}
function openEditDialog(script: AutomationScript) {
editingScript.value = script;
editDialogVisible.value = true;
}
function handleCreateDialogClose() {
editingScript.value = undefined;
}
function handleEditDialogClose() {
editingScript.value = undefined;
store.resetRecording();
}
async function handleSave(input: ScriptSaveInput) {
try {
if (editingScript.value) {
await store.saveScript(input);
ElMessage.success(t('script.toast.updated'));
} else {
await store.saveScript(input);
ElMessage.success(t('script.toast.created'));
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ElMessage.error(msg);
}
}
async function handleToggle(id: string, enabled: boolean) {
try {
await store.toggleScript(id, enabled);
ElMessage.success(enabled ? t('script.toast.enabled') : t('script.toast.disabled'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ElMessage.error(msg || t('script.toast.runFailed', { error: '' }));
}
}
async function handleTest(id: string) {
try {
const result = await store.runScript(id);
if (result.success) {
ElMessage.success(t('script.toast.runSuccess'));
} else {
ElMessage.error(t('script.toast.runFailed', { error: result.error || '' }));
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ElMessage.error(t('script.toast.runFailed', { error: msg }));
}
}
async function confirmDelete(id: string) {
try {
await ElMessageBox.confirm(
t('script.card.deleteConfirm'),
t('common.confirm', 'Confirm'),
{
confirmButtonText: t('common.delete', 'Delete'),
cancelButtonText: t('common.cancel', 'Cancel'),
type: 'warning',
},
);
await store.deleteScript(id);
ElMessage.success(t('script.toast.deleted'));
} catch (err) {
if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) {
const msg = err instanceof Error ? err.message : String(err);
if (msg && msg !== 'cancel') {
ElMessage.error(msg || t('script.toast.runFailed', { error: '' }));
}
}
}
}
</script>