- Implemented `cron` store to manage scheduled tasks with CRUD operations. - Created `locale` store for language settings with persistence and system language detection. - Added `providers` store to handle provider accounts and configurations with API interactions. - Developed `script` store for managing automation scripts, including recording and execution. - Introduced `sharedStore` for managing shared data across components. - Established `skills` store for fetching, installing, and managing skills from a marketplace. - Created `userinfo` store for user authentication and session management. chore: update path aliases from `@store` to `@stores` in TypeScript configuration and Vite config
277 lines
11 KiB
Vue
277 lines
11 KiB
Vue
<template>
|
|
<layout>
|
|
<div class="bg-white dark:bg-[#1b1b1d] 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] dark:text-[#f3f4f6] 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 dark:text-[#9ca3af] 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 dark:border-gray-700 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 dark:bg-white/5 rounded-full px-3 py-1.5 focus-within:bg-black/10 dark:focus-within:bg-white/10 transition-colors border border-transparent focus-within:border-black/10 dark:focus-within:border-white/10 mr-2">
|
|
<RiSearchLine class="h-4 w-4 shrink-0 text-[#525866] dark:text-gray-400" />
|
|
<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 dark:placeholder:text-gray-500 text-[13px] text-[#171717] dark:text-[#f3f4f6]"
|
|
/>
|
|
<button
|
|
v-if="searchQuery"
|
|
type="button"
|
|
@click="searchQuery = ''"
|
|
class="text-[#171717]/50 dark:text-gray-500 hover:text-[#171717] dark:hover:text-[#f3f4f6] 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] dark:text-[#f3f4f6]">
|
|
{{ t('script.stats.total', 'Total') }} {{ store.safeScripts.length }}
|
|
</span>
|
|
<span class="text-[#525866] dark:text-gray-400">
|
|
{{ t('script.stats.active', 'Active') }} {{ store.enabledScripts.length }}
|
|
</span>
|
|
<span class="text-[#525866] dark:text-gray-400">
|
|
{{ 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 dark:border-gray-700 bg-transparent hover:bg-black/5 dark:hover:bg-white/10 text-[#171717]/80 dark:text-[#f3f4f6]/80 hover:text-[#171717] dark:hover:text-[#f3f4f6] 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 dark:border-gray-700 bg-transparent hover:bg-black/5 dark:hover:bg-white/10 text-[#525866] dark:text-gray-400 hover:text-[#171717] dark:hover:text-[#f3f4f6] 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] dark:text-gray-400"
|
|
>
|
|
<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] dark:text-gray-400">
|
|
<RiCodeLine class="h-10 w-10 mb-4 opacity-50" />
|
|
<h3 class="text-lg font-medium mb-2 text-[#171717] dark:text-[#f3f4f6]">{{ 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/stores/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 lifecycle is handled by handleSave / handleEditDialogClose
|
|
}
|
|
|
|
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 {
|
|
const result = await store.saveScript(input);
|
|
ElMessage.success(t('script.toast.created'));
|
|
ElMessage.info(t('script.toast.codegenStarting', '正在启动 Playwright codegen 录制,请在新窗口中操作,完成后关闭窗口即可自动保存代码'));
|
|
const codegenResult = await store.codegen(result.id, input.channel || 'about:blank');
|
|
if (codegenResult.success) {
|
|
ElMessage.success(t('script.toast.codegenFinished', '录制完成,代码已保存'));
|
|
editingScript.value = { ...result, code: codegenResult.code || '' };
|
|
editDialogVisible.value = true;
|
|
} else {
|
|
ElMessage.error(codegenResult.error || t('script.toast.codegenFailed', '录制失败'));
|
|
}
|
|
}
|
|
} 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',
|
|
lockScroll: false,
|
|
closeOnClickModal: false,
|
|
},
|
|
);
|
|
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>
|