feat: add viewport selection and run on create options in AutoProbeForm component

This commit is contained in:
Marvin Zhang
2025-05-19 17:32:40 +08:00
parent da024f7b6f
commit 50c4bc3750
13 changed files with 385 additions and 136 deletions

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeMount, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { useAutoProbe } from '@/components';
import { translate } from '@/utils';
import { getViewPortOptions, translate } from '@/utils';
// i18n
const t = translate;
@@ -10,6 +11,33 @@ const t = translate;
const store = useStore();
const { form, formRef, formRules, isSelectiveForm, isFormItemDisabled } =
useAutoProbe(store);
const viewportOptions = computed<ViewPortSelectOption[]>(() => {
return getViewPortOptions();
});
const viewportValue = ref<ViewPortValue>('pc-normal');
const onViewportChange = (value: ViewPortValue) => {
if (!form.value) return;
const selectedOption = viewportOptions.value.find(
option => option.value === value
);
if (selectedOption) {
form.value.viewport = selectedOption.viewport;
}
};
const updateViewPortValue = () => {
const selectedOption = viewportOptions.value.find(
op =>
op.viewport.width === form.value?.viewport?.width &&
op.viewport.height === form.value?.viewport?.height
);
if (selectedOption) {
viewportValue.value = selectedOption.value!;
}
};
watch(() => JSON.stringify(form.value?.viewport), updateViewPortValue);
onBeforeMount(updateViewPortValue);
defineOptions({ name: 'ClAutoProbeForm' });
</script>
@@ -45,8 +73,11 @@ defineOptions({ name: 'ClAutoProbeForm' });
v-model="form.url"
:disabled="isFormItemDisabled('url')"
:placeholder="t('components.autoprobe.form.url')"
type="textarea"
/>
>
<template #prefix>
<cl-icon :icon="['fa', 'at']" />
</template>
</el-input>
</cl-form-item>
<cl-form-item
:span="4"
@@ -56,9 +87,62 @@ defineOptions({ name: 'ClAutoProbeForm' });
<el-input
v-model="form.query"
:disabled="isFormItemDisabled('query')"
:placeholder="t('components.autoprobe.form.query')"
:placeholder="t('components.autoprobe.form.queryPlaceholder')"
type="textarea"
/>
</cl-form-item>
<cl-form-item
:span="2"
:label="t('components.autoprobe.form.viewport')"
prop="viewport"
>
<el-select
v-model="viewportValue"
:disabled="isFormItemDisabled('viewport')"
:placeholder="t('components.autoprobe.form.viewport')"
@change="onViewportChange"
>
<el-option
v-for="op in viewportOptions"
:key="op.value"
:label="op.label"
:value="op.value"
/>
</el-select>
<cl-tag
v-if="form.viewport"
size="large"
:icon="['fa', 'desktop']"
:label="`${form.viewport?.width}x${form.viewport?.height}`"
>
<template #tooltip>
<div>
<label>{{ t('components.autoprobe.form.viewportWidth') }}: </label>
<span
>{{ form.viewport?.width }}
{{ t('components.autoprobe.form.viewportPx') }}</span
>
</div>
<div>
<label>{{ t('components.autoprobe.form.viewportHeight') }}: </label>
<span
>{{ form.viewport?.height }}
{{ t('components.autoprobe.form.viewportPx') }}</span
>
</div>
</template>
</cl-tag>
</cl-form-item>
<cl-form-item
:span="2"
:label="t('components.autoprobe.form.runOnCreate')"
prop="query"
>
<cl-switch
v-model="form.run_on_create"
:disabled="isFormItemDisabled('run_on_create')"
:placeholder="t('components.autoprobe.form.runOnCreate')"
/>
</cl-form-item>
</cl-form>
</template>

View File

