feat: 新增脚本录制功能
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { RiHomeLine, RiFileEditLine, RiCpuLine, RiSettingsLine, RiPuzzle2Line, RiTimeLine } from '@remixicon/vue'
|
||||
import { RiHomeLine, RiFileEditLine, RiCpuLine, RiSettingsLine, RiPuzzle2Line, RiTimeLine, RiCodeLine } from '@remixicon/vue'
|
||||
|
||||
// 菜单列表申明
|
||||
export interface MenuItem {
|
||||
@@ -53,6 +53,14 @@ export const menus: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '脚本',
|
||||
icon: RiCodeLine,
|
||||
color: '#525866',
|
||||
activeColor: '#2B7FFF',
|
||||
url: '/scripts',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '设置',
|
||||
icon: RiSettingsLine,
|
||||
color: '#525866',
|
||||
|
||||
@@ -22,5 +22,6 @@ export const NAMESPACES = [
|
||||
'models',
|
||||
'skills',
|
||||
'cron',
|
||||
'script',
|
||||
] as const;
|
||||
export type Namespace = (typeof NAMESPACES)[number];
|
||||
54
src/i18n/locales/en/script.json
Normal file
54
src/i18n/locales/en/script.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"title": "Scripts",
|
||||
"subtitle": "Record, edit and run automation scripts",
|
||||
"newScript": "New Script",
|
||||
"refresh": "Refresh",
|
||||
"stats": {
|
||||
"total": "Total Scripts",
|
||||
"active": "Enabled",
|
||||
"failed": "Recently Failed"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No scripts yet",
|
||||
"description": "Create or record automation scripts to extend platform support. Scripts run on Playwright.",
|
||||
"create": "Create your first script"
|
||||
},
|
||||
"card": {
|
||||
"test": "Test",
|
||||
"run": "Run",
|
||||
"deleteConfirm": "Are you sure you want to delete this script? The script file will also be removed.",
|
||||
"last": "Last run",
|
||||
"channel": "Channel",
|
||||
"common": "Common"
|
||||
},
|
||||
"dialog": {
|
||||
"createTitle": "New Script",
|
||||
"editTitle": "Edit Script",
|
||||
"description": "Write or record a Playwright automation script",
|
||||
"name": "Script Name",
|
||||
"namePlaceholder": "e.g. Fliggy room type update",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Briefly describe what this script does...",
|
||||
"channel": "Target Channel",
|
||||
"channelPlaceholder": "Paste channel URL",
|
||||
"channelCommon": "Common",
|
||||
"code": "Script Code",
|
||||
"recordStart": "Start Recording",
|
||||
"recordStop": "Stop Recording",
|
||||
"recording": "Recording...",
|
||||
"recordTip": "Recording will open the Playwright Inspector. Record actions in the Inspector and copy the generated code into the editor.",
|
||||
"saveChanges": "Save Changes"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Script created",
|
||||
"updated": "Script updated",
|
||||
"enabled": "Script enabled",
|
||||
"disabled": "Script disabled",
|
||||
"deleted": "Script deleted",
|
||||
"runSuccess": "Script ran successfully",
|
||||
"runFailed": "Script failed: {{error}}",
|
||||
"nameRequired": "Please enter a script name",
|
||||
"codeRequired": "Please enter script code",
|
||||
"channelRequired": "Please enter a channel URL"
|
||||
}
|
||||
}
|
||||
54
src/i18n/locales/ja/script.json
Normal file
54
src/i18n/locales/ja/script.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"title": "スクリプト管理",
|
||||
"subtitle": "自動化スクリプトの録画、編集、実行",
|
||||
"newScript": "新規スクリプト",
|
||||
"refresh": "更新",
|
||||
"stats": {
|
||||
"total": "総スクリプト数",
|
||||
"active": "有効",
|
||||
"failed": "最近の失敗"
|
||||
},
|
||||
"empty": {
|
||||
"title": "スクリプトがありません",
|
||||
"description": "自動化スクリプトを作成または録画して、各種プラットフォームへの対応を拡張します。スクリプトは Playwright で実行されます。",
|
||||
"create": "最初のスクリプトを作成"
|
||||
},
|
||||
"card": {
|
||||
"test": "テスト",
|
||||
"run": "実行",
|
||||
"deleteConfirm": "このスクリプトを削除してもよろしいですか?対応するスクリプトファイルも削除されます。",
|
||||
"last": "前回の実行",
|
||||
"channel": "チャンネル",
|
||||
"common": "汎用"
|
||||
},
|
||||
"dialog": {
|
||||
"createTitle": "新規スクリプト",
|
||||
"editTitle": "スクリプト編集",
|
||||
"description": "Playwright 自動化スクリプトを作成または録画",
|
||||
"name": "スクリプト名",
|
||||
"namePlaceholder": "例:Fliggy 部屋タイプ変更",
|
||||
"descriptionLabel": "説明",
|
||||
"descriptionPlaceholder": "スクリプトの用途を簡潔に説明...",
|
||||
"channel": "対象チャンネル",
|
||||
"channelPlaceholder": "チャンネルURLを貼り付け",
|
||||
"channelCommon": "汎用",
|
||||
"code": "スクリプトコード",
|
||||
"recordStart": "録画開始",
|
||||
"recordStop": "録画停止",
|
||||
"recording": "録画中...",
|
||||
"recordTip": "録画すると Playwright Inspector が開きます。Inspector で操作を録画し、生成されたコードをエディタに貼り付けてください。",
|
||||
"saveChanges": "変更を保存"
|
||||
},
|
||||
"toast": {
|
||||
"created": "スクリプトを作成しました",
|
||||
"updated": "スクリプトを更新しました",
|
||||
"enabled": "スクリプトを有効にしました",
|
||||
"disabled": "スクリプトを無効にしました",
|
||||
"deleted": "スクリプトを削除しました",
|
||||
"runSuccess": "スクリプトの実行に成功しました",
|
||||
"runFailed": "スクリプトの実行に失敗しました: {{error}}",
|
||||
"nameRequired": "スクリプト名を入力してください",
|
||||
"codeRequired": "スクリプトコードを入力してください",
|
||||
"channelRequired": "チャンネルURLを入力してください"
|
||||
}
|
||||
}
|
||||
54
src/i18n/locales/zh/script.json
Normal file
54
src/i18n/locales/zh/script.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"title": "脚本管理",
|
||||
"subtitle": "录制、编辑和运行自动化脚本",
|
||||
"newScript": "新建脚本",
|
||||
"refresh": "刷新",
|
||||
"stats": {
|
||||
"total": "脚本总数",
|
||||
"active": "已启用",
|
||||
"failed": "最近失败"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无脚本",
|
||||
"description": "创建或录制自动化脚本,以扩展对各类平台的适配能力。脚本基于 Playwright 运行。",
|
||||
"create": "创建第一个脚本"
|
||||
},
|
||||
"card": {
|
||||
"test": "测试",
|
||||
"run": "运行",
|
||||
"deleteConfirm": "确定要删除此脚本吗?对应的脚本文件也会被删除。",
|
||||
"last": "上次运行",
|
||||
"channel": "渠道",
|
||||
"common": "通用"
|
||||
},
|
||||
"dialog": {
|
||||
"createTitle": "新建脚本",
|
||||
"editTitle": "编辑脚本",
|
||||
"description": "编写或录制 Playwright 自动化脚本",
|
||||
"name": "脚本名称",
|
||||
"namePlaceholder": "例如:飞猪房型修改",
|
||||
"descriptionLabel": "脚本描述",
|
||||
"descriptionPlaceholder": "简要说明脚本用途...",
|
||||
"channel": "目标渠道",
|
||||
"channelPlaceholder": "粘贴渠道链接地址",
|
||||
"channelCommon": "通用",
|
||||
"code": "脚本代码",
|
||||
"recordStart": "开始录制",
|
||||
"recordStop": "停止录制",
|
||||
"recording": "录制中...",
|
||||
"recordTip": "录制将打开 Playwright Inspector,请在 Inspector 中录制并复制代码到编辑器。",
|
||||
"saveChanges": "保存更改"
|
||||
},
|
||||
"toast": {
|
||||
"created": "脚本已创建",
|
||||
"updated": "脚本已更新",
|
||||
"enabled": "脚本已启用",
|
||||
"disabled": "脚本已禁用",
|
||||
"deleted": "脚本已删除",
|
||||
"runSuccess": "脚本运行成功",
|
||||
"runFailed": "脚本运行失败: {{error}}",
|
||||
"nameRequired": "请输入脚本名称",
|
||||
"codeRequired": "请输入脚本代码",
|
||||
"channelRequired": "请输入渠道链接地址"
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ export enum IPC_EVENTS {
|
||||
// 打开渠道
|
||||
OPEN_CHANNEL = 'open-channel',
|
||||
|
||||
// 脚本管理
|
||||
SCRIPT_LIST = 'script:list',
|
||||
SCRIPT_GET = 'script:get',
|
||||
SCRIPT_SAVE = 'script:save',
|
||||
SCRIPT_DELETE = 'script:delete',
|
||||
SCRIPT_TOGGLE = 'script:toggle',
|
||||
SCRIPT_RUN = 'script:run',
|
||||
SCRIPT_RECORD_START = 'script:record-start',
|
||||
SCRIPT_RECORD_STOP = 'script:record-stop',
|
||||
|
||||
// 更新
|
||||
UPDATE_CHECK = 'update:check',
|
||||
UPDATE_DOWNLOAD = 'update:download',
|
||||
|
||||
18
src/lib/script-api.ts
Normal file
18
src/lib/script-api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
AutomationScript,
|
||||
ScriptSaveInput,
|
||||
ScriptExecutionResult,
|
||||
} from '@lib/script-types';
|
||||
|
||||
export const scriptApi = {
|
||||
list: (): Promise<AutomationScript[]> => window.api.scriptApi.list(),
|
||||
get: (id: string): Promise<AutomationScript | null> => window.api.scriptApi.get(id),
|
||||
save: (input: ScriptSaveInput): Promise<AutomationScript> => window.api.scriptApi.save(input),
|
||||
delete: (id: string): Promise<boolean> => window.api.scriptApi.delete(id),
|
||||
toggle: (id: string, enabled: boolean): Promise<boolean> => window.api.scriptApi.toggle(id, enabled),
|
||||
run: (id: string): Promise<ScriptExecutionResult> => window.api.scriptApi.run(id),
|
||||
startRecording: (url?: string): Promise<{ success: boolean; code?: string; error?: string }> =>
|
||||
window.api.scriptApi.startRecording(url),
|
||||
stopRecording: (): Promise<{ success: boolean; code?: string; error?: string }> =>
|
||||
window.api.scriptApi.stopRecording(),
|
||||
};
|
||||
53
src/lib/script-types.ts
Normal file
53
src/lib/script-types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface ScriptLastRun {
|
||||
time: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AutomationScript {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
filename: string;
|
||||
enabled: boolean;
|
||||
channel: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
code?: string;
|
||||
lastRun?: ScriptLastRun;
|
||||
}
|
||||
|
||||
export interface ScriptSaveInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
code: string;
|
||||
channel: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ScriptExecutionResult {
|
||||
success: boolean;
|
||||
exitCode: number | null;
|
||||
stdoutTail: string;
|
||||
stderrTail: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ScriptRecordingStatus = 'idle' | 'recording' | 'stopped';
|
||||
|
||||
export interface ScriptMetaItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
filename: string;
|
||||
enabled: boolean;
|
||||
channel: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastRun?: ScriptLastRun;
|
||||
}
|
||||
|
||||
export interface ScriptsMeta {
|
||||
scripts: ScriptMetaItem[];
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h2 class="text-2xl font-bold mb-4">关于我们</h2>
|
||||
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 class="text-lg font-semibold mb-4">智念科技 AI</h3>
|
||||
<p class="mb-4">
|
||||
这是一个基于 Electron + Vue 3 + TypeScript 的桌面应用程序。
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-50 p-4 rounded">
|
||||
<h4 class="font-semibold mb-2">技术栈</h4>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>Electron</li>
|
||||
<li>Vue 3</li>
|
||||
<li>TypeScript</li>
|
||||
<li>Vue Router</li>
|
||||
<li>Pinia</li>
|
||||
<li>Tailwind CSS</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded">
|
||||
<h4 class="font-semibold mb-2">功能特性</h4>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>现代化 UI 界面</li>
|
||||
<li>状态管理</li>
|
||||
<li>路由导航</li>
|
||||
<li>响应式设计</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 关于页面组件
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +0,0 @@
|
||||
<template></template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
185
src/pages/scripts/components/ScriptCard.vue
Normal file
185
src/pages/scripts/components/ScriptCard.vue
Normal 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>
|
||||
223
src/pages/scripts/components/ScriptCreateDialog.vue
Normal file
223
src/pages/scripts/components/ScriptCreateDialog.vue
Normal 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>
|
||||
354
src/pages/scripts/components/ScriptEditorDialog.vue
Normal file
354
src/pages/scripts/components/ScriptEditorDialog.vue
Normal 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>
|
||||
28
src/pages/scripts/components/ScriptStats.vue
Normal file
28
src/pages/scripts/components/ScriptStats.vue
Normal 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
265
src/pages/scripts/index.vue
Normal 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>
|
||||
@@ -47,6 +47,12 @@ const routes = [
|
||||
name: "Cron",
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/scripts",
|
||||
component: () => import("@src/pages/scripts/index.vue"),
|
||||
name: "Scripts",
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
component: () => import("@src/pages/setting/index.vue"),
|
||||
|
||||
161
src/store/script.ts
Normal file
161
src/store/script.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { scriptApi } from '@lib/script-api';
|
||||
import type {
|
||||
AutomationScript,
|
||||
ScriptSaveInput,
|
||||
ScriptExecutionResult,
|
||||
ScriptRecordingStatus,
|
||||
} from '@lib/script-types';
|
||||
|
||||
export const useScriptStore = defineStore('script', () => {
|
||||
const scripts = ref<AutomationScript[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const recordingStatus = ref<ScriptRecordingStatus>('idle');
|
||||
const executionLogs = ref<Record<string, ScriptExecutionResult>>({});
|
||||
|
||||
const safeScripts = computed(() => (Array.isArray(scripts.value) ? scripts.value : []));
|
||||
const enabledScripts = computed(() => safeScripts.value.filter((s) => s.enabled));
|
||||
const scriptsByChannel = computed(() => {
|
||||
const map = new Map<string, AutomationScript[]>();
|
||||
for (const script of safeScripts.value) {
|
||||
const list = map.get(script.channel) || [];
|
||||
list.push(script);
|
||||
map.set(script.channel, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const fetchScripts = async () => {
|
||||
const currentScripts = safeScripts.value;
|
||||
if (currentScripts.length === 0) {
|
||||
loading.value = true;
|
||||
}
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const result = await scriptApi.list();
|
||||
scripts.value = result;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveScript = async (input: ScriptSaveInput) => {
|
||||
try {
|
||||
const result = await scriptApi.save(input);
|
||||
if (input.id) {
|
||||
scripts.value = safeScripts.value.map((s) => (s.id === input.id ? result : s));
|
||||
} else {
|
||||
scripts.value = [...safeScripts.value, result];
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
error.value = msg;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteScript = async (id: string) => {
|
||||
try {
|
||||
await scriptApi.delete(id);
|
||||
scripts.value = safeScripts.value.filter((s) => s.id !== id);
|
||||
delete executionLogs.value[id];
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
error.value = msg;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScript = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await scriptApi.toggle(id, enabled);
|
||||
scripts.value = safeScripts.value.map((s) =>
|
||||
s.id === id ? { ...s, enabled, updatedAt: new Date().toISOString() } : s,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
error.value = msg;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runScript = async (id: string) => {
|
||||
try {
|
||||
const result = await scriptApi.run(id);
|
||||
executionLogs.value = { ...executionLogs.value, [id]: result };
|
||||
// 更新本地 lastRun
|
||||
scripts.value = safeScripts.value.map((s) =>
|
||||
s.id === id
|
||||
? {
|
||||
...s,
|
||||
lastRun: {
|
||||
time: new Date().toISOString(),
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
error.value = msg;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async (url?: string) => {
|
||||
recordingStatus.value = 'recording';
|
||||
try {
|
||||
const result = await scriptApi.startRecording(url);
|
||||
if (!result.success) {
|
||||
recordingStatus.value = 'idle';
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
recordingStatus.value = 'idle';
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
try {
|
||||
const result = await scriptApi.stopRecording();
|
||||
recordingStatus.value = 'stopped';
|
||||
return result;
|
||||
} catch (err) {
|
||||
recordingStatus.value = 'idle';
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const resetRecording = () => {
|
||||
recordingStatus.value = 'idle';
|
||||
};
|
||||
|
||||
return {
|
||||
scripts,
|
||||
loading,
|
||||
error,
|
||||
recordingStatus,
|
||||
executionLogs,
|
||||
safeScripts,
|
||||
enabledScripts,
|
||||
scriptsByChannel,
|
||||
fetchScripts,
|
||||
saveScript,
|
||||
deleteScript,
|
||||
toggleScript,
|
||||
runScript,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
resetRecording,
|
||||
};
|
||||
});
|
||||
@@ -134,3 +134,8 @@ body {
|
||||
color: #FF4949;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user