|
|
@@ -0,0 +1,872 @@
|
|
|
+<template>
|
|
|
+ <div class="model-card">
|
|
|
+ <div class="tabs-row">
|
|
|
+ <nav class="tabs">
|
|
|
+ <button type="button" class="tab" :class="{ on: pane === 'lib' }" @click="pane = 'lib'">模特库选择</button>
|
|
|
+ <button type="button" class="tab" :class="{ on: pane === 'upm' }" @click="pane = 'upm'">我的上传</button>
|
|
|
+ </nav>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="pane === 'lib'" class="lib-pane">
|
|
|
+ <div class="chips-row">
|
|
|
+ <div class="chips">
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'all' }" @click="gender = 'all'">全部</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'male' }" @click="gender = 'male'">男</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'female' }" @click="gender = 'female'">女</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'child' }" @click="gender = 'child'">儿童</button>
|
|
|
+ </div>
|
|
|
+ <span class="hbtns">
|
|
|
+ <button class="hbtn" type="button" title="向左" @click="scrollGrid(libScrollRef, -1)"><svg><use href="#i-left"/></svg></button>
|
|
|
+ <button class="hbtn" type="button" title="向右" @click="scrollGrid(libScrollRef, 1)"><svg><use href="#i-right"/></svg></button>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="hscroll" ref="libScrollRef">
|
|
|
+ <div
|
|
|
+ v-for="model in visibleLibModels"
|
|
|
+ :key="model.id"
|
|
|
+ class="model"
|
|
|
+ :class="{ sel: isSelected(model) }"
|
|
|
+ @click="selectModel(model)"
|
|
|
+ >
|
|
|
+ <el-image
|
|
|
+ v-if="imagesReady"
|
|
|
+ :src="thumbUrl(model.image_url)"
|
|
|
+ :alt="model.name"
|
|
|
+ fit="cover"
|
|
|
+ lazy
|
|
|
+ :scroll-container="libScrollRef || undefined"
|
|
|
+ class="model-img"
|
|
|
+ >
|
|
|
+ <template #placeholder>
|
|
|
+ <div class="model-ph" />
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ <div v-else class="model-ph" />
|
|
|
+ <span class="g-tag">{{ genderLabel(model) }}</span>
|
|
|
+ <span class="ck">✓</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="!visibleLibModels.length" class="empty">暂无模特</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="pane === 'upm'" class="up-pane">
|
|
|
+ <div
|
|
|
+ v-if="!pendingPreview"
|
|
|
+ class="up"
|
|
|
+ :class="hasMine ? 'bar' : 'fill'"
|
|
|
+ @click="pickImage"
|
|
|
+ @dragover.prevent
|
|
|
+ @drop.prevent="onDrop"
|
|
|
+ >
|
|
|
+ <span class="uic"><svg><use href="#i-upl"/></svg></span>
|
|
|
+ <span class="up-copy">
|
|
|
+ <span class="t">上传我的模特图</span>
|
|
|
+ <span class="s">{{ hasMine ? '继续添加,点击即可作为对应性别模特' : '不满意现有模特?上传即可更换,尺寸不限' }}</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div v-else class="up-form" :class="hasMine ? 'bar' : 'fill'">
|
|
|
+ <div class="up-preview">
|
|
|
+ <el-image :src="pendingPreview" fit="cover" class="up-preview-img" />
|
|
|
+ </div>
|
|
|
+ <div class="up-form-side">
|
|
|
+ <div class="up-label">选择类别后确认,将用于对应性别生成</div>
|
|
|
+ <div class="chips">
|
|
|
+ <button type="button" class="chip" :class="{ on: pendingKeywords === '男性' }" @click="pendingKeywords = '男性'">男模特</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: pendingKeywords === '女性' }" @click="pendingKeywords = '女性'">女模特</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: pendingKeywords === '儿童' }" @click="pendingKeywords = '儿童'">儿童</button>
|
|
|
+ </div>
|
|
|
+ <div class="up-actions">
|
|
|
+ <button class="gbtn ghost" type="button" :disabled="uploading" @click="resetPending">重新选择</button>
|
|
|
+ <button class="gbtn solid" type="button" :disabled="uploading" @click="confirmUpload">
|
|
|
+ {{ uploading ? '上传中…' : '确认上传' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-show="hasMine" class="chips-row">
|
|
|
+ <div class="chips">
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'all' }" @click="gender = 'all'">全部</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'male' }" @click="gender = 'male'">男</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'female' }" @click="gender = 'female'">女</button>
|
|
|
+ <button type="button" class="chip" :class="{ on: gender === 'child' }" @click="gender = 'child'">儿童</button>
|
|
|
+ </div>
|
|
|
+ <span class="hbtns">
|
|
|
+ <button class="hbtn" type="button" title="向左" @click="scrollGrid(mineScrollRef, -1)"><svg><use href="#i-left"/></svg></button>
|
|
|
+ <button class="hbtn" type="button" title="向右" @click="scrollGrid(mineScrollRef, 1)"><svg><use href="#i-right"/></svg></button>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div v-show="hasMine" class="hscroll" ref="mineScrollRef">
|
|
|
+ <div
|
|
|
+ v-for="model in visibleMineModels"
|
|
|
+ :key="model.id"
|
|
|
+ class="model"
|
|
|
+ :class="{ sel: isSelected(model) }"
|
|
|
+ @click="selectModel(model)"
|
|
|
+ >
|
|
|
+ <el-image
|
|
|
+ v-if="imagesReady"
|
|
|
+ :src="thumbUrl(model.image_url)"
|
|
|
+ :alt="model.name"
|
|
|
+ fit="cover"
|
|
|
+ lazy
|
|
|
+ :scroll-container="mineScrollRef || undefined"
|
|
|
+ class="model-img"
|
|
|
+ >
|
|
|
+ <template #placeholder>
|
|
|
+ <div class="model-ph" />
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ <div v-else class="model-ph" />
|
|
|
+ <span class="g-tag">{{ genderLabel(model) }}</span>
|
|
|
+ <span class="ck">✓</span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="del"
|
|
|
+ title="删除"
|
|
|
+ :disabled="deletingId === model.id"
|
|
|
+ @click.stop="deleteMine(model)"
|
|
|
+ >×</button>
|
|
|
+ </div>
|
|
|
+ <div v-if="!visibleMineModels.length" class="empty">该类别暂无上传模特</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ ref="fileInput"
|
|
|
+ type="file"
|
|
|
+ accept=".jpg,.jpeg,.png"
|
|
|
+ hidden
|
|
|
+ @change="onFilePicked"
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
+import { getShoesModelTemplateApi, addShoesModelTemplate, delShoesModelTemplate, uploadBaseImg } from '@/apis/other'
|
|
|
+import { ossResize } from '@/utils/oss'
|
|
|
+import { imagesUpload } from '@/utils/appconfig'
|
|
|
+import client from '@/stores/modules/client'
|
|
|
+import icpList from '@/utils/ipc'
|
|
|
+import { useAdaptiveGrid } from '../composables/useAdaptiveGrid'
|
|
|
+
|
|
|
+interface ModelData {
|
|
|
+ id: number
|
|
|
+ name: string
|
|
|
+ image_url: string
|
|
|
+ gender?: 'male' | 'female' | 'child'
|
|
|
+ keywords: string
|
|
|
+ status: number
|
|
|
+}
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ modelValue: { female: any; male: any; child?: any } | null
|
|
|
+}>()
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ 'update:modelValue': [value: { female: any; male: any; child?: any }]
|
|
|
+}>()
|
|
|
+
|
|
|
+const clientStore = client()
|
|
|
+const pane = ref<'lib' | 'upm'>('lib')
|
|
|
+const gender = ref<'all' | 'male' | 'female' | 'child'>('all')
|
|
|
+const femaleModels = ref<ModelData[]>([])
|
|
|
+const maleModels = ref<ModelData[]>([])
|
|
|
+const childModels = ref<ModelData[]>([])
|
|
|
+const myModels = ref<ModelData[]>([])
|
|
|
+const libScrollRef = ref<HTMLElement | null>(null)
|
|
|
+const mineScrollRef = ref<HTMLElement | null>(null)
|
|
|
+const fileInput = ref<HTMLInputElement | null>(null)
|
|
|
+
|
|
|
+const pendingPreview = ref('')
|
|
|
+const pendingKeywords = ref<'男性' | '女性' | '儿童' | ''>('')
|
|
|
+const uploading = ref(false)
|
|
|
+const deletingId = ref<number | null>(null)
|
|
|
+const imagesReady = ref(false)
|
|
|
+
|
|
|
+const hasMine = computed(() => myModels.value.length > 0)
|
|
|
+
|
|
|
+function filterByGender(list: ModelData[]) {
|
|
|
+ if (gender.value === 'female') return list.filter((model) => model.keywords === '女性')
|
|
|
+ if (gender.value === 'male') return list.filter((model) => model.keywords === '男性')
|
|
|
+ if (gender.value === 'child') return list.filter((model) => model.keywords === '儿童')
|
|
|
+ return list
|
|
|
+}
|
|
|
+
|
|
|
+const visibleLibModels = computed(() => {
|
|
|
+ if (gender.value === 'female') return femaleModels.value
|
|
|
+ if (gender.value === 'male') return maleModels.value
|
|
|
+ if (gender.value === 'child') return childModels.value
|
|
|
+ return [...maleModels.value, ...femaleModels.value, ...childModels.value]
|
|
|
+})
|
|
|
+
|
|
|
+const visibleMineModels = computed(() => filterByGender(myModels.value))
|
|
|
+
|
|
|
+const gridOpts = {
|
|
|
+ minItemWidth: 84,
|
|
|
+ minItemHeight: 108,
|
|
|
+ minRows: 1,
|
|
|
+ maxRows: 4,
|
|
|
+ cols: 2,
|
|
|
+ gap: 11,
|
|
|
+}
|
|
|
+
|
|
|
+const { measure: measureLib } = useAdaptiveGrid(libScrollRef, {
|
|
|
+ ...gridOpts,
|
|
|
+ getItemCount: () => visibleLibModels.value.length,
|
|
|
+})
|
|
|
+const { measure: measureMine } = useAdaptiveGrid(mineScrollRef, {
|
|
|
+ ...gridOpts,
|
|
|
+ getItemCount: () => visibleMineModels.value.length,
|
|
|
+})
|
|
|
+
|
|
|
+function measureActive() {
|
|
|
+ if (pane.value === 'lib') measureLib()
|
|
|
+ else measureMine()
|
|
|
+}
|
|
|
+
|
|
|
+function modelKind(model: ModelData): 'female' | 'male' | 'child' {
|
|
|
+ if (model.keywords === '女性') return 'female'
|
|
|
+ if (model.keywords === '儿童') return 'child'
|
|
|
+ return 'male'
|
|
|
+}
|
|
|
+
|
|
|
+function thumbUrl(url?: string) {
|
|
|
+ if (!url) return ''
|
|
|
+ if (/^(data:|blob:|file:)/i.test(url)) return url
|
|
|
+ return ossResize(url, { m: 'fill', w: 280, h: 360 })
|
|
|
+}
|
|
|
+
|
|
|
+function genderLabel(model: ModelData) {
|
|
|
+ if (model.keywords === '女性') return '女'
|
|
|
+ if (model.keywords === '儿童') return '童'
|
|
|
+ return '男'
|
|
|
+}
|
|
|
+
|
|
|
+function pickFields(model: ModelData | null) {
|
|
|
+ if (!model) return null
|
|
|
+ return {
|
|
|
+ id: model.id,
|
|
|
+ name: model.name,
|
|
|
+ image_url: model.image_url,
|
|
|
+ gender: model.gender,
|
|
|
+ keywords: model.keywords,
|
|
|
+ status: model.status,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isSelected(model: ModelData) {
|
|
|
+ const kind = modelKind(model)
|
|
|
+ return props.modelValue?.[kind]?.id === model.id
|
|
|
+}
|
|
|
+
|
|
|
+function clearIfSelected(model: ModelData) {
|
|
|
+ const kind = modelKind(model)
|
|
|
+ if (props.modelValue?.[kind]?.id !== model.id) return
|
|
|
+ emit('update:modelValue', {
|
|
|
+ female: props.modelValue?.female || null,
|
|
|
+ male: props.modelValue?.male || null,
|
|
|
+ child: props.modelValue?.child || null,
|
|
|
+ [kind]: null,
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteMine(model: ModelData) {
|
|
|
+ if (deletingId.value) return
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm('是否删除该模特?', '提示', {
|
|
|
+ confirmButtonText: '删除',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning',
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ deletingId.value = model.id
|
|
|
+ try {
|
|
|
+ await delShoesModelTemplate({ id: model.id })
|
|
|
+ clearIfSelected(model)
|
|
|
+ ElMessage.success('删除成功')
|
|
|
+ await fetchModelList()
|
|
|
+ } catch {
|
|
|
+ // http 拦截器已提示
|
|
|
+ } finally {
|
|
|
+ deletingId.value = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function selectModel(model: ModelData) {
|
|
|
+ const next = {
|
|
|
+ female: props.modelValue?.female || null,
|
|
|
+ male: props.modelValue?.male || null,
|
|
|
+ child: props.modelValue?.child || null,
|
|
|
+ }
|
|
|
+ const kind = modelKind(model)
|
|
|
+ next[kind] = pickFields(model)
|
|
|
+ emit('update:modelValue', next)
|
|
|
+}
|
|
|
+
|
|
|
+function scrollGrid(el: HTMLElement | null, dir: number) {
|
|
|
+ if (!el) return
|
|
|
+ const delta = Math.max(el.clientWidth * 0.9, 1) * dir
|
|
|
+ el.scrollBy({ left: delta, behavior: 'smooth' })
|
|
|
+}
|
|
|
+
|
|
|
+function splitByKeywords(list: ModelData[]) {
|
|
|
+ return {
|
|
|
+ female: list.filter((model) => model.keywords === '女性'),
|
|
|
+ male: list.filter((model) => model.keywords === '男性'),
|
|
|
+ child: list.filter((model) => model.keywords === '儿童'),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchModelList() {
|
|
|
+ try {
|
|
|
+ const [recommendRes, myRes] = await Promise.all([
|
|
|
+ getShoesModelTemplateApi({ status: 2 }),
|
|
|
+ getShoesModelTemplateApi({ is_my: 1 }),
|
|
|
+ ])
|
|
|
+ const recommend = (recommendRes && recommendRes.data) || []
|
|
|
+ const mine = (myRes && myRes.data) || []
|
|
|
+ const grouped = splitByKeywords(recommend)
|
|
|
+ femaleModels.value = grouped.female
|
|
|
+ maleModels.value = grouped.male
|
|
|
+ childModels.value = grouped.child
|
|
|
+ myModels.value = Array.isArray(mine) ? mine : []
|
|
|
+ nextTick(() => measureActive())
|
|
|
+ } catch {
|
|
|
+ ElMessage.error('获取模特列表失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isOversize(dataUrl: string) {
|
|
|
+ const b64 = dataUrl.split(',')[1] || ''
|
|
|
+ return (b64.length * 3) / 4 / 1024 > imagesUpload.fileSize
|
|
|
+}
|
|
|
+
|
|
|
+function applyPickedImage(dataUrl: string) {
|
|
|
+ if (!dataUrl) return
|
|
|
+ if (isOversize(dataUrl)) {
|
|
|
+ ElMessage.error('上传文件大小不能超过 20MB')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ pendingPreview.value = dataUrl
|
|
|
+}
|
|
|
+
|
|
|
+function readLocalFile(file: File) {
|
|
|
+ if (!/\.(jpe?g|png)$/i.test(file.name)) {
|
|
|
+ ElMessage.error('请上传 jpg/png 格式图片')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (file.size / 1024 > imagesUpload.fileSize) {
|
|
|
+ ElMessage.error('上传文件大小不能超过 20MB')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const reader = new FileReader()
|
|
|
+ reader.onload = () => {
|
|
|
+ applyPickedImage(String(reader.result || ''))
|
|
|
+ }
|
|
|
+ reader.readAsDataURL(file)
|
|
|
+}
|
|
|
+
|
|
|
+function pickImage() {
|
|
|
+ if (uploading.value) return
|
|
|
+ if (clientStore.isClient && clientStore.ipc) {
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.openImage)
|
|
|
+ clientStore.ipc.send(icpList.utils.openImage)
|
|
|
+ clientStore.ipc.on(icpList.utils.openImage, (_event: unknown, result: { filePath?: string; base64Image?: string }) => {
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.openImage)
|
|
|
+ if (result?.base64Image) applyPickedImage(result.base64Image)
|
|
|
+ else if (!result?.filePath) return
|
|
|
+ else ElMessage.error('无法读取图片,请重试')
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ fileInput.value?.click()
|
|
|
+}
|
|
|
+
|
|
|
+function onFilePicked(e: Event) {
|
|
|
+ const input = e.target as HTMLInputElement
|
|
|
+ const file = input.files?.[0]
|
|
|
+ input.value = ''
|
|
|
+ if (file) readLocalFile(file)
|
|
|
+}
|
|
|
+
|
|
|
+function onDrop(e: DragEvent) {
|
|
|
+ const file = e.dataTransfer?.files?.[0]
|
|
|
+ if (file) readLocalFile(file)
|
|
|
+}
|
|
|
+
|
|
|
+function resetPending() {
|
|
|
+ pendingPreview.value = ''
|
|
|
+ pendingKeywords.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+async function confirmUpload() {
|
|
|
+ if (!pendingPreview.value) {
|
|
|
+ ElMessage.error('请上传模特图')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!pendingKeywords.value) {
|
|
|
+ ElMessage.error('请选择类别')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ uploading.value = true
|
|
|
+ const keywords = pendingKeywords.value
|
|
|
+ try {
|
|
|
+ const res: any = await uploadBaseImg({ image: pendingPreview.value })
|
|
|
+ const imageUrl = res?.data?.url || res?.url
|
|
|
+ if (!imageUrl) {
|
|
|
+ ElMessage.error('图片上传失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await addShoesModelTemplate({
|
|
|
+ status: 0,
|
|
|
+ image_url: imageUrl,
|
|
|
+ keywords,
|
|
|
+ })
|
|
|
+ ElMessage.success('上传成功')
|
|
|
+ resetPending()
|
|
|
+ await fetchModelList()
|
|
|
+ gender.value = keywords === '女性' ? 'female' : keywords === '儿童' ? 'child' : 'male'
|
|
|
+ pane.value = 'upm'
|
|
|
+ const found =
|
|
|
+ myModels.value.find((model) => model.image_url === imageUrl) ||
|
|
|
+ myModels.value.find((model) => model.keywords === keywords)
|
|
|
+ if (found) selectModel(found)
|
|
|
+ } catch {
|
|
|
+ // http 拦截器已提示
|
|
|
+ } finally {
|
|
|
+ uploading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function revealImages() {
|
|
|
+ imagesReady.value = false
|
|
|
+ await nextTick()
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ imagesReady.value = true
|
|
|
+ measureActive()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+watch(gender, () => {
|
|
|
+ libScrollRef.value?.scrollTo({ left: 0 })
|
|
|
+ mineScrollRef.value?.scrollTo({ left: 0 })
|
|
|
+ nextTick(measureActive)
|
|
|
+})
|
|
|
+watch(pane, () => revealImages())
|
|
|
+watch(() => visibleLibModels.value.length, () => nextTick(measureLib))
|
|
|
+watch(() => visibleMineModels.value.length, () => nextTick(measureMine))
|
|
|
+watch(hasMine, () => nextTick(measureMine))
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ revealImages()
|
|
|
+ fetchModelList()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.model-card {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ flex: 1 1 0;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.tabs-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ gap: 12px;
|
|
|
+ border-bottom: 1.5px solid #e7ebf4;
|
|
|
+ flex: none;
|
|
|
+}
|
|
|
+.tabs {
|
|
|
+ display: flex;
|
|
|
+ gap: 22px;
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.tab {
|
|
|
+ background: none;
|
|
|
+ border: 0;
|
|
|
+ padding: 2px 1px 10px;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #46516a;
|
|
|
+ position: relative;
|
|
|
+ white-space: nowrap;
|
|
|
+ cursor: pointer;
|
|
|
+ outline: none;
|
|
|
+ &:focus,
|
|
|
+ &:focus-visible { outline: none; }
|
|
|
+ &.on {
|
|
|
+ color: #3d63f2;
|
|
|
+ &::after {
|
|
|
+ content: "";
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: -1.5px;
|
|
|
+ height: 3px;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: linear-gradient(100deg, #3e7bfa, #7a5af8);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.lib-pane,
|
|
|
+.up-pane {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ flex: 1 1 0;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.chips-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ flex: none;
|
|
|
+}
|
|
|
+.chips {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.chip {
|
|
|
+ appearance: none;
|
|
|
+ padding: 5px 14px;
|
|
|
+ border-radius: 999px;
|
|
|
+ border: 1.5px solid #d9dfec;
|
|
|
+ background: #fff;
|
|
|
+ font-size: 13.5px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #46516a;
|
|
|
+ cursor: pointer;
|
|
|
+ outline: none;
|
|
|
+ box-sizing: border-box;
|
|
|
+ &:hover {
|
|
|
+ border-color: #3d63f2;
|
|
|
+ color: #3d63f2;
|
|
|
+ }
|
|
|
+ &:focus,
|
|
|
+ &:focus-visible {
|
|
|
+ outline: none;
|
|
|
+ }
|
|
|
+ &.on,
|
|
|
+ &.on:hover,
|
|
|
+ &.on:focus,
|
|
|
+ &.on:active {
|
|
|
+ background: #3d63f2;
|
|
|
+ border-color: #3d63f2;
|
|
|
+ color: #fff;
|
|
|
+ box-shadow: 0 4px 12px rgba(61, 99, 242, 0.28);
|
|
|
+ }
|
|
|
+}
|
|
|
+.hbtns { display: flex; gap: 6px; flex: none; }
|
|
|
+.hbtn {
|
|
|
+ width: 27px;
|
|
|
+ height: 27px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 1.5px solid #d9dfec;
|
|
|
+ background: #fff;
|
|
|
+ color: #46516a;
|
|
|
+ cursor: pointer;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ padding: 0;
|
|
|
+ svg { width: 13px; height: 13px; display: block; }
|
|
|
+ &:hover {
|
|
|
+ border-color: #3d63f2;
|
|
|
+ color: #3d63f2;
|
|
|
+ background: #f5f8ff;
|
|
|
+ }
|
|
|
+}
|
|
|
+.hscroll {
|
|
|
+ --rows: 2;
|
|
|
+ --cols: 2;
|
|
|
+ --row-h: 140px;
|
|
|
+ display: grid;
|
|
|
+ grid-template-rows: repeat(var(--rows), minmax(0, 1fr));
|
|
|
+ gap: 11px;
|
|
|
+ overflow-x: auto;
|
|
|
+ overflow-y: hidden;
|
|
|
+ overscroll-behavior-x: contain;
|
|
|
+ scroll-snap-type: x mandatory;
|
|
|
+ padding-bottom: 2px;
|
|
|
+ scrollbar-width: none;
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-height: 0;
|
|
|
+ &::-webkit-scrollbar { display: none; }
|
|
|
+ > * { scroll-snap-align: start; min-width: 0; min-height: 0; }
|
|
|
+
|
|
|
+ &[data-flow="row"] {
|
|
|
+ align-content: start;
|
|
|
+ grid-auto-flow: row;
|
|
|
+ grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
|
|
|
+ grid-template-rows: repeat(var(--rows), var(--row-h));
|
|
|
+ }
|
|
|
+ &[data-flow="column"],
|
|
|
+ &:not([data-flow]) {
|
|
|
+ align-content: start;
|
|
|
+ grid-auto-flow: column;
|
|
|
+ grid-auto-columns: calc(100% / var(--cols) - 20px);
|
|
|
+ grid-template-rows: repeat(var(--rows), var(--row-h));
|
|
|
+ }
|
|
|
+}
|
|
|
+.model {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ border-radius: 11px;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 2px solid transparent;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #edf2fa;
|
|
|
+ transition: all 0.15s;
|
|
|
+ .model-img {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ :deep(.el-image__inner),
|
|
|
+ :deep(.el-image__placeholder),
|
|
|
+ :deep(.el-image__error),
|
|
|
+ :deep(.model-ph) {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .ck {
|
|
|
+ position: absolute;
|
|
|
+ top: 6px;
|
|
|
+ right: 6px;
|
|
|
+ width: 21px;
|
|
|
+ height: 21px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #3d63f2;
|
|
|
+ color: #fff;
|
|
|
+ font-size: 12px;
|
|
|
+ display: none;
|
|
|
+ place-items: center;
|
|
|
+ box-shadow: 0 2px 6px rgba(23, 43, 99, 0.35);
|
|
|
+ z-index: 2;
|
|
|
+ }
|
|
|
+ &:hover {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 8px 18px rgba(23, 43, 99, 0.14);
|
|
|
+ }
|
|
|
+ &.sel {
|
|
|
+ border-color: #3d63f2;
|
|
|
+ box-shadow: 0 6px 16px rgba(61, 99, 242, 0.24);
|
|
|
+ }
|
|
|
+ &.sel .ck { display: grid; }
|
|
|
+ .del {
|
|
|
+ position: absolute;
|
|
|
+ right: 6px;
|
|
|
+ bottom: 6px;
|
|
|
+ z-index: 3;
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(26, 34, 51, 0.62);
|
|
|
+ color: #fff;
|
|
|
+ font-size: 16px;
|
|
|
+ line-height: 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ padding: 0;
|
|
|
+ opacity: 0;
|
|
|
+ &:hover { background: #f5303d; }
|
|
|
+ &:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
|
+ }
|
|
|
+ &:hover .del { opacity: 1; }
|
|
|
+}
|
|
|
+.hscroll > .model:only-child {
|
|
|
+ height: auto;
|
|
|
+ aspect-ratio: 84 / 108;
|
|
|
+ align-self: start;
|
|
|
+}
|
|
|
+.model-ph {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 100%;
|
|
|
+ background: linear-gradient(90deg, #e7ebf4 0%, #f5f7fb 45%, #e7ebf4 100%);
|
|
|
+ background-size: 200% 100%;
|
|
|
+ animation: model-ph 1.2s ease infinite;
|
|
|
+}
|
|
|
+.model > .model-ph {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+}
|
|
|
+@keyframes model-ph {
|
|
|
+ 0% { background-position: 100% 0; }
|
|
|
+ 100% { background-position: -100% 0; }
|
|
|
+}
|
|
|
+.g-tag {
|
|
|
+ position: absolute;
|
|
|
+ left: 6px;
|
|
|
+ top: 6px;
|
|
|
+ z-index: 2;
|
|
|
+ padding: 1px 6px;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: rgba(26, 34, 51, 0.55);
|
|
|
+ color: #fff;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 16px;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ grid-column: 1 / -1;
|
|
|
+ color: #8b95ab;
|
|
|
+ font-size: 13px;
|
|
|
+ padding: 24px 0;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.up {
|
|
|
+ border: 1.8px dashed #d9dfec;
|
|
|
+ border-radius: 13px;
|
|
|
+ background: #fafbfe;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 14px 12px;
|
|
|
+ text-align: center;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.15s;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ &:hover {
|
|
|
+ border-color: #3d63f2;
|
|
|
+ background: #f5f8ff;
|
|
|
+ }
|
|
|
+ .uic {
|
|
|
+ width: 40px;
|
|
|
+ height: 40px;
|
|
|
+ border-radius: 11px;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ background: linear-gradient(135deg, #eef2ff, #e3e9ff);
|
|
|
+ color: #3d63f2;
|
|
|
+ flex: none;
|
|
|
+ svg { width: 21px; height: 21px; display: block; }
|
|
|
+ }
|
|
|
+ .up-copy {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 2px;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+ .t { font-size: 14px; font-weight: 700; color: #46516a; }
|
|
|
+ .s { font-size: 12px; color: #8b95ab; }
|
|
|
+ &.fill {
|
|
|
+ flex: 1 1 0;
|
|
|
+ min-height: 0;
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+ &.bar {
|
|
|
+ flex: none;
|
|
|
+ min-height: 68px;
|
|
|
+ padding: 12px 14px;
|
|
|
+ flex-direction: row;
|
|
|
+ justify-content: flex-start;
|
|
|
+ text-align: left;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.up-form {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ &.fill {
|
|
|
+ flex: 1 1 0;
|
|
|
+ flex-direction: column;
|
|
|
+ .up-preview { flex: 1 1 0; min-height: 0; }
|
|
|
+ .up-form-side { flex: none; }
|
|
|
+ }
|
|
|
+ &.bar {
|
|
|
+ flex: none;
|
|
|
+ align-items: stretch;
|
|
|
+ .up-preview {
|
|
|
+ width: 92px;
|
|
|
+ height: 118px;
|
|
|
+ flex: none;
|
|
|
+ }
|
|
|
+ .up-form-side { flex: 1; min-width: 0; }
|
|
|
+ }
|
|
|
+}
|
|
|
+.up-preview {
|
|
|
+ border-radius: 11px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #edf2fa;
|
|
|
+ border: 1.5px solid #e7ebf4;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.up-preview-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.up-form-side {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.up-label {
|
|
|
+ font-size: 12.5px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #46516a;
|
|
|
+ text-align: left;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+.up-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.gbtn {
|
|
|
+ flex: 1;
|
|
|
+ height: 36px;
|
|
|
+ border-radius: 11px;
|
|
|
+ font-size: 13.5px;
|
|
|
+ font-weight: 800;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ &:disabled {
|
|
|
+ opacity: 0.55;
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+ &.ghost {
|
|
|
+ border: 1.5px solid #3d63f2;
|
|
|
+ color: #3d63f2;
|
|
|
+ background: #fff;
|
|
|
+ &:hover:not(:disabled) { background: #f3f6ff; }
|
|
|
+ }
|
|
|
+ &.solid {
|
|
|
+ border: 0;
|
|
|
+ color: #fff;
|
|
|
+ background: linear-gradient(100deg, #3e7bfa, #7a5af8);
|
|
|
+ &:hover:not(:disabled) { filter: brightness(1.05); }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|