@@ -85,7 +85,7 @@ const tableColumns = computed<TableColumns<AutoProbeNavItem>>(() => {
case 'list':
const list = row.rule as ListRule;
const selectorType = list.item_selector_type;
const selector = [list.item_selector, list.list_selector]
const selector = [list.list_selector, list.item_selector]
.filter(item => item)
.join(' > ');
return (

View File

@@ -1,10 +1,9 @@
<script setup lang="tsx">
import { computed, CSSProperties, ref } from 'vue';
import { computed, ref } from 'vue';
import { CellStyle } from 'element-plus';
import { ClTag } from '@/components';
import { translate, getIconByItemType, getIconByPageElementType } from '@/utils';
import { ClTag, ClAutoProbeResultsPreview } from '@/components';
import { translate, getIconByItemType } from '@/utils';
import { TAB_NAME_RESULTS, TAB_NAME_PREVIEW } from '@/constants';
import useRequest from '@/services/request';
const t = translate;
@@ -14,6 +13,7 @@ const props = defineProps<{
fields?: AutoProbeNavItem[];
activeFieldName?: string;
url?: string;
viewport?: PageViewPort;
activeId?: string;
}>();
@@ -22,15 +22,9 @@ const emit = defineEmits<{
(e: 'size-change', size: number): void;
}>();
const { get } = useRequest();
// Refs
const resultsContainerRef = ref<HTMLElement | null>(null);
const iframeLoading = ref(true);
const previewRef = ref<HTMLDivElement | null>(null);
const previewLoading = ref(false);
const previewResult = ref<PagePreviewResult>();
const overlayRef = ref<HTMLDivElement | null>(null);
const previewRef = ref<typeof ClAutoProbeResultsPreview | null>(null);
// States
const activeTabName = ref<string | undefined>(TAB_NAME_RESULTS);
@@ -97,12 +91,6 @@ const onTabSelect = async (id: string) => {
if (!resultsVisible.value) {
resultsVisible.value = true;
}
// Reset iframe loading state when switching to preview tab
if (id === TAB_NAME_PREVIEW) {
iframeLoading.value = true;
setTimeout(getPreview, 10);
}
};
const toggleResults = () => {
@@ -112,45 +100,6 @@ const toggleResults = () => {
}
};
const getPreview = async () => {
const { activeId } = props;
const rect = previewRef.value?.getBoundingClientRect();
const viewport: PageViewPort | undefined = rect
? {
width: rect.width,
height: rect.height,
}
: undefined;
previewLoading.value = true;
try {
const res = await get<any, ResponseWithData<PagePreviewResult>>(
`/ai/autoprobes/${activeId}/preview`,
{
viewport,
}
);
previewResult.value = res.data;
} finally {
previewLoading.value = false;
}
};
const overlayScale = computed(() => {
const rect = overlayRef.value?.getBoundingClientRect();
if (!rect) return 1;
return rect.width / 1280; // TODO: Adjust based on the actual viewport size
});
const getElementMaskStyle = (el: PageElement): CSSProperties => {
return {
position: 'absolute',
left: el.coordinates.left * overlayScale.value + 'px',
top: el.coordinates.top * overlayScale.value + 'px',
width: el.coordinates.width * overlayScale.value + 'px',
height: el.coordinates.height * overlayScale.value + 'px',
};
};
// Resize handler
const heightKey = 'autoprobe.results.containerHeight';
const onSizeChange = (size: number) => {
@@ -194,44 +143,28 @@ defineOptions({ name: 'ClAutoProbeResultsContainer' });
</div>
</template>
</cl-nav-tabs>
<div class="results" v-if="activeTabName === TAB_NAME_RESULTS">
<cl-table
:key="JSON.stringify(tableColumns)"
:columns="tableColumns"
:data="tableData"
:header-cell-style="tableCellStyle"
:cell-style="tableCellStyle"
embedded
hide-footer
/>
</div>
<div
v-else-if="activeTabName === TAB_NAME_PREVIEW"
ref="previewRef"
class="preview"
>
<!-- <el-skeleton :rows="15" animated v-if="iframeLoading && url" />-->
<div v-loading="previewLoading" class="preview-container">
<div v-if="previewResult" ref="overlayRef" class="preview-overlay">
<img class="screenshot" :src="previewResult.screenshot_base64" />
<div
v-for="(el, index) in previewResult.page_elements"
:key="index"
class="element-mask"
:style="getElementMaskStyle(el)"
>
<el-badge type="primary" :badge-style="{ opacity: 0.8 }">
<template #content>
<span style="margin-right: 5px">
<cl-icon :icon="getIconByPageElementType(el.type)" />
</span>
{{ el.name }}
</template>
</el-badge>
</div>
</div>
<template v-if="activeTabName === TAB_NAME_RESULTS">
<div class="results">
<cl-table
:key="JSON.stringify(tableColumns)"
:columns="tableColumns"
:data="tableData"
:header-cell-style="tableCellStyle"
:cell-style="tableCellStyle"
embedded
hide-footer
/>
</div>
</div>
</template>
<template v-else-if="activeTabName === TAB_NAME_PREVIEW">
<cl-auto-probe-results-preview
v-if="activeId"
ref="previewRef"
:active-id="activeId"
:viewport="viewport"
/>
</template>
</div>
</template>
@@ -289,41 +222,5 @@ defineOptions({ name: 'ClAutoProbeResultsContainer' });
top: 0;
}
}
.preview {
overflow: hidden;
height: calc(100% - 41px);
.preview-container {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
scrollbar-width: none;
.preview-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 1;
img.screenshot {
width: 100%;
}
.element-mask {
border: 1px solid var(--el-color-primary-light-5);
border-radius: 4px;
z-index: 1;
&:hover {
background: rgba(64, 156, 255, 0.2);
cursor: pointer;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import {
type CSSProperties,
onMounted,
ref,
watch,
onBeforeUnmount,
} from 'vue';
import useRequest from '@/services/request';
import { getIconByPageElementType } from '@/utils';
import { debounce } from 'lodash';
const props = defineProps<{
activeId: string;
viewport?: PageViewPort;
}>();
const { get } = useRequest();
const previewRef = ref<HTMLDivElement | null>(null);
const previewLoading = ref(false);
const previewResult = ref<PagePreviewResult>();
const overlayRef = ref<HTMLDivElement | null>(null);
const overlayScale = ref(1);
const updateOverlayScale = () => {
const { viewport } = props;
const rect = overlayRef.value?.getBoundingClientRect();
if (!rect) return 1;
overlayScale.value = rect.width / (viewport?.width ?? 1280);
};
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
// Initial calculation if reference is already available
if (overlayRef.value) {
setupResizeObserver();
}
// Watch for when the reference becomes available
watch(overlayRef, newRef => {
if (newRef) {
setupResizeObserver();
}
});
const handle = setInterval(() => {
if (!overlayRef.value) return;
overlayRef.value.addEventListener('resize', updateOverlayScale);
updateOverlayScale();
clearInterval(handle);
}, 10);
return () => {
overlayRef.value?.removeEventListener('resize', updateOverlayScale);
};
});
const setupResizeObserver = () => {
// Clean up existing observer if there is one
if (resizeObserver) {
resizeObserver.disconnect();
}
resizeObserver = new ResizeObserver(() => {
updateOverlayScale();
});
resizeObserver.observe(overlayRef.value!);
updateOverlayScale();
};
// Clean up function
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
const getPreview = debounce(async () => {
const { activeId } = props;
const rect = previewRef.value?.getBoundingClientRect();
const viewport: PageViewPort | undefined = rect
? {
width: rect.width,
height: rect.height,
}
: undefined;
previewLoading.value = true;
try {
const res = await get<any, ResponseWithData<PagePreviewResult>>(
`/ai/autoprobes/${activeId}/preview`
);
previewResult.value = res.data;
} finally {
previewLoading.value = false;
}
});
onMounted(getPreview);
const getElementMaskStyle = (el: PageElement): CSSProperties => {
return {
position: 'absolute',
left: el.coordinates.left * overlayScale.value + 'px',
top: el.coordinates.top * overlayScale.value + 'px',
width: el.coordinates.width * overlayScale.value + 'px',
height: el.coordinates.height * overlayScale.value + 'px',
};
};
defineExpose({
updateOverlayScale,
});
defineOptions({ name: 'ClAutoProbeResultsPreview' });
</script>
<template>
<div ref="previewRef" class="preview">
<div v-loading="previewLoading" class="preview-container">
<div v-if="previewResult" ref="overlayRef" class="preview-overlay">
<img class="screenshot" :src="previewResult.screenshot_base64" />
<div
v-for="(el, index) in previewResult.page_elements"
:key="index"
class="element-mask"
:style="getElementMaskStyle(el)"
>
<el-badge type="primary" :badge-style="{ opacity: 0.8 }">
<template #content>
<span style="margin-right: 5px">
<cl-icon :icon="getIconByPageElementType(el.type)" />
</span>
{{ el.name }}
</template>
</el-badge>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.preview {
overflow: hidden;
height: calc(100% - 41px);
.preview-container {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
scrollbar-width: none;
.preview-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 1;
img.screenshot {
width: fit-content;
max-width: 100%;
}
.element-mask {
border: 1px solid var(--el-color-primary-light-5);
border-radius: 4px;
z-index: 1;
&:hover {
background: rgba(64, 156, 255, 0.2);
cursor: pointer;
}
}
}
}
}
</style>

View File

@@ -13,7 +13,12 @@ const useAutoProbe = (store: Store<RootStoreState>) => {
const state = store.state[ns];
// form rules
const formRules: FormRules = {};
const formRules: FormRules = {
url: {
pattern: /^https?:\/\/.+/,
message: 'URL is not valid (must start with http:// or https://)',
},
};
// all autoprobe select options
const allAutoProbeSelectOptions = computed<SelectOption[]>(() =>

View File

@@ -24,6 +24,7 @@ import AutoProbeItemDetail from './core/autoprobe/AutoProbeItemDetail.vue';
import AutoProbePagePatternsSidebar from './core/autoprobe/AutoProbePagePatternsSidebar.vue';
import AutoProbePatternStats from './core/autoprobe/AutoProbePatternStats.vue';
import AutoProbeResultsContainer from './core/autoprobe/AutoProbeResultsContainer.vue';
import AutoProbeResultsPreview from './core/autoprobe/AutoProbeResultsPreview.vue';
import AutoProbeSelector from './core/autoprobe/AutoProbeSelector.vue';
import AutoProbeTaskStatus from './core/autoprobe/AutoProbeTaskStatus.vue';
import BlockOptionsDropdownList from './ui/lexical/components/BlockOptionsDropdownList.vue';
@@ -279,6 +280,7 @@ export {
AutoProbePagePatternsSidebar as ClAutoProbePagePatternsSidebar,
AutoProbePatternStats as ClAutoProbePatternStats,
AutoProbeResultsContainer as ClAutoProbeResultsContainer,
AutoProbeResultsPreview as ClAutoProbeResultsPreview,
AutoProbeSelector as ClAutoProbeSelector,
AutoProbeTaskStatus as ClAutoProbeTaskStatus,
BlockOptionsDropdownList as ClBlockOptionsDropdownList,

View File

@@ -3,6 +3,20 @@ const autoprobe: LComponentsAutoProbe = {
name: 'Name',
url: 'URL',
query: 'Query',
queryPlaceholder:
'User instruction query to extract data (default to system prompt only)',
runOnCreate: 'Run on Create',
viewport: 'Viewport',
viewports: {
pc: {
normal: 'PC (Normal)',
wide: 'PC (Wide)',
small: 'PC (Small)',
},
},
viewportWidth: 'Viewport Width',
viewportHeight: 'Viewport Height',
viewportPx: 'px',
},
task: {
status: {

View File

@@ -3,6 +3,19 @@ const autoprobe: LComponentsAutoProbe = {
name: '名称',
url: 'URL',
query: '查询',
queryPlaceholder: '用户指令查询以提取数据 (默认为系统提示)',
runOnCreate: '创建时运行',
viewport: '视窗大小',
viewports: {
pc: {
normal: 'PC (正常)',
wide: 'PC ()',
small: 'PC ()',
},
},
viewportWidth: '视窗宽度',
viewportHeight: '视窗高度',
viewportPx: '像素',
},
task: {
status: {

View File

@@ -3,6 +3,19 @@ interface LComponentsAutoProbe {
name: string;
url: string;
query: string;
queryPlaceholder: string;
runOnCreate: string;
viewport: string;
viewports: {
pc: {
normal: string;
wide: string;
small: string;
};
};
viewportWidth: string;
viewportHeight: string;
viewportPx: string;
};
task: {
status: {

View File

@@ -6,8 +6,10 @@ export declare global {
last_task_id?: string;
last_task?: AutoProbeTask;
default_task_id?: string;
run_on_create?: boolean;
page_pattern?: PagePattern;
page_data?: PageData;
viewport?: PageViewPort;
}
type AutoProbeTaskStatus =
@@ -121,4 +123,10 @@ export declare global {
screenshot_base64: string;
page_elements: PageElement[];
}
type ViewPortValue = 'pc-normal' | 'pc-wide' | 'pc-small';
interface ViewPortSelectOption extends SelectOption<ViewPortValue> {
viewport: PageViewPort;
}
}

View File

@@ -11,6 +11,7 @@ import {
} from '@/constants/tab';
import { translate } from '@/utils/i18n';
import useRequest from '@/services/request';
import { getViewPortOptions } from '@/utils';
// i18n
const t = translate;
@@ -19,6 +20,13 @@ const { post } = useRequest();
const state = {
...getDefaultStoreState<AutoProbe>('autoprobe'),
newFormFn: () => {
return {
run_on_create: true,
viewport: getViewPortOptions().find(v => v.value === 'pc-normal')
?.viewport,
};
},
tabs: [
{ id: TAB_NAME_OVERVIEW, title: t('common.tabs.overview') },
{ id: TAB_NAME_TASKS, title: t('common.tabs.tasks') },

View File

@@ -1,3 +1,7 @@
import { translate } from '@/utils';
const t = translate;
export const getIconBySelectorType = (selectorType: SelectorType): Icon => {
switch (selectorType) {
case 'css':
@@ -50,4 +54,24 @@ export const getIconByPageElementType = (itemType?: PageElementType): Icon => {
default:
return ['fa', 'question'];
}
}
};
export const getViewPortOptions = () => {
return [
{
label: t('components.autoprobe.form.viewports.pc.normal'),
value: 'pc-normal',
viewport: { width: 1280, height: 800 },
},
{
label: t('components.autoprobe.form.viewports.pc.wide'),
value: 'pc-wide',
viewport: { width: 1920, height: 1080 },
},
{
label: t('components.autoprobe.form.viewports.pc.small'),
value: 'pc-small',
viewport: { width: 1024, height: 768 },
},
] as ViewPortSelectOption[];
};

View File

@@ -264,6 +264,7 @@ defineOptions({ name: 'ClAutoProbeDetailTabPatterns' });
:fields="resultsFields"
:active-field-name="resultsActiveField?.name"
:url="form.url"
:viewport="form.viewport"
:active-id="activeId"
@size-change="onSizeChange"
/>