@adminforth/bulk-ai-flow 2.0.4 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.log +7 -6
- package/custom/GenerationSetup.vue +148 -0
- package/custom/ImageLightbox.vue +45 -0
- package/custom/RecordDetail.vue +276 -0
- package/custom/ReviewQueue.vue +253 -0
- package/custom/VisionAction.vue +266 -878
- package/custom/package-lock.json +1 -331
- package/custom/package.json +0 -3
- package/custom/pnpm-lock.yaml +0 -214
- package/custom/reviewModel.ts +124 -0
- package/dist/custom/GenerationSetup.vue +148 -0
- package/dist/custom/ImageLightbox.vue +45 -0
- package/dist/custom/RecordDetail.vue +276 -0
- package/dist/custom/ReviewQueue.vue +253 -0
- package/dist/custom/VisionAction.vue +266 -878
- package/dist/custom/package-lock.json +1 -331
- package/dist/custom/package.json +0 -3
- package/dist/custom/pnpm-lock.yaml +0 -214
- package/dist/custom/reviewModel.ts +124 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +34 -39
- package/dist/types.d.ts +2 -2
- package/index.ts +39 -46
- package/package.json +1 -1
- package/types.ts +2 -2
- package/custom/ImageCompare.vue +0 -185
- package/custom/ImageGenerationCarousel.vue +0 -327
- package/custom/Swiper.vue +0 -76
- package/custom/VisionTable.vue +0 -575
- package/dist/custom/ImageCompare.vue +0 -185
- package/dist/custom/ImageGenerationCarousel.vue +0 -327
- package/dist/custom/Swiper.vue +0 -76
- package/dist/custom/VisionTable.vue +0 -575
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="af-review-queue @container/review flex flex-col gap-3">
|
|
3
|
+
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
4
|
+
<div class="flex min-w-[16rem] flex-1 items-center gap-3">
|
|
5
|
+
<IconPause v-if="isPaused" class="w-4 h-4 shrink-0 text-lightWarning dark:text-darkWarning" aria-hidden="true" />
|
|
6
|
+
<Spinner v-else-if="isProcessing" class="w-4 h-4 shrink-0" />
|
|
7
|
+
<IconCircleCheck v-else-if="processed === total" class="w-4 h-4 shrink-0 text-lightSuccess dark:text-darkSuccess" aria-hidden="true" />
|
|
8
|
+
<IconClock v-else class="w-4 h-4 shrink-0 text-lightInputIconColor dark:text-darkInputIconColor" aria-hidden="true" />
|
|
9
|
+
<span class="shrink-0 text-sm tabular-nums text-lightDialogHeaderText dark:text-darkDialogHeaderText" aria-live="polite">
|
|
10
|
+
<span v-if="isPaused" class="font-medium text-lightWarning dark:text-darkWarning">{{ t('Paused') }} ·</span>
|
|
11
|
+
{{ t('{processed} of {total} records processed', { processed, total }) }}
|
|
12
|
+
</span>
|
|
13
|
+
<ProgressBar
|
|
14
|
+
class="flex-1"
|
|
15
|
+
:currentValue="processed"
|
|
16
|
+
:maxValue="Math.max(total, 1)"
|
|
17
|
+
:showValues="false"
|
|
18
|
+
:showProgress="false"
|
|
19
|
+
:showAnimation="isProcessing && !isPaused"
|
|
20
|
+
/>
|
|
21
|
+
<template v-if="isPaused">
|
|
22
|
+
<Button type="button" variant="secondary" size="sm" @click="emit('stop')">
|
|
23
|
+
<IconSquare class="w-3.5 h-3.5" aria-hidden="true" />
|
|
24
|
+
{{ t('Stop') }}
|
|
25
|
+
</Button>
|
|
26
|
+
<Button type="button" variant="secondary" size="sm" @click="emit('resume')">
|
|
27
|
+
<IconPlay class="w-3.5 h-3.5" aria-hidden="true" />
|
|
28
|
+
{{ t('Resume') }}
|
|
29
|
+
</Button>
|
|
30
|
+
</template>
|
|
31
|
+
</div>
|
|
32
|
+
<ButtonGroup v-model="filter">
|
|
33
|
+
<template #button:all>
|
|
34
|
+
<span :class="FILTER_CLASSES">{{ t('All') }} <span class="tabular-nums opacity-60">{{ records.length }}</span></span>
|
|
35
|
+
</template>
|
|
36
|
+
<template #button:changed>
|
|
37
|
+
<span :class="FILTER_CLASSES">{{ t('Changed') }} <span class="tabular-nums opacity-60">{{ changedCount }}</span></span>
|
|
38
|
+
</template>
|
|
39
|
+
<template #button:failed>
|
|
40
|
+
<span :class="FILTER_CLASSES">{{ t('Failed') }} <span class="tabular-nums opacity-60">{{ failedCount }}</span></span>
|
|
41
|
+
</template>
|
|
42
|
+
</ButtonGroup>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="grid h-[min(44rem,calc(100dvh-16rem))] min-h-[20rem] grid-cols-1 overflow-hidden rounded-lg border border-lightFormBorder bg-lightForm dark:border-darkFormBorder dark:bg-darkForm @3xl/review:grid-cols-[18rem_minmax(0,1fr)]">
|
|
46
|
+
<div class="af-review-list hidden min-h-0 flex-col border-e border-lightFormBorder bg-lightFormHeading dark:border-darkFormBorder dark:bg-darkFormHeading @3xl/review:flex">
|
|
47
|
+
<ul class="min-h-0 flex-1 overflow-y-auto" :aria-label="t('Records')">
|
|
48
|
+
<li
|
|
49
|
+
v-for="record in pageRecords"
|
|
50
|
+
:key="record.id"
|
|
51
|
+
class="flex items-center gap-2.5 border-b border-s-2 border-b-lightFormBorder ps-2.5 dark:border-b-darkFormBorder"
|
|
52
|
+
:class="record === selectedRecord
|
|
53
|
+
? 'border-s-lightPrimary bg-lightPrimaryOpacity dark:border-s-darkPrimary dark:bg-darkPrimaryOpacity'
|
|
54
|
+
: 'border-s-transparent hover:bg-lightListTableRowHover dark:hover:bg-darkListTableRowHover'"
|
|
55
|
+
>
|
|
56
|
+
<Checkbox v-model="record.isChecked" class="[&>label]:ms-0">
|
|
57
|
+
<span class="sr-only">{{ t('Save {record}', { record: recordLabel(record) }) }}</span>
|
|
58
|
+
</Checkbox>
|
|
59
|
+
<button
|
|
60
|
+
:ref="(el) => setRowButton(record, el as HTMLButtonElement | null)"
|
|
61
|
+
type="button"
|
|
62
|
+
class="flex min-w-0 flex-1 items-center gap-2.5 py-2 pe-3 text-start focus:outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-lightFocusRing dark:focus-visible:ring-darkFocusRing"
|
|
63
|
+
:aria-current="record === selectedRecord || undefined"
|
|
64
|
+
@click="selectAt(filteredRecords.indexOf(record))"
|
|
65
|
+
@keydown="onRowKeydown($event, record)"
|
|
66
|
+
>
|
|
67
|
+
<span
|
|
68
|
+
v-if="hasImages"
|
|
69
|
+
class="flex h-8 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-lightSecondaryDarken dark:bg-darkSecondaryLighten"
|
|
70
|
+
:class="!record.isChecked && 'opacity-50'"
|
|
71
|
+
>
|
|
72
|
+
<img v-if="thumbnail(record)" :src="thumbnail(record)" alt="" loading="lazy" class="h-full w-full object-cover" />
|
|
73
|
+
<IconImage v-else class="w-4 h-4 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText" aria-hidden="true" />
|
|
74
|
+
</span>
|
|
75
|
+
<span class="min-w-0 flex-1" :class="!record.isChecked && 'opacity-50'">
|
|
76
|
+
<span class="block truncate text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ recordLabel(record) }}</span>
|
|
77
|
+
<span
|
|
78
|
+
class="block truncate text-xs"
|
|
79
|
+
:class="record.status === 'failed' ? 'text-lightDanger dark:text-darkDanger' : 'text-lightInputIconColor dark:text-darkInputIconColor'"
|
|
80
|
+
>
|
|
81
|
+
{{ recordSummary(record) }}
|
|
82
|
+
</span>
|
|
83
|
+
</span>
|
|
84
|
+
<Spinner v-if="record.status === 'processing'" class="w-4 h-4 shrink-0" />
|
|
85
|
+
<IconClock v-else-if="record.status === 'pending'" class="w-4 h-4 shrink-0 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText" aria-hidden="true" />
|
|
86
|
+
<IconCircleAlert v-else-if="record.status === 'failed'" class="w-4 h-4 shrink-0 text-lightDanger dark:text-darkDanger" aria-hidden="true" />
|
|
87
|
+
<IconCircleCheck v-else class="w-4 h-4 shrink-0 text-lightSuccess dark:text-darkSuccess" aria-hidden="true" />
|
|
88
|
+
</button>
|
|
89
|
+
</li>
|
|
90
|
+
</ul>
|
|
91
|
+
<div
|
|
92
|
+
v-if="filteredRecords.length > pageSize"
|
|
93
|
+
class="af-pagination-container flex items-center justify-between gap-2 border-t border-lightFormBorder px-3 py-1.5 text-xs text-lightInputIconColor dark:border-darkFormBorder dark:text-darkInputIconColor"
|
|
94
|
+
>
|
|
95
|
+
<span class="tabular-nums">
|
|
96
|
+
{{ t('{from}–{to} of {total}', { from: pageStart + 1, to: pageStart + pageRecords.length, total: filteredRecords.length }) }}
|
|
97
|
+
</span>
|
|
98
|
+
<div class="flex gap-1">
|
|
99
|
+
<Button type="button" variant="ghost" size="sm" iconOnly :disabled="pageStart === 0" :aria-label="t('Previous page')" @click="offset = pageStart - pageSize">
|
|
100
|
+
<IconChevronLeft aria-hidden="true" />
|
|
101
|
+
</Button>
|
|
102
|
+
<Button type="button" variant="ghost" size="sm" iconOnly :disabled="pageStart + pageSize >= filteredRecords.length" :aria-label="t('Next page')" @click="offset = pageStart + pageSize">
|
|
103
|
+
<IconChevronRight aria-hidden="true" />
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<RecordDetail
|
|
110
|
+
v-if="selectedRecord"
|
|
111
|
+
:record="selectedRecord"
|
|
112
|
+
:meta="meta"
|
|
113
|
+
:label="recordLabel(selectedRecord)"
|
|
114
|
+
:position="selectedIndex + 1"
|
|
115
|
+
:total="filteredRecords.length"
|
|
116
|
+
@prev="selectAt(selectedIndex - 1)"
|
|
117
|
+
@next="selectAt(selectedIndex + 1)"
|
|
118
|
+
@regenerateRecord="emit('regenerateRecord', { recordId: selectedRecord.id })"
|
|
119
|
+
@regenerateImages="emit('regenerateImages', { recordId: selectedRecord.id })"
|
|
120
|
+
@regenerateField="(fieldName) => emit('regenerateCell', { recordId: selectedRecord.id, fieldName })"
|
|
121
|
+
@zoom="(url) => emit('zoom', url)"
|
|
122
|
+
/>
|
|
123
|
+
<div v-else class="flex flex-col items-center justify-center gap-2 p-6 text-center @3xl/review:col-start-2">
|
|
124
|
+
<IconListFilter class="w-6 h-6 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText" aria-hidden="true" />
|
|
125
|
+
<p class="text-sm text-lightInputIconColor dark:text-darkInputIconColor">{{ t('No records match this filter.') }}</p>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
130
|
+
|
|
131
|
+
<script setup lang="ts">
|
|
132
|
+
import { computed, nextTick, ref, watch } from 'vue';
|
|
133
|
+
import { useI18n } from 'vue-i18n';
|
|
134
|
+
import { Button, ButtonGroup, Checkbox, ProgressBar, Spinner } from '@/afcl';
|
|
135
|
+
import {
|
|
136
|
+
IconChevronLeft, IconChevronRight, IconCircleAlert, IconCircleCheck, IconClock, IconImage, IconListFilter, IconPause, IconPlay,
|
|
137
|
+
IconSquare,
|
|
138
|
+
} from '@iconify-prerendered/vue-lucide';
|
|
139
|
+
import RecordDetail from './RecordDetail.vue';
|
|
140
|
+
import { changedFieldsCount, generatedImageUrl, isRecordChanged, recordError, type RecordState } from './reviewModel';
|
|
141
|
+
|
|
142
|
+
type Filter = 'all' | 'changed' | 'failed';
|
|
143
|
+
|
|
144
|
+
const DEFAULT_PAGE_SIZE = 10;
|
|
145
|
+
const FILTER_CLASSES = 'px-3 py-1.5';
|
|
146
|
+
|
|
147
|
+
const props = defineProps<{
|
|
148
|
+
records: RecordState[],
|
|
149
|
+
meta: any,
|
|
150
|
+
processed: number,
|
|
151
|
+
total: number,
|
|
152
|
+
isProcessing: boolean,
|
|
153
|
+
isPaused: boolean,
|
|
154
|
+
}>();
|
|
155
|
+
|
|
156
|
+
const emit = defineEmits<{
|
|
157
|
+
regenerateRecord: [payload: { recordId: string | number }],
|
|
158
|
+
regenerateImages: [payload: { recordId: string | number }],
|
|
159
|
+
regenerateCell: [payload: { recordId: string | number, fieldName: string }],
|
|
160
|
+
zoom: [url: string],
|
|
161
|
+
stop: [],
|
|
162
|
+
resume: [],
|
|
163
|
+
}>();
|
|
164
|
+
|
|
165
|
+
const { t } = useI18n();
|
|
166
|
+
|
|
167
|
+
const pageSize: number = props.meta.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
168
|
+
const hasImages = props.meta.isAttachFiles || props.meta.outputImageFields.length > 0;
|
|
169
|
+
|
|
170
|
+
const filter = ref<Filter>('all');
|
|
171
|
+
const selectedId = ref<string | number | null>(null);
|
|
172
|
+
const offset = ref(0);
|
|
173
|
+
const rowButtons = new Map<string | number, HTMLButtonElement>();
|
|
174
|
+
|
|
175
|
+
const changedCount = computed(() => props.records.filter((record) => isRecordChanged(record, props.meta)).length);
|
|
176
|
+
const failedCount = computed(() => props.records.filter((record) => record.status === 'failed').length);
|
|
177
|
+
|
|
178
|
+
const filteredRecords = computed(() => props.records.filter((record) => {
|
|
179
|
+
if (filter.value === 'changed') {
|
|
180
|
+
return isRecordChanged(record, props.meta);
|
|
181
|
+
}
|
|
182
|
+
return filter.value === 'all' || record.status === 'failed';
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
// a record which left the filter (or was saved) hands the selection to the first one
|
|
186
|
+
const selectedIndex = computed(() => Math.max(0, filteredRecords.value.findIndex((record) => record.id === selectedId.value)));
|
|
187
|
+
const selectedRecord = computed<RecordState | undefined>(() => filteredRecords.value[selectedIndex.value]);
|
|
188
|
+
|
|
189
|
+
// stays on the last page when saved records shrink the list
|
|
190
|
+
const pageStart = computed(() => {
|
|
191
|
+
const lastPageStart = Math.max(0, Math.ceil(filteredRecords.value.length / pageSize) - 1) * pageSize;
|
|
192
|
+
return Math.min(offset.value, lastPageStart);
|
|
193
|
+
});
|
|
194
|
+
const pageRecords = computed(() => filteredRecords.value.slice(pageStart.value, pageStart.value + pageSize));
|
|
195
|
+
|
|
196
|
+
watch(filter, () => {
|
|
197
|
+
offset.value = 0;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
function selectAt(index: number) {
|
|
201
|
+
selectedId.value = filteredRecords.value[index].id;
|
|
202
|
+
offset.value = index - index % pageSize;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function setRowButton(record: RecordState, el: HTMLButtonElement | null) {
|
|
206
|
+
if (el) {
|
|
207
|
+
rowButtons.set(record.id, el);
|
|
208
|
+
} else {
|
|
209
|
+
rowButtons.delete(record.id);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function onRowKeydown(event: KeyboardEvent, record: RecordState) {
|
|
214
|
+
if (event.key === ' ') {
|
|
215
|
+
event.preventDefault();
|
|
216
|
+
record.isChecked = !record.isChecked;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
|
|
220
|
+
const index = filteredRecords.value.indexOf(record) + (step ?? 0);
|
|
221
|
+
if (!step || index < 0 || index >= filteredRecords.value.length) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
selectAt(index);
|
|
226
|
+
await nextTick();
|
|
227
|
+
rowButtons.get(filteredRecords.value[index].id)!.focus();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function recordLabel(record: RecordState): string {
|
|
231
|
+
return record.label || t('Record {id}', { id: record.id });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function thumbnail(record: RecordState): string | null {
|
|
235
|
+
return record.images[0]
|
|
236
|
+
?? props.meta.outputImageFields.map((field: string) => generatedImageUrl(record, props.meta, field)).find(Boolean)
|
|
237
|
+
?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function recordSummary(record: RecordState): string {
|
|
241
|
+
if (record.status === 'pending') {
|
|
242
|
+
return t('Waiting');
|
|
243
|
+
}
|
|
244
|
+
if (record.status === 'processing') {
|
|
245
|
+
return t('Generating…');
|
|
246
|
+
}
|
|
247
|
+
if (record.status === 'failed') {
|
|
248
|
+
return recordError(record) || t('Generation failed');
|
|
249
|
+
}
|
|
250
|
+
const count = changedFieldsCount(record, props.meta);
|
|
251
|
+
return count ? t('{count} field changed | {count} fields changed', count) : t('No changes');
|
|
252
|
+
}
|
|
253
|
+
</script>
|