refactor: Migrate the project functionality and optimize the code structure
This commit is contained in:
160
src/components/DialogCreateTask.vue
Normal file
160
src/components/DialogCreateTask.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:header="$t('parseModelUrl')"
|
||||
:modal="true"
|
||||
:maximizable="!isMobile"
|
||||
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
|
||||
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
|
||||
pt:mask:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
|
||||
pt:root:class="max-h-full"
|
||||
pt:content:class="px-0"
|
||||
@after-hide="clearContent"
|
||||
>
|
||||
<div class="flex h-full flex-col gap-4 px-5">
|
||||
<ResponseInput
|
||||
v-model="modelUrl"
|
||||
:allow-clear="true"
|
||||
:placeholder="$t('pleaseInputModelUrl')"
|
||||
@keypress.enter="searchModelsByUrl"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="pi pi-search pi-inputicon"
|
||||
@click="searchModelsByUrl"
|
||||
></span>
|
||||
</template>
|
||||
</ResponseInput>
|
||||
|
||||
<div v-show="data.length > 0">
|
||||
<ResponseSelect
|
||||
v-model="current"
|
||||
:items="data"
|
||||
:type="isMobile ? 'drop' : 'button'"
|
||||
>
|
||||
<template #prefix>
|
||||
<span>version:</span>
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
</div>
|
||||
|
||||
<ResponseScrollArea class="-mx-5 h-full">
|
||||
<div class="px-5">
|
||||
<ModelContent
|
||||
v-for="{ item } in data"
|
||||
v-show="current == item.id"
|
||||
:key="item.id"
|
||||
:model="item"
|
||||
:editable="true"
|
||||
@submit="createDownTask"
|
||||
>
|
||||
<template #action>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
:label="$t('download')"
|
||||
type="submit"
|
||||
></Button>
|
||||
</template>
|
||||
</ModelContent>
|
||||
|
||||
<div v-show="data.length === 0">
|
||||
<div class="flex flex-col items-center gap-4 py-8">
|
||||
<i class="pi pi-box text-3xl"></i>
|
||||
<div>No Models Found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponseScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogResizer :min-width="390"></DialogResizer>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import ModelContent from 'components/ModelContent.vue'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { useModelSearch } from 'hooks/download'
|
||||
import { ref } from 'vue'
|
||||
import { previewUrlToFile } from 'utils/common'
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { request } from 'hooks/request'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { useConfig } from 'hooks/config'
|
||||
|
||||
const visible = defineModel<boolean>('visible')
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
const { toast } = useToast()
|
||||
const loading = useLoading()
|
||||
|
||||
const modelUrl = ref<string>()
|
||||
|
||||
const { current, data, search } = useModelSearch()
|
||||
|
||||
const searchModelsByUrl = async () => {
|
||||
if (modelUrl.value) {
|
||||
await search(modelUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
modelUrl.value = undefined
|
||||
data.value = []
|
||||
}
|
||||
|
||||
const createDownTask = async (data: VersionModel) => {
|
||||
const formData = new FormData()
|
||||
|
||||
loading.show()
|
||||
// set base info
|
||||
formData.append('type', data.type)
|
||||
formData.append('pathIndex', data.pathIndex.toString())
|
||||
formData.append('fullname', data.fullname)
|
||||
// set preview
|
||||
const previewFile = await previewUrlToFile(data.preview as string).catch(
|
||||
() => {
|
||||
loading.hide()
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to download preview',
|
||||
life: 15000,
|
||||
})
|
||||
throw new Error('Failed to download preview')
|
||||
},
|
||||
)
|
||||
formData.append('previewFile', previewFile)
|
||||
// set description
|
||||
formData.append('description', data.description)
|
||||
// set model download info
|
||||
formData.append('downloadPlatform', data.downloadPlatform)
|
||||
formData.append('downloadUrl', data.downloadUrl)
|
||||
formData.append('sizeBytes', data.sizeBytes.toString())
|
||||
formData.append('hashes', JSON.stringify(data.hashes))
|
||||
|
||||
await request('/model', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(() => {
|
||||
visible.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: e.message ?? 'Failed to create download task',
|
||||
life: 15000,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
loading.hide()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
167
src/components/DialogDownload.vue
Normal file
167
src/components/DialogDownload.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:modal="true"
|
||||
pt:mask:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
|
||||
pt:root:class="max-h-full"
|
||||
pt:content:class="px-0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-1 items-center justify-between pr-2">
|
||||
<span class="p-dialog-title select-none">
|
||||
{{ $t('downloadList') }}
|
||||
</span>
|
||||
<div class="p-dialog-header-actions">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="refresh"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<div class="whitespace-nowrap px-4 @container">
|
||||
<div class="flex gap-4 @sm:justify-end">
|
||||
<Button
|
||||
class="w-full @sm:w-auto"
|
||||
:label="$t('createDownloadTask')"
|
||||
@click="toggleCreateTask"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponseScrollArea>
|
||||
<div class="w-full px-4">
|
||||
<ul class="m-0 flex list-none flex-col gap-4 p-0">
|
||||
<li
|
||||
v-for="item in data"
|
||||
:key="item.taskId"
|
||||
class="rounded-lg border border-gray-500 p-4"
|
||||
>
|
||||
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
|
||||
<div class="h-18 preview-aspect">
|
||||
<img :src="item.preview" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<span class="flex-1 overflow-hidden text-ellipsis">
|
||||
{{ item.fullname }}
|
||||
</span>
|
||||
<span v-show="item.status === 'waiting'" class="h-4">
|
||||
<i class="pi pi-spinner pi-spin"></i>
|
||||
</span>
|
||||
<span
|
||||
v-show="item.status === 'doing'"
|
||||
class="h-4 cursor-pointer"
|
||||
@click="item.pauseTask"
|
||||
>
|
||||
<i class="pi pi-pause-circle"></i>
|
||||
</span>
|
||||
<span
|
||||
v-show="item.status === 'pause'"
|
||||
class="h-4 cursor-pointer"
|
||||
@click="item.resumeTask"
|
||||
>
|
||||
<i class="pi pi-play-circle"></i>
|
||||
</span>
|
||||
<span class="h-4 cursor-pointer" @click="item.deleteTask">
|
||||
<i class="pi pi-trash text-red-400"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded bg-gray-200">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-[width]"
|
||||
:style="{ width: `${item.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div>{{ item.downloadProgress }}</div>
|
||||
<div v-show="item.status === 'doing'">
|
||||
{{ item.downloadSpeed }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- <ul class="m-0 flex list-none flex-col gap-4 p-0 px-4 pb-0">
|
||||
<li
|
||||
v-for="item in data"
|
||||
:key="item.taskId"
|
||||
class="flex flex-row gap-3 overflow-hidden rounded-lg border border-gray-500 p-4"
|
||||
>
|
||||
<div class="h-18 preview-aspect">
|
||||
<img
|
||||
:src="`/model-manager/preview/download/${item.preview}`"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ item.fullname }}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<i v-show="item.status === 'waiting'">
|
||||
{{ $t('waiting') }}...
|
||||
</i>
|
||||
<i
|
||||
v-show="item.status === 'doing'"
|
||||
class="pi pi-pause-circle"
|
||||
@click="item.pauseTask"
|
||||
></i>
|
||||
<i
|
||||
v-show="item.status === 'pause'"
|
||||
class="pi pi-play-circle"
|
||||
@click="item.resumeTask"
|
||||
></i>
|
||||
<i
|
||||
class="pi pi-trash text-red-400"
|
||||
@click="item.deleteTask"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 flex-1 overflow-hidden rounded bg-gray-200">
|
||||
<div class="h-full *:h-full *:bg-blue-500 *:transition-all">
|
||||
<div :style="{ width: `${item.progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ item.downloadProgress }}</div>
|
||||
<div v-show="item.status === 'doing'">
|
||||
{{ item.downloadSpeed }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul> -->
|
||||
</ResponseScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogResizer :min-width="390" :min-height="390"></DialogResizer>
|
||||
</Dialog>
|
||||
|
||||
<DialogCreateTask v-model:visible="openCreateTask"></DialogCreateTask>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogCreateTask from 'components/DialogCreateTask.vue'
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { useDownload } from 'hooks/download'
|
||||
import { useBoolean } from 'hooks/utils'
|
||||
|
||||
const { visible, data, refresh } = useDownload()
|
||||
|
||||
const [openCreateTask, toggleCreateTask] = useBoolean()
|
||||
</script>
|
||||
223
src/components/DialogManager.vue
Normal file
223
src/components/DialogManager.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="updateVisible"
|
||||
:maximizable="!isMobile"
|
||||
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
|
||||
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
|
||||
:pt:mask:class="['group', { open }]"
|
||||
pt:root:class="max-h-full group-[:not(.open)]:!hidden"
|
||||
pt:content:class="px-0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-1 items-center justify-between pr-2">
|
||||
<span class="p-dialog-title select-none">{{ $t('modelManager') }}</span>
|
||||
<div class="p-dialog-header-actions">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="refreshModels"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="download.toggle"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="flex h-full flex-col gap-4 overflow-hidden @container/content"
|
||||
:style="{
|
||||
['--card-width']: `${cardWidth}px`,
|
||||
['--gutter']: `${gutter}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'grid grid-cols-1 justify-center gap-4 px-8',
|
||||
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
|
||||
'@lg/content:gap-[var(--gutter)]',
|
||||
'@lg/content:px-4',
|
||||
]"
|
||||
>
|
||||
<div class="col-span-full @container/toolbar">
|
||||
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']">
|
||||
<ResponseInput
|
||||
v-model="searchContent"
|
||||
:placeholder="$t('searchModels')"
|
||||
:allow-clear="true"
|
||||
suffix-icon="pi pi-search"
|
||||
></ResponseInput>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 overflow-hidden"
|
||||
>
|
||||
<ResponseSelect
|
||||
v-model="currentType"
|
||||
:items="typeOptions"
|
||||
:type="isMobile ? 'drop' : 'button'"
|
||||
></ResponseSelect>
|
||||
<ResponseSelect
|
||||
v-model="sortOrder"
|
||||
:items="sortOrderOptions"
|
||||
></ResponseSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponseScrollArea class="h-full">
|
||||
<div
|
||||
:class="[
|
||||
'-mt-8 grid grid-cols-1 justify-center gap-8 px-8',
|
||||
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
|
||||
'@lg/content:gap-[var(--gutter)]',
|
||||
'@lg/content:-mt-[var(--gutter)]',
|
||||
'@lg/content:px-4',
|
||||
]"
|
||||
>
|
||||
<div class="col-span-full"></div>
|
||||
<div v-for="model in list" v-show="model.visible" :key="model.id">
|
||||
<DialogModelCard
|
||||
:key="`${model.type}:${model.pathIndex}:${model.fullname}`"
|
||||
:model="model"
|
||||
></DialogModelCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="noneDisplayModel" class="flex justify-center pt-20">
|
||||
<div class="select-none text-lg font-bold">No models found</div>
|
||||
</div>
|
||||
</ResponseScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogResizer
|
||||
:min-width="cardWidth * 2 + gutter + 42"
|
||||
:min-height="cardWidth * aspect * 0.5 + 162"
|
||||
></DialogResizer>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="manager-dialog">
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { useDialogManager } from 'hooks/manager'
|
||||
import { useModels } from 'hooks/model'
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import DialogModelCard from 'components/DialogModelCard.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { useDownload } from 'hooks/download'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { isMobile, cardWidth, gutter, aspect, refreshSetting } = useConfig()
|
||||
|
||||
const download = useDownload()
|
||||
|
||||
const { visible, updateVisible, open } = useDialogManager()
|
||||
const { data, refresh } = useModels()
|
||||
const { toast } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const searchContent = ref<string>()
|
||||
|
||||
const currentType = ref('all')
|
||||
const typeOptions = ref(
|
||||
[
|
||||
{ label: 'ALL', value: 'all' },
|
||||
{ label: 'Checkpoint', value: 'checkpoints' },
|
||||
{ label: 'embedding', value: 'embeddings' },
|
||||
{ label: 'Hypernetwork', value: 'hypernetworks' },
|
||||
{ label: 'Lora', value: 'loras' },
|
||||
{ label: 'VAE', value: 'vae' },
|
||||
{ label: 'VAE approx', value: 'vae_approx' },
|
||||
{ label: 'Controlnet', value: 'controlnet' },
|
||||
{ label: 'Clip', value: 'clip' },
|
||||
{ label: 'Clip Vision', value: 'clip_vision' },
|
||||
{ label: 'Diffusers', value: 'diffusers' },
|
||||
{ label: 'Gligen', value: 'gligen' },
|
||||
{ label: 'Photomaker', value: 'photomaker' },
|
||||
{ label: 'Style Models', value: 'style_models' },
|
||||
{ label: 'Unet', value: 'unet' },
|
||||
].map((item) => {
|
||||
return {
|
||||
...item,
|
||||
command: () => {
|
||||
currentType.value = item.value
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const sortOrder = ref('name')
|
||||
const sortOrderOptions = ref(
|
||||
['name', 'size', 'created', 'modified'].map((key) => {
|
||||
return {
|
||||
label: t(`sort.${key}`),
|
||||
value: key,
|
||||
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
|
||||
command: () => {
|
||||
sortOrder.value = key
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const list = computed(() => {
|
||||
const filterList = data.value.map((model) => {
|
||||
const showAllModel = currentType.value === 'all'
|
||||
|
||||
const matchType = showAllModel || model.type === currentType.value
|
||||
const matchName = model.fullname
|
||||
.toLowerCase()
|
||||
.includes(searchContent.value?.toLowerCase() || '')
|
||||
|
||||
model.visible = matchType && matchName
|
||||
|
||||
return model
|
||||
})
|
||||
|
||||
let sortStrategy = (a: Model, b: Model) => 0
|
||||
switch (sortOrder.value) {
|
||||
case 'name':
|
||||
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
|
||||
break
|
||||
case 'size':
|
||||
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
|
||||
break
|
||||
case 'created':
|
||||
sortStrategy = (a, b) => b.createdAt - a.createdAt
|
||||
break
|
||||
case 'modified':
|
||||
sortStrategy = (a, b) => b.updatedAt - a.updatedAt
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return filterList.sort(sortStrategy)
|
||||
})
|
||||
|
||||
const noneDisplayModel = computed(() => {
|
||||
return !list.value.some((model) => model.visible)
|
||||
})
|
||||
|
||||
const refreshModels = async () => {
|
||||
await Promise.all([refresh(), refreshSetting()])
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Refreshed Models',
|
||||
life: 2000,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
97
src/components/DialogModelCard.vue
Normal file
97
src/components/DialogModelCard.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
class="group/card relative w-full cursor-pointer select-none preview-aspect"
|
||||
@click.stop.prevent="toggle"
|
||||
>
|
||||
<div class="h-full overflow-hidden rounded-lg">
|
||||
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110">
|
||||
<img class="h-full w-full object-cover" :src="preview" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-draggable-overlay
|
||||
class="absolute left-0 top-0 h-full w-full"
|
||||
draggable="true"
|
||||
@dragend.stop="dragToAddModelNode"
|
||||
></div>
|
||||
|
||||
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
|
||||
<div class="relative h-full w-full text-white">
|
||||
<div class="absolute bottom-0 left-0">
|
||||
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
|
||||
<div class="line-clamp-3 break-all text-2xl font-bold @lg:text-lg">
|
||||
{{ model.basename }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-0 top-0 w-full">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
|
||||
<div class="font-bold @lg:text-xs">
|
||||
{{ displayType }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="duration-300 group-hover/card:opacity-100">
|
||||
<div class="flex flex-col gap-4 *:pointer-events-auto">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click.stop="addModelNode"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click.stop="copyModelNode"
|
||||
></Button>
|
||||
<Button
|
||||
v-show="model.preview"
|
||||
icon="pi pi-file-import"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click.stop="loadPreviewWorkflow"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogModelDetail
|
||||
v-model:visible="visible"
|
||||
:model="model"
|
||||
></DialogModelDetail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoolean } from 'hooks/utils'
|
||||
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||
import Button from 'primevue/button'
|
||||
import { resolveModelType } from 'utils/model'
|
||||
import { computed } from 'vue'
|
||||
import { useModelNodeAction } from 'hooks/model'
|
||||
|
||||
interface Props {
|
||||
model: Model
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const [visible, toggle] = useBoolean()
|
||||
|
||||
const displayType = computed(() => resolveModelType(props.model.type).display)
|
||||
const preview = computed(() =>
|
||||
Array.isArray(props.model.preview)
|
||||
? props.model.preview[0]
|
||||
: props.model.preview,
|
||||
)
|
||||
|
||||
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||
useModelNodeAction(props.model)
|
||||
</script>
|
||||
103
src/components/DialogModelDetail.vue
Normal file
103
src/components/DialogModelDetail.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:header="filename"
|
||||
:maximizable="!isMobile"
|
||||
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
|
||||
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
|
||||
pt:title:class="whitespace-nowrap text-ellipsis overflow-hidden"
|
||||
pt:root:class="max-h-full"
|
||||
pt:content:class="px-0"
|
||||
@after-hide="handleCancel"
|
||||
>
|
||||
<ResponseScrollArea class="h-full">
|
||||
<div class="px-8">
|
||||
<ModelContent
|
||||
v-model:editable="editable"
|
||||
:model="model"
|
||||
@submit="handleSave"
|
||||
@reset="handleCancel"
|
||||
>
|
||||
<template #action="{ metadata }">
|
||||
<template v-if="editable">
|
||||
<Button :label="$t('cancel')" type="reset"></Button>
|
||||
<Button :label="$t('save')" type="submit"></Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
v-show="metadata.modelPage"
|
||||
icon="pi pi-eye"
|
||||
@click="openModelPage(metadata.modelPage)"
|
||||
></Button>
|
||||
<Button icon="pi pi-plus" @click.stop="addModelNode"></Button>
|
||||
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button>
|
||||
<Button
|
||||
v-show="model.preview"
|
||||
icon="pi pi-file-import"
|
||||
@click.stop="loadPreviewWorkflow"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-pen-to-square"
|
||||
@click="editable = true"
|
||||
></Button>
|
||||
<Button
|
||||
severity="danger"
|
||||
icon="pi pi-trash"
|
||||
@click="handleDelete"
|
||||
></Button>
|
||||
</template>
|
||||
</template>
|
||||
</ModelContent>
|
||||
</div>
|
||||
</ResponseScrollArea>
|
||||
<DialogResizer :min-width="390"></DialogResizer>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import ModelContent from 'components/ModelContent.vue'
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useModelNodeAction, useModels } from 'hooks/model'
|
||||
|
||||
const visible = defineModel<boolean>('visible')
|
||||
interface Props {
|
||||
model: Model
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
const { remove, update } = useModels()
|
||||
|
||||
const editable = ref(false)
|
||||
|
||||
const filename = computed(() => {
|
||||
const basename = props.model.fullname.split('/').pop()!
|
||||
return basename.replace(props.model.extension, '')
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
editable.value = false
|
||||
}
|
||||
|
||||
const handleSave = async (data: BaseModel) => {
|
||||
editable.value = false
|
||||
await update(props.model, data)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
await remove(props.model)
|
||||
}
|
||||
|
||||
const openModelPage = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
|
||||
props.model,
|
||||
)
|
||||
</script>
|
||||
303
src/components/DialogResizer.vue
Normal file
303
src/components/DialogResizer.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div v-if="allowResize" data-dialog-resizer>
|
||||
<div
|
||||
v-if="allow?.x"
|
||||
data-resize-pos="left"
|
||||
class="absolute -left-1 top-0 h-full w-2 cursor-ew-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.x"
|
||||
data-resize-pos="right"
|
||||
class="absolute -right-1 top-0 h-full w-2 cursor-ew-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.y"
|
||||
data-resize-pos="top"
|
||||
class="absolute -top-1 left-0 h-2 w-full cursor-ns-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.y"
|
||||
data-resize-pos="bottom"
|
||||
class="absolute -bottom-1 left-0 h-2 w-full cursor-ns-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.x && allow?.y"
|
||||
data-resize-pos="top-left"
|
||||
class="absolute -left-1 -top-1 h-2 w-2 cursor-se-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.x && allow?.y"
|
||||
data-resize-pos="top-right"
|
||||
class="absolute -right-1 -top-1 h-2 w-2 cursor-sw-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.x && allow?.y"
|
||||
data-resize-pos="bottom-left"
|
||||
class="absolute -bottom-1 -left-1 h-2 w-2 cursor-sw-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.x && allow?.y"
|
||||
data-resize-pos="bottom-right"
|
||||
class="absolute -bottom-1 -right-1 h-2 w-2 cursor-se-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { clamp } from 'lodash'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
type ContainerSize = { width: number; height: number }
|
||||
type ContainerPosition = { left: number; top: number }
|
||||
|
||||
interface ResizableProps {
|
||||
defaultSize?: Partial<ContainerSize>
|
||||
defaultMobileSize?: Partial<ContainerSize>
|
||||
allow?: { x?: boolean; y?: boolean }
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ResizableProps>(), {
|
||||
allow: () => ({ x: true, y: true }),
|
||||
})
|
||||
|
||||
const config = useConfig()
|
||||
const allowResize = computed(() => {
|
||||
return !config.isMobile.value
|
||||
})
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const resizeDirection = ref<string[]>([])
|
||||
|
||||
const getContainer = () => {
|
||||
return instance!.parent!.vnode.el as HTMLDivElement
|
||||
}
|
||||
|
||||
const minWidth = computed(() => {
|
||||
const defaultMinWidth = 100
|
||||
return props.minWidth ?? defaultMinWidth
|
||||
})
|
||||
|
||||
const maxWidth = computed(() => {
|
||||
const defaultMaxWidth = window.innerWidth
|
||||
return props.maxWidth ?? defaultMaxWidth
|
||||
})
|
||||
|
||||
const minHeight = computed(() => {
|
||||
const defaultMinHeight = 100
|
||||
return props.minHeight ?? defaultMinHeight
|
||||
})
|
||||
|
||||
const maxHeight = computed(() => {
|
||||
const defaultMaxHeight = window.innerHeight
|
||||
return props.maxHeight ?? defaultMaxHeight
|
||||
})
|
||||
|
||||
const isResizing = ref(false)
|
||||
|
||||
const defaultWidth = window.innerWidth * 0.6
|
||||
const defaultHeight = window.innerHeight * 0.8
|
||||
|
||||
const containerSize = ref({
|
||||
width:
|
||||
props.defaultSize?.width ??
|
||||
clamp(defaultWidth, minWidth.value, maxWidth.value),
|
||||
height:
|
||||
props.defaultSize?.height ??
|
||||
clamp(defaultHeight, minHeight.value, maxHeight.value),
|
||||
})
|
||||
const containerPosition = ref<ContainerPosition>({ left: 0, top: 0 })
|
||||
|
||||
const updateContainerSize = (size: ContainerSize) => {
|
||||
const container = getContainer()
|
||||
container.style.width = `${size.width}px`
|
||||
container.style.height = `${size.height}px`
|
||||
}
|
||||
|
||||
const updateContainerPosition = (position: ContainerPosition) => {
|
||||
const container = getContainer()
|
||||
container.style.left = `${position.left}px`
|
||||
container.style.top = `${position.top}px`
|
||||
}
|
||||
|
||||
const recordContainerPosition = () => {
|
||||
const container = getContainer()
|
||||
containerPosition.value = {
|
||||
left: container.offsetLeft,
|
||||
top: container.offsetTop,
|
||||
}
|
||||
}
|
||||
|
||||
const updateGlobalStyle = (direction?: string) => {
|
||||
let cursor = ''
|
||||
let select = ''
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
cursor = 'ew-resize'
|
||||
select = 'none'
|
||||
break
|
||||
case 'top':
|
||||
case 'bottom':
|
||||
cursor = 'ns-resize'
|
||||
select = 'none'
|
||||
break
|
||||
case 'top-left':
|
||||
case 'bottom-right':
|
||||
cursor = 'se-resize'
|
||||
select = 'none'
|
||||
break
|
||||
case 'top-right':
|
||||
case 'bottom-left':
|
||||
cursor = 'sw-resize'
|
||||
select = 'none'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
document.body.style.cursor = cursor
|
||||
document.body.style.userSelect = select
|
||||
}
|
||||
|
||||
const resize = (event: MouseEvent) => {
|
||||
if (isResizing.value) {
|
||||
const container = getContainer()
|
||||
|
||||
for (const direction of resizeDirection.value) {
|
||||
if (direction === 'left') {
|
||||
if (event.clientX > 0) {
|
||||
containerSize.value.width = clamp(
|
||||
container.offsetLeft + container.offsetWidth - event.clientX,
|
||||
minWidth.value,
|
||||
maxWidth.value,
|
||||
)
|
||||
}
|
||||
if (
|
||||
containerSize.value.width > minWidth.value &&
|
||||
containerSize.value.width < maxWidth.value
|
||||
) {
|
||||
containerPosition.value.left = clamp(
|
||||
event.clientX,
|
||||
0,
|
||||
window.innerWidth - containerSize.value.width,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'right') {
|
||||
containerSize.value.width = clamp(
|
||||
event.clientX - container.offsetLeft,
|
||||
minWidth.value,
|
||||
maxWidth.value,
|
||||
)
|
||||
}
|
||||
|
||||
if (direction === 'top') {
|
||||
if (event.clientY > 0) {
|
||||
containerSize.value.height = clamp(
|
||||
container.offsetTop + container.offsetHeight - event.clientY,
|
||||
minHeight.value,
|
||||
maxHeight.value,
|
||||
)
|
||||
}
|
||||
if (
|
||||
containerSize.value.height > minHeight.value &&
|
||||
containerSize.value.height < maxHeight.value
|
||||
) {
|
||||
containerPosition.value.top = clamp(
|
||||
event.clientY,
|
||||
0,
|
||||
window.innerHeight - containerSize.value.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'bottom') {
|
||||
containerSize.value.height = clamp(
|
||||
event.clientY - container.offsetTop,
|
||||
minHeight.value,
|
||||
maxHeight.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
updateContainerSize(containerSize.value)
|
||||
updateContainerPosition(containerPosition.value)
|
||||
}
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
isResizing.value = false
|
||||
resizeDirection.value = []
|
||||
document.removeEventListener('mousemove', resize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
updateGlobalStyle()
|
||||
}
|
||||
|
||||
const startResize = (event: MouseEvent) => {
|
||||
isResizing.value = true
|
||||
const direction =
|
||||
(event.target as HTMLElement).getAttribute('data-resize-pos') ?? ''
|
||||
resizeDirection.value = direction.split('-')
|
||||
recordContainerPosition()
|
||||
updateGlobalStyle(direction)
|
||||
document.addEventListener('mousemove', resize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (allowResize.value) {
|
||||
updateContainerSize(containerSize.value)
|
||||
} else {
|
||||
updateContainerSize({
|
||||
width: props.defaultMobileSize?.width ?? window.innerWidth,
|
||||
height: props.defaultMobileSize?.height ?? window.innerHeight,
|
||||
})
|
||||
}
|
||||
|
||||
recordContainerPosition()
|
||||
updateContainerPosition(containerPosition.value)
|
||||
getContainer().style.position = 'fixed'
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopResize()
|
||||
})
|
||||
|
||||
watch(allowResize, (allowResize) => {
|
||||
if (allowResize) {
|
||||
updateContainerSize(containerSize.value)
|
||||
updateContainerPosition(containerPosition.value)
|
||||
} else {
|
||||
updateContainerSize({
|
||||
width: props.defaultMobileSize?.width ?? window.innerWidth,
|
||||
height: props.defaultMobileSize?.height ?? window.innerHeight,
|
||||
})
|
||||
updateContainerPosition({ left: 0, top: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
updateContainerSize,
|
||||
updateContainerPosition,
|
||||
})
|
||||
</script>
|
||||
17
src/components/FormWrapper.vue
Normal file
17
src/components/FormWrapper.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<form @submit="handleSubmit" @reset="handleReset">
|
||||
<slot name="default"></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emits = defineEmits(['submit', 'reset'])
|
||||
|
||||
const handleReset = () => {
|
||||
emits('reset')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
emits('submit')
|
||||
}
|
||||
</script>
|
||||
15
src/components/GlobalLoading.vue
Normal file
15
src/components/GlobalLoading.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div v-show="loading">
|
||||
<div class="fixed left-0 top-0 h-full w-full" style="z-index: 9999">
|
||||
<div class="flex h-full w-full items-center justify-center bg-black/30">
|
||||
<i class="pi pi-spinner pi-spin text-3xl opacity-30"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGlobalLoading } from 'hooks/loading'
|
||||
|
||||
const { loading } = useGlobalLoading()
|
||||
</script>
|
||||
22
src/components/GlobalToast.vue
Normal file
22
src/components/GlobalToast.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<Toast :position="position" :style="style"></Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from 'hooks/config'
|
||||
import Toast from 'primevue/toast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
const position = computed(() => {
|
||||
return config.isMobile.value ? 'top-center' : 'top-right'
|
||||
})
|
||||
|
||||
const style = computed(() => {
|
||||
if (config.isMobile.value) {
|
||||
return { width: '80vw' }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
90
src/components/ModelBaseInfo.vue
Normal file
90
src/components/ModelBaseInfo.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="editable" class="flex flex-col gap-4">
|
||||
<ResponseSelect v-if="!baseInfo.type" v-model="type" :items="typeOptions">
|
||||
<template #prefix>
|
||||
<span>{{ $t('modelType') }}</span>
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
|
||||
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
|
||||
</ResponseSelect>
|
||||
|
||||
<ResponseInput
|
||||
v-model.trim="basename"
|
||||
class="-mr-2 text-right"
|
||||
update-trigger="blur"
|
||||
>
|
||||
<template #suffix>
|
||||
<span class="pi-inputicon">
|
||||
{{ extension }}
|
||||
</span>
|
||||
</template>
|
||||
</ResponseInput>
|
||||
</div>
|
||||
|
||||
<table class="w-full table-fixed border-collapse border">
|
||||
<colgroup>
|
||||
<col class="w-32" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr v-for="item in information" class="h-8 border-b">
|
||||
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
|
||||
{{ $t(`info.${item.key}`) }}
|
||||
</td>
|
||||
<td class="break-all px-4">{{ item.display }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { useModelBaseInfo } from 'hooks/model'
|
||||
import { resolveModelType } from 'utils/model'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
|
||||
const { modelFolders } = useConfig()
|
||||
|
||||
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
return Object.keys(modelFolders.value).map((curr) => {
|
||||
return {
|
||||
value: curr,
|
||||
label: resolveModelType(curr).display,
|
||||
command: () => {
|
||||
type.value = curr
|
||||
pathIndex.value = 0
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const pathOptions = computed(() => {
|
||||
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
|
||||
return {
|
||||
value: index,
|
||||
label: folder,
|
||||
command: () => {
|
||||
pathIndex.value = index
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const information = computed(() => {
|
||||
return Object.values(baseInfo.value).filter((row) => {
|
||||
if (editable.value) {
|
||||
return row.key !== 'fullname'
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
96
src/components/ModelContent.vue
Normal file
96
src/components/ModelContent.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<form
|
||||
class="@container"
|
||||
@submit.prevent="handleSubmit"
|
||||
@reset.prevent="handleReset"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[50rem]">
|
||||
<div class="relative flex flex-col gap-4 overflow-hidden @xl:flex-row">
|
||||
<ModelPreview
|
||||
class="shrink-0"
|
||||
v-model:editable="editable"
|
||||
></ModelPreview>
|
||||
|
||||
<div class="flex flex-col gap-4 overflow-hidden">
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<slot name="action" :metadata="formInstance.metadata.value"></slot>
|
||||
</div>
|
||||
|
||||
<ModelBaseInfo v-model:editable="editable"></ModelBaseInfo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value="0" class="mt-4">
|
||||
<TabList>
|
||||
<Tab value="0">Description</Tab>
|
||||
<Tab value="1">Metadata</Tab>
|
||||
</TabList>
|
||||
<TabPanels pt:root:class="p-0 py-4">
|
||||
<TabPanel value="0">
|
||||
<ModelDescription v-model:editable="editable"></ModelDescription>
|
||||
</TabPanel>
|
||||
<TabPanel value="1">
|
||||
<ModelMetadata></ModelMetadata>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModelPreview from 'components/ModelPreview.vue'
|
||||
import ModelBaseInfo from 'components/ModelBaseInfo.vue'
|
||||
import ModelDescription from 'components/ModelDescription.vue'
|
||||
import ModelMetadata from 'components/ModelMetadata.vue'
|
||||
import Tab from 'primevue/tab'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import {
|
||||
useModelBaseInfoEditor,
|
||||
useModelDescriptionEditor,
|
||||
useModelFormData,
|
||||
useModelMetadataEditor,
|
||||
useModelPreviewEditor,
|
||||
} from 'hooks/model'
|
||||
import { toRaw, watch } from 'vue'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
interface Props {
|
||||
model: BaseModel
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const editable = defineModel<boolean>('editable')
|
||||
|
||||
const emits = defineEmits<{
|
||||
submit: [formData: BaseModel]
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const formInstance = useModelFormData(() => cloneDeep(toRaw(props.model)))
|
||||
|
||||
useModelBaseInfoEditor(formInstance)
|
||||
useModelPreviewEditor(formInstance)
|
||||
useModelDescriptionEditor(formInstance)
|
||||
useModelMetadataEditor(formInstance)
|
||||
|
||||
const handleReset = () => {
|
||||
formInstance.reset()
|
||||
emits('reset')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const data = formInstance.submit()
|
||||
emits('submit', data)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.model,
|
||||
() => {
|
||||
handleReset()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
91
src/components/ModelDescription.vue
Normal file
91
src/components/ModelDescription.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-show="active"
|
||||
:class="[
|
||||
'w-full resize-none overflow-hidden px-3 py-2 outline-none',
|
||||
'rounded-lg border',
|
||||
'border-[var(--p-form-field-border-color)]',
|
||||
'focus:border-[var(--p-form-field-focus-border-color)]',
|
||||
'relative z-10',
|
||||
]"
|
||||
v-model="innerValue"
|
||||
@input="resizeTextarea"
|
||||
@blur="exitEditMode"
|
||||
></textarea>
|
||||
|
||||
<div v-show="!active">
|
||||
<div v-show="editable" class="flex items-center gap-2 text-gray-600">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>
|
||||
{{ $t('tapToChange') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="renderedDescription"
|
||||
class="markdown-it"
|
||||
v-html="renderedDescription"
|
||||
></div>
|
||||
<div v-else class="flex flex-col items-center gap-2 py-5">
|
||||
<i class="pi pi-info-circle text-lg"></i>
|
||||
<div>no description</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="editable"
|
||||
class="absolute left-0 top-0 h-full w-full cursor-pointer"
|
||||
@click="entryEditMode"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModelDescription } from 'hooks/model'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
const active = ref(false)
|
||||
|
||||
const { description, renderedDescription } = useModelDescription()
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const innerValue = ref<string>()
|
||||
|
||||
watch(
|
||||
description,
|
||||
(value) => {
|
||||
innerValue.value = value
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resizeTextarea = () => {
|
||||
const textarea = textareaRef.value!
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
const scrollHeight = textarea.scrollHeight
|
||||
|
||||
textarea.style.height = scrollHeight + 'px'
|
||||
|
||||
textarea.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
|
||||
const entryEditMode = async () => {
|
||||
active.value = true
|
||||
await nextTick()
|
||||
resizeTextarea()
|
||||
textareaRef.value!.focus()
|
||||
}
|
||||
|
||||
const exitEditMode = () => {
|
||||
description.value = innerValue.value!
|
||||
active.value = false
|
||||
}
|
||||
</script>
|
||||
37
src/components/ModelMetadata.vue
Normal file
37
src/components/ModelMetadata.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<table v-if="dataSource.length" class="w-full border-collapse border">
|
||||
<tbody>
|
||||
<tr v-for="item in dataSource" class="h-8 border-b">
|
||||
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
|
||||
{{ item.key }}
|
||||
</td>
|
||||
<td class="break-all px-4">{{ item.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-2 py-5">
|
||||
<i class="pi pi-info-circle text-lg"></i>
|
||||
<div>no metadata</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModelMetadata } from 'hooks/model'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { metadata } = useModelMetadata()
|
||||
|
||||
const dataSource = computed(() => {
|
||||
const dataSource: { key: string; value: any }[] = []
|
||||
|
||||
for (const key in metadata.value) {
|
||||
if (Object.prototype.hasOwnProperty.call(metadata.value, key)) {
|
||||
const value = metadata.value[key]
|
||||
dataSource.push({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
})
|
||||
</script>
|
||||
112
src/components/ModelPreview.vue
Normal file
112
src/components/ModelPreview.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4"
|
||||
:style="{ ['--preview-width']: `${cardWidth}px` }"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
:class="[
|
||||
'relative mx-auto w-full',
|
||||
'@sm:w-[var(--preview-width)]',
|
||||
'overflow-hidden rounded-lg preview-aspect',
|
||||
]"
|
||||
>
|
||||
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
|
||||
|
||||
<Carousel
|
||||
v-if="defaultContent.length > 1"
|
||||
v-show="currentType === 'default'"
|
||||
class="absolute top-0 h-full w-full"
|
||||
:value="defaultContent"
|
||||
v-model:page="defaultContentPage"
|
||||
:circular="true"
|
||||
:show-navigators="true"
|
||||
:show-indicators="false"
|
||||
pt:contentcontainer:class="h-full"
|
||||
pt:content:class="h-full"
|
||||
pt:itemlist:class="h-full"
|
||||
:prev-button-props="{
|
||||
class: 'absolute left-4 z-10',
|
||||
rounded: true,
|
||||
severity: 'secondary',
|
||||
}"
|
||||
:next-button-props="{
|
||||
class: 'absolute right-4 z-10',
|
||||
rounded: true,
|
||||
severity: 'secondary',
|
||||
}"
|
||||
>
|
||||
<template #item="slotProps">
|
||||
<ResponseImage
|
||||
:src="slotProps.data"
|
||||
:error="noPreviewContent"
|
||||
></ResponseImage>
|
||||
</template>
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editable" class="flex flex-col gap-4 whitespace-nowrap">
|
||||
<div class="h-10"></div>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 items-center gap-4',
|
||||
'absolute left-1/2 -translate-x-1/2',
|
||||
'@xl:left-0 @xl:translate-x-0',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
v-for="type in typeOptions"
|
||||
:key="type"
|
||||
:severity="currentType === type ? undefined : 'secondary'"
|
||||
:label="$t(type)"
|
||||
@click="currentType = type"
|
||||
></Button>
|
||||
</div>
|
||||
|
||||
<div v-show="currentType === 'network'">
|
||||
<div class="absolute left-0 w-full">
|
||||
<ResponseInput
|
||||
v-model="networkContent"
|
||||
prefix-icon="pi pi-globe"
|
||||
:allow-clear="true"
|
||||
></ResponseInput>
|
||||
</div>
|
||||
<div class="h-10"></div>
|
||||
</div>
|
||||
|
||||
<div v-show="currentType === 'local'">
|
||||
<ResponseFileUpload
|
||||
class="absolute left-0 h-24 w-full"
|
||||
@select="updateLocalContent"
|
||||
>
|
||||
</ResponseFileUpload>
|
||||
<div class="h-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ResponseImage from 'components/ResponseImage.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
|
||||
import Button from 'primevue/button'
|
||||
import Carousel from 'primevue/carousel'
|
||||
import { useModelPreview } from 'hooks/model'
|
||||
import { useConfig } from 'hooks/config'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
const { cardWidth } = useConfig()
|
||||
|
||||
const {
|
||||
preview,
|
||||
typeOptions,
|
||||
currentType,
|
||||
defaultContent,
|
||||
defaultContentPage,
|
||||
networkContent,
|
||||
updateLocalContent,
|
||||
noPreviewContent,
|
||||
} = useModelPreview()
|
||||
</script>
|
||||
56
src/components/ResponseFileUpload.vue
Normal file
56
src/components/ResponseFileUpload.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg border border-gray-500 p-4 text-gray-500"
|
||||
@dragenter.stop.prevent
|
||||
@dragover.stop.prevent
|
||||
@dragleave.stop.prevent
|
||||
@drop.stop.prevent="handleDropFile"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot name="default">
|
||||
<div class="flex h-full flex-col items-center justify-center gap-2">
|
||||
<i class="pi pi-cloud-upload text-2xl"></i>
|
||||
<p class="m-0 select-none overflow-hidden text-ellipsis">
|
||||
{{ $t('uploadFile') }}
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emits = defineEmits<{
|
||||
select: [event: SelectEvent]
|
||||
}>()
|
||||
|
||||
const covertFileList = (fileList: FileList) => {
|
||||
const files: SelectFile[] = []
|
||||
for (const file of fileList) {
|
||||
const selectFile = file as SelectFile
|
||||
selectFile.objectURL = URL.createObjectURL(file)
|
||||
files.push(selectFile)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const handleDropFile = (event: DragEvent) => {
|
||||
const files = event.dataTransfer?.files
|
||||
|
||||
if (files) {
|
||||
emits('select', { originalEvent: event, files: covertFileList(files) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = () => {
|
||||
const files = input.files
|
||||
if (files) {
|
||||
emits('select', { originalEvent: event, files: covertFileList(files) })
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
</script>
|
||||
36
src/components/ResponseImage.vue
Normal file
36
src/components/ResponseImage.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<span class="relative">
|
||||
<img :src="src" :alt="alt" v-bind="$attrs" @error="onError" />
|
||||
<img v-if="error" v-show="loadError" :src="error" class="absolute top-0" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
src?: string
|
||||
alt?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const loadError = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
loadError.value = !props.src
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const onError = () => {
|
||||
loadError.value = true
|
||||
}
|
||||
</script>
|
||||
82
src/components/ResponseInput.vue
Normal file
82
src/components/ResponseInput.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="p-component p-inputtext flex items-center gap-2">
|
||||
<slot name="prefix">
|
||||
<span v-if="prefixIcon" :class="[prefixIcon, 'pi-inputicon']"></span>
|
||||
</slot>
|
||||
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="innerValue"
|
||||
class="flex-1 border-none bg-transparent text-base outline-none"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
@paste.stop
|
||||
v-bind="$attrs"
|
||||
@[trigger]="updateContent"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="allowClear"
|
||||
v-show="content"
|
||||
class="pi pi-times pi-inputicon"
|
||||
@click="clearContent"
|
||||
></span>
|
||||
<slot name="suffix">
|
||||
<span v-if="suffixIcon" :class="[suffixIcon, 'pi-inputicon']"></span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
prefixIcon?: string
|
||||
suffixIcon?: string
|
||||
placeholder?: string
|
||||
allowClear?: boolean
|
||||
updateTrigger?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const [content, modifiers] = defineModel<string, 'trim'>()
|
||||
|
||||
const inputRef = ref()
|
||||
|
||||
const innerValue = ref(content)
|
||||
const trigger = computed(() => props.updateTrigger ?? 'input')
|
||||
const updateContent = () => {
|
||||
let value = innerValue.value
|
||||
|
||||
if (modifiers.trim) {
|
||||
value = innerValue.value?.trim()
|
||||
}
|
||||
|
||||
content.value = value
|
||||
inputRef.value.value = value
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const clearContent = () => {
|
||||
content.value = undefined
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.p-inputtext:focus-within {
|
||||
border-color: var(--p-inputtext-focus-border-color);
|
||||
box-shadow: var(--p-inputtext-focus-ring-shadow);
|
||||
outline: var(--p-inputtext-focus-ring-width)
|
||||
var(--p-inputtext-focus-ring-style) var(--p-inputtext-focus-ring-color);
|
||||
outline-offset: var(--p-inputtext-focus-ring-offset);
|
||||
}
|
||||
|
||||
.p-inputtext .pi-inputicon {
|
||||
font-size: 1rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
214
src/components/ResponseScrollArea.vue
Normal file
214
src/components/ResponseScrollArea.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div data-scroll-area class="group/scroll relative overflow-hidden">
|
||||
<div
|
||||
ref="viewport"
|
||||
data-scroll-viewport
|
||||
class="h-full w-full overflow-auto scrollbar-none"
|
||||
@scroll="onContentScroll"
|
||||
v-resize="onContainerResize"
|
||||
>
|
||||
<div data-scroll-content style="min-width: 100%">
|
||||
<slot name="default"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="scroll in scrollbars"
|
||||
:key="scroll.direction"
|
||||
v-show="scroll.visible"
|
||||
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
|
||||
:class="[
|
||||
'pointer-events-none absolute z-auto h-full w-full rounded-full',
|
||||
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
|
||||
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-bind="{ ['data-scroll-thumb']: scroll.direction }"
|
||||
:class="[
|
||||
'pointer-events-auto absolute h-full w-full rounded-full',
|
||||
'cursor-pointer bg-black dark:bg-white',
|
||||
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
|
||||
]"
|
||||
:style="{
|
||||
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`,
|
||||
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`,
|
||||
opacity: isDragging ? 0.1 : '',
|
||||
}"
|
||||
@mousedown="startDragThumb"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onUnmounted, ref } from 'vue'
|
||||
import { clamp, throttle } from 'lodash'
|
||||
|
||||
interface ScrollAreaProps {
|
||||
scrollbar?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaProps>(), {
|
||||
scrollbar: true,
|
||||
})
|
||||
const emit = defineEmits(['scroll', 'resize'])
|
||||
|
||||
type ScrollbarDirection = 'horizontal' | 'vertical'
|
||||
|
||||
interface Scrollbar {
|
||||
direction: ScrollbarDirection
|
||||
visible: boolean
|
||||
size: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
interface ScrollbarAttribute {
|
||||
clientSize: string
|
||||
scrollOffset: string
|
||||
pagePosition: string
|
||||
offset: string
|
||||
size: string
|
||||
}
|
||||
|
||||
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = {
|
||||
horizontal: {
|
||||
clientSize: 'clientWidth',
|
||||
scrollOffset: 'scrollLeft',
|
||||
pagePosition: 'pageX',
|
||||
offset: 'left',
|
||||
size: 'width',
|
||||
},
|
||||
vertical: {
|
||||
clientSize: 'clientHeight',
|
||||
scrollOffset: 'scrollTop',
|
||||
pagePosition: 'pageY',
|
||||
offset: 'top',
|
||||
size: 'height',
|
||||
},
|
||||
}
|
||||
|
||||
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
|
||||
horizontal: {
|
||||
direction: 'horizontal',
|
||||
visible: props.scrollbar,
|
||||
size: 0,
|
||||
offset: 0,
|
||||
},
|
||||
vertical: {
|
||||
direction: 'vertical',
|
||||
visible: props.scrollbar,
|
||||
size: 0,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const onContainerResize: ResizeObserverCallback = throttle((entries) => {
|
||||
emit('resize', entries)
|
||||
if (isDragging.value) return
|
||||
|
||||
const entry = entries[0]
|
||||
const container = entry.target as HTMLElement
|
||||
const content = container.querySelector('[data-scroll-content]')!
|
||||
|
||||
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
||||
const containerSize: number = container[attr.clientSize]
|
||||
const contentSize: number = content[attr.clientSize]
|
||||
item.visible = props.scrollbar && contentSize > containerSize
|
||||
item.size = Math.pow(containerSize, 2) / contentSize
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
|
||||
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
|
||||
})
|
||||
})
|
||||
|
||||
const onContentScroll = throttle((event: Event) => {
|
||||
emit('scroll', event)
|
||||
if (isDragging.value) return
|
||||
|
||||
const container = event.target as HTMLDivElement
|
||||
const content = container.querySelector('[data-scroll-content]')!
|
||||
|
||||
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
||||
const containerSize = container[attr.clientSize]
|
||||
const contentSize = content[attr.clientSize]
|
||||
const scrollOffset = container[attr.scrollOffset]
|
||||
|
||||
item.offset =
|
||||
(scrollOffset / (contentSize - containerSize)) *
|
||||
(containerSize - item.size)
|
||||
}
|
||||
|
||||
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
|
||||
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
|
||||
})
|
||||
|
||||
const viewport = ref<HTMLElement>()
|
||||
const draggingDirection = ref<ScrollbarDirection>()
|
||||
const prevDraggingEvent = ref<MouseEvent>()
|
||||
|
||||
const moveThumb = throttle((event: MouseEvent) => {
|
||||
if (isDragging.value) {
|
||||
const container = viewport.value!
|
||||
const content = container.querySelector('[data-scroll-content]')!
|
||||
|
||||
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
||||
const containerSize = container[attr.clientSize]
|
||||
const contentSize = content[attr.clientSize]
|
||||
|
||||
// Resolve thumb position
|
||||
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
|
||||
const currPagePos = event[attr.pagePosition]
|
||||
const offset = currPagePos - prevPagePos
|
||||
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
|
||||
|
||||
// Resolve scroll position
|
||||
const scrollOffset = containerSize - item.size
|
||||
const offsetSize = contentSize - containerSize
|
||||
|
||||
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
|
||||
}
|
||||
|
||||
const scrollDirection = draggingDirection.value!
|
||||
|
||||
resolveOffset(
|
||||
scrollbars.value[scrollDirection],
|
||||
scrollbarAttrs[scrollDirection],
|
||||
)
|
||||
prevDraggingEvent.value = event
|
||||
}
|
||||
})
|
||||
|
||||
const stopMoveThumb = () => {
|
||||
isDragging.value = false
|
||||
draggingDirection.value = undefined
|
||||
prevDraggingEvent.value = undefined
|
||||
document.removeEventListener('mousemove', moveThumb)
|
||||
document.removeEventListener('mouseup', stopMoveThumb)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const startDragThumb = (event: MouseEvent) => {
|
||||
isDragging.value = true
|
||||
const target = event.target as HTMLElement
|
||||
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
|
||||
prevDraggingEvent.value = event
|
||||
document.addEventListener('mousemove', moveThumb)
|
||||
document.addEventListener('mouseup', stopMoveThumb)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'default'
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopMoveThumb()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
viewport,
|
||||
})
|
||||
</script>
|
||||
234
src/components/ResponseSelect.vue
Normal file
234
src/components/ResponseSelect.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<slot
|
||||
v-if="type === 'drop'"
|
||||
name="target"
|
||||
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
|
||||
>
|
||||
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
|
||||
<Button
|
||||
v-bind="{ rounded, text, severity, size }"
|
||||
class="w-full whitespace-nowrap"
|
||||
>
|
||||
<slot name="prefix">
|
||||
<span v-if="prefixIcon" class="p-button-icon p-button-icon-left">
|
||||
<i :class="prefixIcon"></i>
|
||||
</span>
|
||||
</slot>
|
||||
<span class="flex-1 overflow-scroll text-right scrollbar-none">
|
||||
<slot name="label">{{ currentLabel }}</slot>
|
||||
</span>
|
||||
<slot name="suffix">
|
||||
<span v-if="suffixIcon" class="p-button-icon p-button-icon-right">
|
||||
<i :class="suffixIcon"></i>
|
||||
</span>
|
||||
</slot>
|
||||
</Button>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div v-else class="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref="scrollArea"
|
||||
class="h-full w-full overflow-auto scrollbar-none"
|
||||
v-resize="checkScrollPosition"
|
||||
@scroll="checkScrollPosition"
|
||||
>
|
||||
<div ref="contentArea" class="table max-w-full">
|
||||
<div
|
||||
v-show="showControlButton && scrollPosition !== 'left'"
|
||||
:class="[
|
||||
'pointer-events-none absolute left-0 top-1/2 z-10',
|
||||
'-translate-y-1/2 bg-gradient-to-r from-current to-transparent pr-16',
|
||||
]"
|
||||
style="color: var(--p-dialog-background)"
|
||||
>
|
||||
<Button
|
||||
icon="pi pi-angle-left"
|
||||
class="pointer-events-auto border-none bg-transparent"
|
||||
severity="secondary"
|
||||
@click="scrollTo('prev')"
|
||||
:size="size"
|
||||
></Button>
|
||||
</div>
|
||||
<div class="flex h-10 items-center gap-2">
|
||||
<Button
|
||||
v-for="item in items"
|
||||
severity="secondary"
|
||||
:key="item.value"
|
||||
:data-active="current === item.value"
|
||||
:active="current === item.value"
|
||||
class="data-[active=true]:bg-blue-500 data-[active=true]:text-white"
|
||||
:size="size"
|
||||
@click="item.command"
|
||||
>
|
||||
<span class="whitespace-nowrap">{{ item.label }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-show="showControlButton && scrollPosition !== 'right'"
|
||||
:class="[
|
||||
'pointer-events-none absolute right-0 top-1/2 z-10',
|
||||
'-translate-y-1/2 bg-gradient-to-l from-current to-transparent pl-16',
|
||||
]"
|
||||
style="color: var(--p-dialog-background)"
|
||||
>
|
||||
<Button
|
||||
:size="size"
|
||||
icon="pi pi-angle-right"
|
||||
class="pointer-events-auto border-none bg-transparent"
|
||||
severity="secondary"
|
||||
@click="scrollTo('next')"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot v-if="isMobile" name="mobile">
|
||||
<Drawer
|
||||
v-model:visible="visible"
|
||||
position="bottom"
|
||||
style="height: auto; max-height: 80%"
|
||||
>
|
||||
<template #container>
|
||||
<slot name="container">
|
||||
<slot name="mobile:container">
|
||||
<div class="h-full overflow-scroll scrollbar-none">
|
||||
<Menu
|
||||
:model="items"
|
||||
pt:root:class="border-0 px-4 py-5"
|
||||
:pt:list:onClick="toggle"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<slot name="item" :item="item">
|
||||
<slot name="mobile:container:item" :item="item">
|
||||
<a class="p-menu-item-link justify-between">
|
||||
<span
|
||||
class="p-menu-item-label overflow-hidden break-words"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span v-show="current === item.value">
|
||||
<i class="pi pi-check text-blue-400"></i>
|
||||
</span>
|
||||
</a>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
</Menu>
|
||||
</div>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
</Drawer>
|
||||
</slot>
|
||||
|
||||
<slot v-else name="desktop">
|
||||
<slot name="container">
|
||||
<slot name="desktop:container">
|
||||
<Menu ref="menu" :model="items" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<slot name="item" :item="item">
|
||||
<slot name="desktop:container:item" :item="item">
|
||||
<a class="p-menu-item-link justify-between">
|
||||
<span class="p-menu-item-label">{{ item.label }}</span>
|
||||
<span v-show="current === item.value">
|
||||
<i class="pi pi-check text-blue-400"></i>
|
||||
</span>
|
||||
</a>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
</Menu>
|
||||
</slot>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from 'hooks/config'
|
||||
import Button, { ButtonProps } from 'primevue/button'
|
||||
import Drawer from 'primevue/drawer'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const current = defineModel()
|
||||
|
||||
interface Props {
|
||||
items?: SelectOptions[]
|
||||
rounded?: boolean
|
||||
text?: boolean
|
||||
severity?: ButtonProps['severity']
|
||||
size?: ButtonProps['size']
|
||||
type?: 'button' | 'drop'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
severity: 'secondary',
|
||||
type: 'drop',
|
||||
})
|
||||
|
||||
const suffixIcon = ref('pi pi-angle-down')
|
||||
const prefixIcon = computed(() => {
|
||||
return props.items?.find((item) => item.value === current.value)?.icon
|
||||
})
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
return props.items?.find((item) => item.value === current.value)?.label
|
||||
})
|
||||
|
||||
const menu = ref()
|
||||
const visible = ref(false)
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
|
||||
const toggle = (event: MouseEvent) => {
|
||||
if (isMobile.value) {
|
||||
visible.value = !visible.value
|
||||
} else {
|
||||
menu.value.toggle(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Select Button Type
|
||||
const scrollArea = ref()
|
||||
const contentArea = ref()
|
||||
|
||||
type ScrollPosition = 'left' | 'right'
|
||||
|
||||
const scrollPosition = ref<ScrollPosition | undefined>('left')
|
||||
const showControlButton = ref<boolean>(true)
|
||||
|
||||
const scrollTo = (type: 'prev' | 'next') => {
|
||||
const container = scrollArea.value as HTMLDivElement
|
||||
const scrollLeft = container.scrollLeft
|
||||
const direction = type === 'prev' ? -1 : 1
|
||||
const distance = (container.clientWidth / 3) * 2
|
||||
container.scrollTo({
|
||||
left: scrollLeft + direction * distance,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
const container = scrollArea.value as HTMLDivElement
|
||||
const content = contentArea.value as HTMLDivElement
|
||||
|
||||
const scrollLeft = container.scrollLeft
|
||||
|
||||
const containerWidth = container.clientWidth
|
||||
const contentWidth = content.clientWidth
|
||||
|
||||
let position: ScrollPosition | undefined = undefined
|
||||
|
||||
if (scrollLeft === 0) {
|
||||
position = 'left'
|
||||
}
|
||||
if (Math.ceil(scrollLeft) >= contentWidth - containerWidth) {
|
||||
position = 'right'
|
||||
}
|
||||
|
||||
scrollPosition.value = position
|
||||
showControlButton.value = contentWidth > containerWidth
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user