@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
package/build.log
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
sending incremental file list
|
|
6
6
|
custom/
|
|
7
|
-
custom/
|
|
8
|
-
custom/
|
|
9
|
-
custom/
|
|
7
|
+
custom/GenerationSetup.vue
|
|
8
|
+
custom/ImageLightbox.vue
|
|
9
|
+
custom/RecordDetail.vue
|
|
10
|
+
custom/ReviewQueue.vue
|
|
10
11
|
custom/VisionAction.vue
|
|
11
|
-
custom/VisionTable.vue
|
|
12
12
|
custom/package-lock.json
|
|
13
13
|
custom/package.json
|
|
14
14
|
custom/pnpm-lock.yaml
|
|
15
|
+
custom/reviewModel.ts
|
|
15
16
|
custom/tsconfig.json
|
|
16
17
|
|
|
17
|
-
sent
|
|
18
|
-
total size is
|
|
18
|
+
sent 83,073 bytes received 210 bytes 166,566.00 bytes/sec
|
|
19
|
+
total size is 82,285 speedup is 0.99
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="af-confirmation-panel flex flex-col gap-4">
|
|
3
|
+
<section class="af-configure-prompts-card flex flex-col gap-1.5">
|
|
4
|
+
<h4 class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Fields to generate') }}</h4>
|
|
5
|
+
<ul class="af-settings-panel overflow-hidden rounded-lg border border-lightFormBorder divide-y divide-lightFormBorder dark:border-darkFormBorder dark:divide-darkFormBorder">
|
|
6
|
+
<li
|
|
7
|
+
v-for="row in rows"
|
|
8
|
+
:key="row.field"
|
|
9
|
+
:class="openFields.has(row.field) ? 'bg-lightFormHeading dark:bg-darkFormHeading' : 'bg-lightForm dark:bg-darkForm'"
|
|
10
|
+
>
|
|
11
|
+
<div class="flex items-center gap-3 px-3.5 py-2.5">
|
|
12
|
+
<div class="flex-1 min-w-0">
|
|
13
|
+
<p class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ fieldLabel(meta, row.field) }}</p>
|
|
14
|
+
<p v-if="!openFields.has(row.field)" class="truncate text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
15
|
+
{{ prompts[row.category][row.field] }}
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
<span
|
|
19
|
+
v-if="isUnsaved(row)"
|
|
20
|
+
class="inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-xs font-medium border-lightWarningBorder bg-lightWarningSoft text-lightWarning dark:border-darkWarningBorder dark:bg-darkWarningSoft dark:text-darkWarning"
|
|
21
|
+
>
|
|
22
|
+
{{ t('Unsaved') }}
|
|
23
|
+
</span>
|
|
24
|
+
<span class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-xs font-medium bg-lightSecondaryDarken text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor">
|
|
25
|
+
{{ kindLabels[CATEGORY_KIND[row.category]] }}
|
|
26
|
+
</span>
|
|
27
|
+
<Button
|
|
28
|
+
type="button"
|
|
29
|
+
variant="ghost"
|
|
30
|
+
size="sm"
|
|
31
|
+
iconOnly
|
|
32
|
+
:aria-label="openFields.has(row.field) ? t('Hide prompt') : t('Edit prompt')"
|
|
33
|
+
:aria-expanded="openFields.has(row.field)"
|
|
34
|
+
:aria-controls="`${idPrefix}-${row.field}`"
|
|
35
|
+
@click="toggle(row.field)"
|
|
36
|
+
>
|
|
37
|
+
<IconChevronUp v-if="openFields.has(row.field)" aria-hidden="true" />
|
|
38
|
+
<IconPencil v-else aria-hidden="true" />
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
<div v-if="openFields.has(row.field)" :id="`${idPrefix}-${row.field}`" class="flex flex-col items-start gap-2 px-3.5 pb-3">
|
|
42
|
+
<Textarea
|
|
43
|
+
v-model="prompts[row.category][row.field]"
|
|
44
|
+
rows="4"
|
|
45
|
+
class="resize-y"
|
|
46
|
+
:placeholder="t('Enter prompt instructions...')"
|
|
47
|
+
/>
|
|
48
|
+
<div class="flex w-full flex-wrap items-center justify-between gap-2">
|
|
49
|
+
<Button
|
|
50
|
+
type="button"
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="sm"
|
|
53
|
+
:disabled="prompts[row.category][row.field] === defaultPrompts[row.category][row.field]"
|
|
54
|
+
@click="prompts[row.category][row.field] = defaultPrompts[row.category][row.field]"
|
|
55
|
+
>
|
|
56
|
+
<IconRotateCcw class="w-3.5 h-3.5" aria-hidden="true" />
|
|
57
|
+
{{ t('Reset to default') }}
|
|
58
|
+
</Button>
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="secondary"
|
|
62
|
+
size="sm"
|
|
63
|
+
:disabled="!isUnsaved(row)"
|
|
64
|
+
@click="emit('save', row.category, row.field)"
|
|
65
|
+
>
|
|
66
|
+
<IconSave class="w-3.5 h-3.5" aria-hidden="true" />
|
|
67
|
+
{{ t('Save prompt') }}
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</li>
|
|
72
|
+
</ul>
|
|
73
|
+
<p class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
74
|
+
{{ t('Saved prompts are kept in this browser, unsaved changes apply to this run only.') }}
|
|
75
|
+
</p>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<div class="af-overwrite-settings-card rounded-lg border border-lightFormBorder bg-lightForm px-3.5 py-3 dark:border-darkFormBorder dark:bg-darkForm">
|
|
79
|
+
<Toggle v-model="overwrite" class="w-full flex-row-reverse justify-between gap-3 [&>label]:ms-0">
|
|
80
|
+
<span class="flex flex-col">
|
|
81
|
+
<span class="font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Overwrite existing values') }}</span>
|
|
82
|
+
<span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
83
|
+
{{ overwrite ? t('AI-generated data will replace existing values.') : t('AI fills only empty fields.') }}
|
|
84
|
+
</span>
|
|
85
|
+
</span>
|
|
86
|
+
</Toggle>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div
|
|
90
|
+
v-if="overwrite"
|
|
91
|
+
class="flex items-start gap-2.5 rounded-default border px-3 py-2.5 text-sm border-lightWarningBorder bg-lightWarningSoft text-lightWarning dark:border-darkWarningBorder dark:bg-darkWarningSoft dark:text-darkWarning"
|
|
92
|
+
>
|
|
93
|
+
<IconTriangleAlert class="mt-0.5 w-4 h-4 shrink-0" aria-hidden="true" />
|
|
94
|
+
<p>{{ t('Existing values will be replaced when you save.') }}</p>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</template>
|
|
98
|
+
|
|
99
|
+
<script setup lang="ts">
|
|
100
|
+
import { computed, reactive, useId } from 'vue';
|
|
101
|
+
import { useI18n } from 'vue-i18n';
|
|
102
|
+
import { Button, Textarea, Toggle } from '@/afcl';
|
|
103
|
+
import { IconChevronUp, IconPencil, IconRotateCcw, IconSave, IconTriangleAlert } from '@iconify-prerendered/vue-lucide';
|
|
104
|
+
import { CATEGORY_KIND, fieldLabel, type FieldKind, type PromptCategory } from './reviewModel';
|
|
105
|
+
|
|
106
|
+
type Prompts = Record<PromptCategory, Record<string, string>>;
|
|
107
|
+
type PromptRow = { category: PromptCategory, field: string };
|
|
108
|
+
|
|
109
|
+
const props = defineProps<{
|
|
110
|
+
meta: any,
|
|
111
|
+
defaultPrompts: Prompts,
|
|
112
|
+
savedPrompts: Prompts,
|
|
113
|
+
}>();
|
|
114
|
+
|
|
115
|
+
const emit = defineEmits<{
|
|
116
|
+
save: [category: PromptCategory, field: string],
|
|
117
|
+
}>();
|
|
118
|
+
|
|
119
|
+
const prompts = defineModel<Prompts>('prompts', { required: true });
|
|
120
|
+
const overwrite = defineModel<boolean>('overwrite', { required: true });
|
|
121
|
+
|
|
122
|
+
const { t } = useI18n();
|
|
123
|
+
const idPrefix = useId();
|
|
124
|
+
const openFields = reactive(new Set<string>());
|
|
125
|
+
|
|
126
|
+
const kindLabels = computed<Record<FieldKind, string>>(() => ({
|
|
127
|
+
fromImage: t('From image'),
|
|
128
|
+
text: t('Text'),
|
|
129
|
+
image: t('Image'),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// in the order of the columns in the review
|
|
133
|
+
const rows = computed<PromptRow[]>(() => (Object.keys(CATEGORY_KIND) as PromptCategory[]).flatMap((category) =>
|
|
134
|
+
Object.keys(prompts.value[category]).map((field) => ({ category, field })),
|
|
135
|
+
));
|
|
136
|
+
|
|
137
|
+
function isUnsaved(row: PromptRow): boolean {
|
|
138
|
+
return prompts.value[row.category][row.field] !== props.savedPrompts[row.category][row.field];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toggle(field: string) {
|
|
142
|
+
if (openFields.has(field)) {
|
|
143
|
+
openFields.delete(field);
|
|
144
|
+
} else {
|
|
145
|
+
openFields.add(field);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div
|
|
4
|
+
v-if="src"
|
|
5
|
+
class="af-image-lightbox fixed inset-0 z-[100] flex items-center justify-center bg-black/90 cursor-zoom-out"
|
|
6
|
+
@click="src = null"
|
|
7
|
+
>
|
|
8
|
+
<img :src="src" alt="" class="max-w-[90vw] max-h-[90vh] rounded-lg object-contain select-none" />
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
class="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-[3px] focus-visible:ring-white/40 transition-colors"
|
|
12
|
+
:aria-label="t('Close')"
|
|
13
|
+
>
|
|
14
|
+
<IconX class="w-4 h-4" aria-hidden="true" />
|
|
15
|
+
</button>
|
|
16
|
+
</div>
|
|
17
|
+
</Teleport>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { onBeforeUnmount, watch } from 'vue';
|
|
22
|
+
import { useI18n } from 'vue-i18n';
|
|
23
|
+
import { IconX } from '@iconify-prerendered/vue-lucide';
|
|
24
|
+
|
|
25
|
+
// a photo is dark in both themes, so the lightbox keeps black and white tints instead of tokens
|
|
26
|
+
const src = defineModel<string | null>({ required: true });
|
|
27
|
+
|
|
28
|
+
const { t } = useI18n();
|
|
29
|
+
|
|
30
|
+
function onKeydown(event: KeyboardEvent) {
|
|
31
|
+
if (event.key === 'Escape') {
|
|
32
|
+
src.value = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
watch(src, (value) => {
|
|
37
|
+
if (value) {
|
|
38
|
+
document.addEventListener('keydown', onKeydown);
|
|
39
|
+
} else {
|
|
40
|
+
document.removeEventListener('keydown', onKeydown);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown));
|
|
45
|
+
</script>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="af-review-record @container/detail flex min-h-0 flex-col">
|
|
3
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-lightFormBorder px-4 py-3 dark:border-darkFormBorder">
|
|
4
|
+
<div class="min-w-0 flex-1 basis-full @md/detail:basis-0">
|
|
5
|
+
<h4 class="truncate text-sm font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ label }}</h4>
|
|
6
|
+
<p class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
7
|
+
{{ t('Record {position} of {total}', { position, total }) }}
|
|
8
|
+
<i18n-t keypath="· {up} {down} to move, {space} to include" tag="span" class="hidden @3xl/review:inline">
|
|
9
|
+
<template #up><kbd :class="KBD_CLASSES">↑</kbd></template>
|
|
10
|
+
<template #down><kbd :class="KBD_CLASSES">↓</kbd></template>
|
|
11
|
+
<template #space><kbd :class="KBD_CLASSES">Space</kbd></template>
|
|
12
|
+
</i18n-t>
|
|
13
|
+
</p>
|
|
14
|
+
</div>
|
|
15
|
+
<Checkbox v-model="record.isChecked">{{ t('Save this record') }}</Checkbox>
|
|
16
|
+
<Button type="button" variant="secondary" size="sm" :disabled="!isSettled" @click="emit('regenerateRecord')">
|
|
17
|
+
<IconRefreshCw class="w-3.5 h-3.5" aria-hidden="true" />
|
|
18
|
+
{{ t('Regenerate') }}
|
|
19
|
+
</Button>
|
|
20
|
+
<div class="flex gap-1">
|
|
21
|
+
<Button type="button" variant="secondary" size="sm" iconOnly :disabled="position === 1" :aria-label="t('Previous record')" @click="emit('prev')">
|
|
22
|
+
<IconChevronUp aria-hidden="true" />
|
|
23
|
+
</Button>
|
|
24
|
+
<Button type="button" variant="secondary" size="sm" iconOnly :disabled="position === total" :aria-label="t('Next record')" @click="emit('next')">
|
|
25
|
+
<IconChevronDown aria-hidden="true" />
|
|
26
|
+
</Button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
31
|
+
<div v-if="record.status === 'pending'" class="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
|
32
|
+
<IconClock class="w-6 h-6 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText" aria-hidden="true" />
|
|
33
|
+
<p class="text-sm text-lightInputIconColor dark:text-darkInputIconColor">{{ t('This record is waiting for its turn.') }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<template v-else>
|
|
37
|
+
<div class="hidden gap-3 border-b border-lightFormBorder bg-lightFormHeading px-4 py-2 text-xs font-medium text-lightInputIconColor dark:border-darkFormBorder dark:bg-darkFormHeading dark:text-darkInputIconColor @lg/detail:grid @lg/detail:grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)]">
|
|
38
|
+
<span>{{ t('Current') }}</span>
|
|
39
|
+
<span></span>
|
|
40
|
+
<span>{{ t('Generated') }}</span>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div v-if="meta.isAttachFiles" class="flex flex-col gap-2 border-b border-lightFormBorder px-4 py-3 dark:border-darkFormBorder">
|
|
44
|
+
<span class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Source images') }}</span>
|
|
45
|
+
<div v-if="record.images.length" class="flex flex-wrap gap-2">
|
|
46
|
+
<button
|
|
47
|
+
v-for="(image, index) in record.images"
|
|
48
|
+
:key="image"
|
|
49
|
+
type="button"
|
|
50
|
+
:class="IMAGE_BUTTON_CLASSES"
|
|
51
|
+
:aria-label="t('Open source image {number}', { number: index + 1 })"
|
|
52
|
+
@click="emit('zoom', image)"
|
|
53
|
+
>
|
|
54
|
+
<img :src="image" alt="" class="h-16 w-24 object-cover" />
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
<Skeleton v-else-if="record.status === 'processing'" type="image" class="h-16 w-24 rounded-default" />
|
|
58
|
+
<p v-else class="text-sm text-lightInputPlaceholderText dark:text-darkInputPlaceholderText">{{ t('No source images') }}</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
v-for="field in meta.outputFields"
|
|
63
|
+
:key="field"
|
|
64
|
+
class="flex flex-col gap-2 border-b border-lightFormBorder px-4 py-3 last:border-b-0 dark:border-darkFormBorder"
|
|
65
|
+
>
|
|
66
|
+
<div class="flex items-center gap-2">
|
|
67
|
+
<span class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ fieldLabel(meta, field) }}</span>
|
|
68
|
+
<span v-if="!isFieldGenerating(record, meta, field)" :class="[BADGE_CLASSES, badgeClasses(field)]">{{ badgeLabel(field) }}</span>
|
|
69
|
+
<span class="flex-1"></span>
|
|
70
|
+
<Tooltip>
|
|
71
|
+
<Button
|
|
72
|
+
type="button"
|
|
73
|
+
variant="ghost"
|
|
74
|
+
size="sm"
|
|
75
|
+
iconOnly
|
|
76
|
+
:loader="record.regeneratingFieldsStatus[field] === true"
|
|
77
|
+
:disabled="!isSettled || isFieldGenerating(record, meta, field)"
|
|
78
|
+
:aria-label="t('Regenerate {field}', { field: fieldLabel(meta, field) })"
|
|
79
|
+
@click="regenerate(field)"
|
|
80
|
+
>
|
|
81
|
+
<IconRefreshCw aria-hidden="true" />
|
|
82
|
+
</Button>
|
|
83
|
+
<template #tooltip>{{ t('Regenerate') }}</template>
|
|
84
|
+
</Tooltip>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="grid grid-cols-1 items-start gap-2 @lg/detail:grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)] @lg/detail:gap-3">
|
|
88
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
89
|
+
<span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor @lg/detail:hidden">{{ t('Current') }}</span>
|
|
90
|
+
<template v-if="fieldKind(meta, field) === 'image'">
|
|
91
|
+
<button
|
|
92
|
+
v-if="record.oldImagePreviews[field]"
|
|
93
|
+
type="button"
|
|
94
|
+
:class="IMAGE_BUTTON_CLASSES"
|
|
95
|
+
:aria-label="t('Open current image')"
|
|
96
|
+
@click="emit('zoom', record.oldImagePreviews[field])"
|
|
97
|
+
>
|
|
98
|
+
<img :src="record.oldImagePreviews[field]" alt="" class="aspect-[16/10] w-full object-cover" />
|
|
99
|
+
</button>
|
|
100
|
+
<div v-else :class="IMAGE_PLACEHOLDER_CLASSES">
|
|
101
|
+
<IconImageOff class="w-5 h-5" aria-hidden="true" />
|
|
102
|
+
{{ t('No image') }}
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
105
|
+
<div
|
|
106
|
+
v-else
|
|
107
|
+
class="min-h-[2.125rem] max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded-default bg-lightSecondaryDarken px-2.5 py-1.5 text-sm leading-5 text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor"
|
|
108
|
+
>
|
|
109
|
+
<span v-if="isEmpty(record.oldData[field])" class="text-lightInputPlaceholderText dark:text-darkInputPlaceholderText">-</span>
|
|
110
|
+
<template v-else>{{ displayValue(field, record.oldData[field]) }}</template>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<IconArrowRight class="mt-2.5 hidden w-4 h-4 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText @lg/detail:block" aria-hidden="true" />
|
|
115
|
+
|
|
116
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
117
|
+
<span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor @lg/detail:hidden">{{ t('Generated') }}</span>
|
|
118
|
+
<template v-if="fieldKind(meta, field) === 'image'">
|
|
119
|
+
<Skeleton v-if="isFieldGenerating(record, meta, field)" type="image" class="aspect-[16/10] w-full rounded-lg" />
|
|
120
|
+
<button
|
|
121
|
+
v-else-if="generatedImageUrl(record, meta, field)"
|
|
122
|
+
type="button"
|
|
123
|
+
:class="IMAGE_BUTTON_CLASSES"
|
|
124
|
+
:aria-label="t('Open generated image')"
|
|
125
|
+
@click="emit('zoom', generatedImageUrl(record, meta, field))"
|
|
126
|
+
>
|
|
127
|
+
<img :src="generatedImageUrl(record, meta, field)" alt="" class="aspect-[16/10] w-full object-cover" />
|
|
128
|
+
</button>
|
|
129
|
+
<div v-else :class="IMAGE_PLACEHOLDER_CLASSES">
|
|
130
|
+
<IconImageOff class="w-5 h-5" aria-hidden="true" />
|
|
131
|
+
{{ t('Not generated') }}
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
<Skeleton
|
|
135
|
+
v-else-if="isFieldGenerating(record, meta, field)"
|
|
136
|
+
type="input"
|
|
137
|
+
class="w-full"
|
|
138
|
+
:class="editorKind(field) === 'multiline' ? 'h-24' : 'h-[2.125rem]'"
|
|
139
|
+
/>
|
|
140
|
+
<Select
|
|
141
|
+
v-else-if="editorKind(field) === 'enum'"
|
|
142
|
+
v-model="record.data[field]"
|
|
143
|
+
class="w-full"
|
|
144
|
+
:options="enumOptions(field)"
|
|
145
|
+
teleportToTop
|
|
146
|
+
/>
|
|
147
|
+
<div v-else-if="editorKind(field) === 'boolean'" class="flex h-[2.125rem] items-center">
|
|
148
|
+
<Toggle v-model="record.data[field]" />
|
|
149
|
+
</div>
|
|
150
|
+
<Input v-else-if="editorKind(field) === 'number'" v-model="record.data[field]" type="number" fullWidth />
|
|
151
|
+
<Input v-else-if="editorKind(field) === 'line'" v-model="record.data[field]" type="text" fullWidth />
|
|
152
|
+
<Textarea
|
|
153
|
+
v-else
|
|
154
|
+
:modelValue="record.data[field] ?? ''"
|
|
155
|
+
rows="4"
|
|
156
|
+
class="resize-y"
|
|
157
|
+
@update:modelValue="(value) => record.data[field] = value"
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<p v-if="fieldError(record, meta, field)" class="flex items-start gap-1.5 text-xs text-lightDanger dark:text-darkDanger">
|
|
163
|
+
<IconCircleAlert class="mt-px w-3.5 h-3.5 shrink-0" aria-hidden="true" />
|
|
164
|
+
{{ fieldError(record, meta, field) }}
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
</template>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</template>
|
|
171
|
+
|
|
172
|
+
<script setup lang="ts">
|
|
173
|
+
import { computed } from 'vue';
|
|
174
|
+
import { useI18n } from 'vue-i18n';
|
|
175
|
+
import { Button, Checkbox, Input, Select, Skeleton, Textarea, Toggle, Tooltip } from '@/afcl';
|
|
176
|
+
import {
|
|
177
|
+
IconArrowRight, IconChevronDown, IconChevronUp, IconCircleAlert, IconClock, IconImageOff, IconRefreshCw,
|
|
178
|
+
} from '@iconify-prerendered/vue-lucide';
|
|
179
|
+
import {
|
|
180
|
+
fieldChange, fieldError, fieldKind, fieldLabel, generatedImageUrl, isFieldGenerating, isRecordBusy, type RecordState,
|
|
181
|
+
} from './reviewModel';
|
|
182
|
+
|
|
183
|
+
type EditorKind = 'enum' | 'boolean' | 'number' | 'line' | 'multiline';
|
|
184
|
+
|
|
185
|
+
const NUMBER_TYPES = ['integer', 'decimal', 'float'];
|
|
186
|
+
const KBD_CLASSES = 'rounded border border-lightFormBorder px-1 font-mono text-[11px] dark:border-darkFormBorder';
|
|
187
|
+
const BADGE_CLASSES = 'inline-flex h-5 items-center rounded-full border px-2 text-xs font-medium';
|
|
188
|
+
const IMAGE_BUTTON_CLASSES = 'block overflow-hidden rounded-lg border border-lightFormBorder cursor-zoom-in hover:border-lightPrimary focus:outline-none focus-visible:ring-[3px] focus-visible:ring-lightFocusRing dark:border-darkFormBorder dark:hover:border-darkPrimary dark:focus-visible:ring-darkFocusRing transition-colors';
|
|
189
|
+
const IMAGE_PLACEHOLDER_CLASSES = 'flex aspect-[16/10] w-full flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-lightFormBorder bg-lightFormHeading text-xs text-lightInputPlaceholderText dark:border-darkFormBorder dark:bg-darkFormHeading dark:text-darkInputPlaceholderText';
|
|
190
|
+
|
|
191
|
+
const props = defineProps<{
|
|
192
|
+
record: RecordState,
|
|
193
|
+
meta: any,
|
|
194
|
+
label: string,
|
|
195
|
+
position: number,
|
|
196
|
+
total: number,
|
|
197
|
+
}>();
|
|
198
|
+
|
|
199
|
+
const emit = defineEmits<{
|
|
200
|
+
prev: [],
|
|
201
|
+
next: [],
|
|
202
|
+
regenerateRecord: [],
|
|
203
|
+
regenerateImages: [],
|
|
204
|
+
regenerateField: [field: string],
|
|
205
|
+
zoom: [url: string],
|
|
206
|
+
}>();
|
|
207
|
+
|
|
208
|
+
const { t } = useI18n();
|
|
209
|
+
|
|
210
|
+
const isSettled = computed(() => !isRecordBusy(props.record));
|
|
211
|
+
|
|
212
|
+
function columnEnum(field: string) {
|
|
213
|
+
return props.meta.columnEnums.find((column: any) => column.name === field);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function editorKind(field: string): EditorKind {
|
|
217
|
+
if (columnEnum(field)) {
|
|
218
|
+
return 'enum';
|
|
219
|
+
}
|
|
220
|
+
const type = props.meta.columnTypes[field];
|
|
221
|
+
if (type === 'boolean') {
|
|
222
|
+
return 'boolean';
|
|
223
|
+
}
|
|
224
|
+
if (NUMBER_TYPES.includes(type)) {
|
|
225
|
+
return 'number';
|
|
226
|
+
}
|
|
227
|
+
return type === 'string' ? 'line' : 'multiline';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function enumOptions(field: string) {
|
|
231
|
+
return columnEnum(field).enum.map((item: any) => ({ label: item.label, value: item.value }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isEmpty(value: any) {
|
|
235
|
+
return value === null || value === undefined || value === '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function displayValue(field: string, value: any): string {
|
|
239
|
+
const option = columnEnum(field)?.enum.find((item: any) => item.value === value);
|
|
240
|
+
if (option) {
|
|
241
|
+
return option.label;
|
|
242
|
+
}
|
|
243
|
+
if (typeof value === 'boolean') {
|
|
244
|
+
return value ? t('Yes') : t('No');
|
|
245
|
+
}
|
|
246
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function badgeLabel(field: string): string {
|
|
250
|
+
if (fieldError(props.record, props.meta, field)) {
|
|
251
|
+
return t('Failed');
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
new: t('New'),
|
|
255
|
+
changed: t('Changed'),
|
|
256
|
+
same: t('Unchanged'),
|
|
257
|
+
}[fieldChange(props.record, props.meta, field)];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function badgeClasses(field: string): string {
|
|
261
|
+
if (fieldError(props.record, props.meta, field)) {
|
|
262
|
+
return 'border-lightDangerBorder bg-lightDangerSoft text-lightDanger dark:border-darkDangerBorder dark:bg-darkDangerSoft dark:text-darkDanger';
|
|
263
|
+
}
|
|
264
|
+
return fieldChange(props.record, props.meta, field) === 'same'
|
|
265
|
+
? 'border-transparent bg-lightSecondaryDarken text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor'
|
|
266
|
+
: 'border-lightPrimary/30 bg-lightPrimaryOpacity text-lightPrimary dark:border-darkPrimary/40 dark:bg-darkPrimaryOpacity dark:text-darkPrimary';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function regenerate(field: string) {
|
|
270
|
+
if (fieldKind(props.meta, field) === 'image') {
|
|
271
|
+
emit('regenerateImages');
|
|
272
|
+
} else {
|
|
273
|
+
emit('regenerateField', field);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
</script>
|