|
- <template>
- <div class="min-h-screen overflow-x-hidden bg-[var(--c-f3f7fb)] pb-24 lg:pb-12">
- <AppHero
- kicker="SMART ID PHOTO"
- title="一张自拍,制作标准证件照"
- desc="上传一张清晰正面照,自动换好底色、裁成所需尺寸,几秒获得可直接下载使用的高清证件照。"
- >
- <template #actions>
- <button
- type="button"
- class="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-colors hover:bg-[var(--c-087de5)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 lg:h-11"
- @click="triggerUpload"
- >
- <UIcon name="i-lucide-upload" class="size-4" />
- 上传照片
- </button>
- <button
- type="button"
- class="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-52647b)] shadow-sm transition-colors hover:bg-[var(--c-f8fbff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] lg:h-11"
- @click="openHistory"
- >
- <UIcon name="i-lucide-history" class="size-4" />
- 历史记录
- </button>
- </template>
- <template #aside>
- <div class="hidden w-[390px] overflow-hidden rounded-[28px] border border-white/80 bg-[var(--c-ffffff)]/88 p-5 shadow-[0_20px_48px_var(--sh-31-62-112-130)] backdrop-blur lg:block">
- <div class="flex items-center gap-5">
- <div class="relative h-[145px] w-[116px] shrink-0">
- <div class="absolute left-3 top-1 h-[136px] w-[102px] rotate-6 rounded-[20px] border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/75 shadow-sm" />
- <div class="absolute inset-y-0 left-0 w-[104px] overflow-hidden rounded-[20px] border-[5px] border-[var(--c-e8eef7)] bg-[var(--c-438edb)] shadow-[0_14px_28px_var(--sh-44-104-168-240)]">
- <img
- src="https://cdn.aionline.cc/ai/anime/id-photo-demo.jpg"
- alt="蓝底标准证件照效果示例"
- width="295"
- height="413"
- class="h-full w-full object-cover"
- loading="eager"
- decoding="async"
- fetchpriority="high"
- >
- <span class="absolute left-2 top-2 rounded-full bg-[var(--c-ffffff)]/90 px-2 py-1 text-[9px] font-extrabold text-[var(--c-0b8cff)]">一寸</span>
- </div>
- <span class="absolute bottom-0 right-0 flex size-8 items-center justify-center rounded-full border-4 border-[var(--c-e8eef7)] bg-[var(--c-19ad72)] text-white shadow-md">
- <UIcon name="i-lucide-check" class="size-4" />
- </span>
- </div>
- <div class="min-w-0 flex-1">
- <span class="inline-flex rounded-full bg-[var(--c-fff2e8)] px-2.5 py-1 text-[10px] font-extrabold tracking-wide text-[var(--c-f06b1d)]">上传 1 张,直接得成品</span>
- <h2 class="mt-2 text-lg font-extrabold leading-7 text-[var(--c-172033)]">换底、裁剪,一次完成</h2>
- <ul class="mt-3 space-y-2 text-xs font-bold text-[var(--c-5f7087)]">
- <li v-for="item in heroBenefits" :key="item" class="flex items-center gap-2">
- <UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0 text-[var(--c-18a66a)]" />
- {{ item }}
- </li>
- </ul>
- </div>
- </div>
- </div>
- </template>
- </AppHero>
-
- <main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10">
- <div class="grid items-start gap-6 xl:grid-cols-[minmax(0,1.18fr)_minmax(380px,0.82fr)]">
- <div class="min-w-0 space-y-5">
- <section class="rounded-3xl border border-[var(--c-e9eff7)] bg-[var(--c-ffffff)] p-4 shadow-[0_16px_42px_var(--sh-35-64-105-80)] sm:p-6">
- <div class="mb-5 flex flex-wrap items-center justify-between gap-3">
- <div>
- <h2 class="text-lg font-extrabold text-[var(--c-172033)]">照片预览</h2>
- <p class="mt-1 text-xs text-[var(--c-8794a6)]">{{ workspaceHint }}</p>
- </div>
- <div v-if="localFile" class="flex items-center gap-2">
- <button
- type="button"
- class="inline-flex h-9 items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-4 text-xs font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-dfefff)]"
- @click="triggerUpload"
- >
- <UIcon name="i-lucide-refresh-cw" class="size-3.5" />
- 更换照片
- </button>
- <button
- v-if="status !== 'processing'"
- type="button"
- aria-label="移除照片"
- class="flex size-9 items-center justify-center rounded-full bg-[var(--c-f7f9fc)] text-[var(--c-8190a4)] transition-colors hover:bg-[var(--c-fff0ef)] hover:text-[var(--c-e5484d)]"
- @click="clearPhoto"
- >
- <UIcon name="i-lucide-trash-2" class="size-4" />
- </button>
- </div>
- </div>
-
- <input
- ref="fileInput"
- class="sr-only"
- type="file"
- accept="image/jpeg,image/png,image/webp"
- @change="onFileChange"
- >
-
- <div
- v-if="!localFile"
- class="group flex min-h-[420px] cursor-pointer flex-col items-center justify-center rounded-[28px] border-2 border-dashed px-6 text-center transition-all sm:min-h-[520px]"
- :class="dragActive
- ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
- : 'border-[var(--c-d9e3ef)] bg-[var(--c-f8fbfe)] hover:border-[var(--c-0b8cff)]/60 hover:bg-[var(--c-f3f9ff)]'"
- role="button"
- tabindex="0"
- aria-label="选择证件照原图"
- @click="triggerUpload"
- @keydown.enter.prevent="triggerUpload"
- @keydown.space.prevent="triggerUpload"
- @dragenter.prevent="dragActive = true"
- @dragover.prevent="dragActive = true"
- @dragleave.prevent="dragActive = false"
- @drop.prevent="onDrop"
- >
- <span class="flex size-20 items-center justify-center rounded-3xl bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-[0_12px_30px_var(--sh-25-101-170-120)] transition-transform group-hover:-translate-y-1">
- <UIcon name="i-lucide-image-up" class="size-9" />
- </span>
- <h3 class="mt-6 text-lg font-extrabold text-[var(--c-172033)]">点击或拖入一张照片</h3>
- <p class="mt-2 max-w-md text-sm leading-6 text-[var(--c-718096)]">
- 支持 JPG、PNG、WebP,文件不超过 10MB。推荐竖版半身照,避免遮挡面部。
- </p>
- <span class="mt-6 inline-flex h-11 items-center rounded-full bg-[var(--c-0b8cff)] px-6 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)]">
- 选择照片
- </span>
- <div class="mt-8 grid w-full max-w-lg grid-cols-3 gap-3">
- <div v-for="tip in uploadTips" :key="tip.label" class="rounded-2xl bg-[var(--c-ffffff)] px-2 py-3">
- <UIcon :name="tip.icon" class="mx-auto size-4 text-[var(--c-18a66a)]" />
- <div class="mt-1.5 text-[11px] font-bold text-[var(--c-607087)]">{{ tip.label }}</div>
- </div>
- </div>
- </div>
-
- <div
- v-else
- class="relative flex min-h-[420px] items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,var(--c-edf2f8)_25%,var(--c-f8fafc)_25%,var(--c-f8fafc)_50%,var(--c-edf2f8)_50%,var(--c-edf2f8)_75%,var(--c-f8fafc)_75%)] bg-[length:24px_24px] sm:min-h-[520px]"
- :class="result ? 'overflow-visible' : 'overflow-hidden'"
- @dragenter.prevent="dragActive = true"
- @dragover.prevent="dragActive = true"
- @dragleave.prevent="dragActive = false"
- @drop.prevent="onDrop"
- >
- <img
- :src="displayImage"
- class="max-h-[520px] max-w-full object-contain"
- :class="result ? 'rounded-[4px]' : ''"
- :style="result ? resultImageStyle : undefined"
- :alt="result ? '制作完成的证件照' : '待处理照片预览'"
- @error="handleImageError"
- >
-
- <div v-if="result" class="absolute left-4 top-4 rounded-full bg-[var(--c-ink)]/70 px-3 py-1.5 text-xs font-bold text-white backdrop-blur">
- {{ result.spec_name }} · {{ result.width }}×{{ result.height }}px
- </div>
-
- <div v-if="status === 'processing'" class="absolute inset-0 flex items-center justify-center bg-[var(--c-132238)]/70 p-5 backdrop-blur-sm">
- <div class="w-full max-w-md rounded-3xl bg-[var(--c-ffffff)] p-6 shadow-2xl sm:p-8" aria-live="polite">
- <span class="mx-auto flex size-16 items-center justify-center rounded-3xl bg-[var(--c-eef7ff)]">
- <UIcon name="i-lucide-scan-face" class="size-8 animate-pulse text-[var(--c-0b8cff)]" />
- </span>
- <div class="mt-5 flex items-end justify-between gap-4">
- <div class="min-w-0 text-left">
- <h3 class="text-lg font-extrabold text-[var(--c-172033)]">正在制作证件照</h3>
- <p class="mt-1 text-sm text-[var(--c-7c899b)]">{{ processingStages[processingStage] }}</p>
- </div>
- <span class="shrink-0 text-2xl font-extrabold tabular-nums text-[var(--c-0b8cff)]">{{ processingProgress }}%</span>
- </div>
- <div
- class="mt-5 h-2.5 overflow-hidden rounded-full bg-[var(--c-eaf0f7)]"
- role="progressbar"
- aria-label="证件照制作进度"
- aria-valuemin="0"
- aria-valuemax="100"
- :aria-valuenow="processingProgress"
- >
- <div
- class="h-full rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-46c2ff)] transition-[width] duration-500"
- :style="{ width: `${processingProgress}%` }"
- />
- </div>
- <div class="mt-3 flex items-center justify-between gap-3 text-[11px]">
- <span class="font-bold text-[var(--c-7f8da0)]">整体进度</span>
- <span class="inline-flex items-center gap-1.5 font-bold text-[var(--c-46698d)]">
- <UIcon name="i-lucide-clock-3" class="size-3.5" />
- {{ processingRemainingText }}
- </span>
- </div>
- <div class="mt-5 grid grid-cols-4 gap-2">
- <span
- v-for="(_, index) in processingStages"
- :key="index"
- class="h-1.5 rounded-full transition-colors"
- :class="index <= processingStage ? 'bg-[var(--c-0b8cff)]' : 'bg-[var(--c-e4ebf4)]'"
- />
- </div>
- <p class="mt-5 text-center text-xs text-[var(--c-9aa6b5)]">请保持页面打开,完成后将自动展示成品</p>
- </div>
- </div>
- </div>
-
- <div v-if="photoMeta.width" class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-[var(--c-7f8c9f)]">
- <span class="inline-flex items-center gap-1.5">
- <UIcon name="i-lucide-maximize" class="size-3.5" />
- 原图 {{ photoMeta.width }}×{{ photoMeta.height }}px
- </span>
- <span class="inline-flex items-center gap-1.5">
- <UIcon name="i-lucide-file-image" class="size-3.5" />
- {{ fileSizeText }}
- </span>
- <span v-if="result" class="inline-flex items-center gap-1.5 text-[var(--c-18a66a)]">
- <UIcon name="i-lucide-circle-check" class="size-3.5" />
- 已生成 300 DPI 成品
- </span>
- </div>
-
- <div v-if="errorMessage" class="mt-4 flex items-start gap-2 rounded-2xl bg-[var(--c-fff2f1)] px-4 py-3 text-sm text-[var(--c-c63d42)]">
- <UIcon name="i-lucide-circle-alert" class="mt-0.5 size-4 shrink-0" />
- <span>{{ errorMessage }}</span>
- </div>
-
- <div v-if="result" class="mt-5 grid gap-3 sm:grid-cols-2">
- <button
- type="button"
- class="inline-flex h-12 items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] text-sm font-extrabold text-white transition-colors hover:bg-[var(--c-087de5)]"
- @click="downloadResult"
- >
- <UIcon name="i-lucide-download" class="size-4" />
- 下载证件照
- </button>
- <button
- type="button"
- class="inline-flex h-12 items-center justify-center gap-2 rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-dfefff)]"
- @click="startOver"
- >
- <UIcon name="i-lucide-plus" class="size-4" />
- 制作新照片
- </button>
- </div>
- </section>
-
- <section class="rounded-3xl border border-[var(--c-e9eff7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_34px_var(--sh-35-64-105-60)] sm:p-6">
- <div>
- <h2 class="text-base font-extrabold text-[var(--c-172033)]">背景与自然优化</h2>
- <p class="mt-1 text-xs text-[var(--c-8794a6)]">选择底色和画面效果,预览区会展示最终成品</p>
- </div>
-
- <div class="mt-5 grid gap-6 sm:grid-cols-[1.05fr_0.95fr]">
- <div>
- <div class="text-xs font-bold text-[var(--c-52647b)]">背景颜色</div>
- <div class="mt-3 grid grid-cols-3 gap-3">
- <button
- v-for="background in backgrounds"
- :key="background.key"
- type="button"
- class="group cursor-pointer rounded-2xl border p-2 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- :class="isBackgroundSelected(background.color)
- ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
- : 'border-[var(--c-e8eef6)] hover:border-[var(--c-bcdcff)]'"
- :aria-label="`选择${background.name}背景`"
- @click="setBackground(background.color)"
- >
- <span
- class="relative mx-auto flex size-8 items-center justify-center rounded-full border border-black/10 shadow-inner"
- :style="{ backgroundColor: background.color }"
- >
- <UIcon
- v-if="isBackgroundSelected(background.color)"
- name="i-lucide-check"
- class="size-4"
- :class="isLightBackground(background.color) ? 'text-[var(--c-0b8cff)]' : 'text-white'"
- />
- </span>
- <span class="mt-1.5 block truncate text-[10px] font-bold text-[var(--c-64748a)]">{{ background.name }}</span>
- </button>
-
- <UPopover
- :content="{ align: 'start', side: 'bottom', sideOffset: 10 }"
- :ui="{ content: 'rounded-2xl border border-[var(--c-dfe8f2)] bg-[var(--c-ffffff)] p-0 shadow-[0_18px_48px_var(--sh-31-62-112-160)]' }"
- >
- <button
- type="button"
- aria-label="选择自定义背景色"
- class="group cursor-pointer rounded-2xl border p-2 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- :class="isCustomBackground
- ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
- : 'border-[var(--c-e8eef6)] hover:border-[var(--c-bcdcff)]'"
- >
- <span
- class="relative mx-auto flex size-8 items-center justify-center rounded-full border border-black/10 shadow-inner"
- :style="{ backgroundColor: settings.background }"
- >
- <UIcon
- v-if="isCustomBackground"
- name="i-lucide-check"
- class="size-4"
- :class="isLightBackground(settings.background) ? 'text-[var(--c-0b8cff)]' : 'text-white'"
- />
- <span class="absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full border border-[var(--c-dfe8f2)] bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-sm">
- <UIcon name="i-lucide-palette" class="size-2.5" />
- </span>
- </span>
- <span class="mt-1.5 block truncate text-[10px] font-bold text-[var(--c-64748a)]">自定义</span>
- </button>
-
- <template #content>
- <div class="w-[220px] p-4">
- <div class="mb-3 flex items-center justify-between gap-3">
- <div>
- <div class="text-xs font-extrabold text-[var(--c-172033)]">自定义背景色</div>
- <div class="mt-0.5 text-[10px] text-[var(--c-8794a6)]">拖动选取,成品实时应用</div>
- </div>
- <span class="shrink-0 font-mono text-[11px] font-bold uppercase text-[var(--c-52647b)]">
- {{ settings.background }}
- </span>
- </div>
- <div role="group" aria-label="自定义证件照背景颜色">
- <UColorPicker
- :model-value="settings.background"
- format="hex"
- size="sm"
- :ui="{
- selector: 'shrink-0',
- track: 'w-2 shrink-0',
- trackThumb: '-translate-x-1 rtl:translate-x-1',
- }"
- @update:model-value="setBackground"
- />
- </div>
- </div>
- </template>
- </UPopover>
- </div>
- </div>
-
- <div>
- <div class="text-xs font-bold text-[var(--c-52647b)]">画面优化</div>
- <div class="mt-3 grid grid-cols-2 gap-3">
- <button
- v-for="option in beautyOptions"
- :key="option.key"
- type="button"
- class="rounded-2xl border p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- :class="settings.beauty === option.key
- ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
- : 'border-[var(--c-e8eef6)] hover:border-[var(--c-bcdcff)]'"
- @click="settings.beauty = option.key"
- >
- <span class="flex items-center gap-2 text-xs font-extrabold text-[var(--c-172033)]">
- <UIcon :name="option.key === 'natural' ? 'i-lucide-wand-sparkles' : 'i-lucide-scan-face'" class="size-4 text-[var(--c-0b8cff)]" />
- {{ option.name }}
- </span>
- <span class="mt-1.5 block text-[10px] leading-4 text-[var(--c-8794a6)]">{{ option.desc }}</span>
- </button>
- </div>
- </div>
- </div>
-
- <div v-if="result" class="mt-6 border-t border-[var(--c-edf1f6)] pt-5">
- <div class="flex items-center justify-between">
- <div class="text-xs font-bold text-[var(--c-52647b)]">构图微调</div>
- <button type="button" class="text-[11px] font-bold text-[var(--c-0b8cff)]" @click="resetAdjustments">恢复默认</button>
- </div>
- <div class="mt-4 grid gap-5 sm:grid-cols-3">
- <label class="block">
- <span class="flex justify-between text-[11px] text-[var(--c-7b899c)]">
- <span>人物大小</span><span>{{ Math.round(settings.scale * 100) }}%</span>
- </span>
- <input v-model.number="settings.scale" class="id-range mt-2 w-full" type="range" min="0.75" max="1.4" step="0.01">
- </label>
- <label class="block">
- <span class="flex justify-between text-[11px] text-[var(--c-7b899c)]">
- <span>水平位置</span><span>{{ adjustmentLabel(settings.offset_x) }}</span>
- </span>
- <input v-model.number="settings.offset_x" class="id-range mt-2 w-full" type="range" min="-1" max="1" step="0.02">
- </label>
- <label class="block">
- <span class="flex justify-between text-[11px] text-[var(--c-7b899c)]">
- <span>垂直位置</span><span>{{ adjustmentLabel(settings.offset_y) }}</span>
- </span>
- <input v-model.number="settings.offset_y" class="id-range mt-2 w-full" type="range" min="-1" max="1" step="0.02">
- </label>
- </div>
- <div class="mt-5 flex flex-col items-center justify-between gap-2 sm:flex-row">
- <p class="text-[10px] text-[var(--c-9ba7b7)]">调整已生成照片不重复扣点</p>
- <button
- type="button"
- class="flex h-11 w-full items-center justify-center gap-2 rounded-full px-6 text-sm font-extrabold transition-colors sm:w-auto"
- :class="adjustDirty && !rerendering
- ? 'bg-[var(--c-ink)] text-white hover:bg-[var(--c-26354b)]'
- : 'cursor-not-allowed bg-[var(--c-edf1f6)] text-[var(--c-a0aaba)]'"
- :disabled="!adjustDirty || rerendering"
- @click="applyAdjustments"
- >
- <span v-if="rerendering" class="size-4 animate-spin rounded-full border-2 border-white/35 border-t-white" />
- <UIcon v-else name="i-lucide-sliders-horizontal" class="size-4" />
- {{ rerendering ? '正在应用...' : '应用调整' }}
- </button>
- </div>
- </div>
- </section>
- </div>
-
- <aside class="min-w-0 space-y-5">
- <section class="rounded-3xl border border-[var(--c-e9eff7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_34px_var(--sh-35-64-105-60)] sm:p-6">
- <div class="flex items-center justify-between">
- <div>
- <h2 class="text-base font-extrabold text-[var(--c-172033)]">选择规格</h2>
- <p class="mt-1 text-xs text-[var(--c-8794a6)]">成品将严格输出为所选像素尺寸</p>
- </div>
- <span v-if="selectedSpec" class="rounded-full bg-[var(--c-eef7ff)] px-3 py-1 text-[11px] font-extrabold text-[var(--c-0b8cff)]">
- {{ selectedSpec.dpi }} DPI
- </span>
- </div>
-
- <div class="mt-5 flex gap-2 overflow-x-auto pb-1 no-scrollbar">
- <button
- v-for="category in categories"
- :key="category.key"
- type="button"
- class="h-9 shrink-0 rounded-full px-4 text-xs font-bold transition-colors"
- :class="selectedCategory === category.key
- ? 'bg-[var(--c-ink)] text-white'
- : 'bg-[var(--c-f3f6fa)] text-[var(--c-64748a)] hover:bg-[var(--c-eaf0f7)]'"
- @click="selectedCategory = category.key"
- >
- {{ category.name }}
- </button>
- </div>
-
- <label class="mt-4 flex h-10 items-center gap-2 rounded-xl bg-[var(--c-f5f8fc)] px-3 text-[var(--c-8996a8)]">
- <UIcon name="i-lucide-search" class="size-4" />
- <input
- v-model.trim="specKeyword"
- class="min-w-0 flex-1 bg-transparent text-sm text-[var(--c-172033)] outline-none placeholder:text-[var(--c-a8b2c0)]"
- placeholder="搜索尺寸或用途"
- >
- </label>
-
- <div v-if="configLoading" class="mt-4 grid grid-cols-2 gap-3">
- <div v-for="index in 4" :key="index" class="h-[86px] animate-pulse rounded-2xl bg-[var(--c-eef2f7)]" />
- </div>
- <div v-else class="mt-4 grid grid-cols-2 gap-3">
- <button
- v-for="spec in visibleSpecs"
- :key="spec.key"
- type="button"
- class="relative min-w-0 rounded-2xl border p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- :class="selectedSpecKey === spec.key
- ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)] shadow-[0_7px_18px_var(--sh-11-140-255-100)]'
- : 'border-[var(--c-e8eef6)] bg-[var(--c-ffffff)] hover:border-[var(--c-bcdcff)]'"
- @click="selectedSpecKey = spec.key"
- >
- <span v-if="spec.hot" class="absolute right-2.5 top-2.5 rounded-full bg-[var(--c-fff1e8)] px-1.5 py-0.5 text-[9px] font-extrabold text-[var(--c-f36b21)]">常用</span>
- <span class="block truncate pr-8 text-sm font-extrabold text-[var(--c-172033)]">{{ spec.name }}</span>
- <span class="mt-1.5 block text-[11px] text-[var(--c-78869a)]">{{ spec.width }}×{{ spec.height }}px</span>
- <span class="mt-0.5 block truncate text-[10px] text-[var(--c-a0aaba)]">{{ spec.print }}</span>
- </button>
- </div>
- <p v-if="!configLoading && !visibleSpecs.length" class="py-8 text-center text-sm text-[var(--c-9aa5b4)]">没有匹配的规格</p>
- </section>
-
- <section class="hidden rounded-3xl border border-[var(--c-ddecfb)] bg-[var(--c-f8fbff)] p-5 lg:block">
- <div class="flex items-center justify-between gap-4">
- <div class="min-w-0">
- <div
- class="text-xs"
- :class="isFreeUsage
- ? 'font-bold text-[var(--c-18a66a)]'
- : isBalanceInsufficient
- ? 'font-bold text-[var(--c-e5484d)]'
- : 'text-[var(--c-77879b)]'"
- >
- {{ billingText }}
- </div>
- <div class="mt-1 text-base font-extrabold text-[var(--c-172033)]">{{ selectedSpec?.name || '请选择规格' }}</div>
- </div>
- <button
- type="button"
- class="inline-flex h-12 min-w-[180px] shrink-0 items-center justify-center gap-2 rounded-full text-sm font-extrabold text-white shadow-[0_12px_24px_var(--sh-11-140-255-240)] transition-all"
- :class="canCreate && status !== 'processing'
- ? 'bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-39b6ff)] hover:-translate-y-0.5'
- : 'cursor-not-allowed bg-[var(--c-9cc8eb)]'"
- :disabled="!canCreate || status === 'processing'"
- @click="createPhoto"
- >
- <UIcon name="i-lucide-zap" class="size-4" />
- {{ createButtonText }}
- </button>
- </div>
- </section>
- </aside>
- </div>
- </main>
-
- <div class="fixed inset-x-0 bottom-0 z-50 border-t border-[var(--c-e6edf6)] bg-[var(--c-ffffff)]/95 px-4 py-3 shadow-[0_-10px_30px_var(--sh-32-56-92-100)] backdrop-blur lg:hidden" style="padding-bottom: calc(12px + env(safe-area-inset-bottom))">
- <div class="mx-auto flex max-w-2xl items-center gap-3">
- <button
- type="button"
- class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]"
- aria-label="查看历史记录"
- @click="openHistory"
- >
- <UIcon name="i-lucide-history" class="size-5" />
- </button>
- <button
- type="button"
- class="flex h-12 min-w-0 flex-1 items-center justify-center gap-2 rounded-full text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)]"
- :class="canCreate && status !== 'processing'
- ? 'bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-39b6ff)]'
- : 'bg-[var(--c-9cc8eb)]'"
- :disabled="!canCreate || status === 'processing'"
- @click="createPhoto"
- >
- <span v-if="status === 'processing'" class="size-4 animate-spin rounded-full border-2 border-white/35 border-t-white" />
- <UIcon v-else name="i-lucide-zap" class="size-4" />
- {{ createButtonText }}
- </button>
- </div>
- </div>
-
- <Teleport to="body">
- <Transition name="id-dialog">
- <div
- v-if="insufficientOpen"
- class="fixed inset-0 z-[120] flex items-center justify-center bg-[var(--c-0d1b2f)]/55 p-4 backdrop-blur-[2px]"
- role="dialog"
- aria-modal="true"
- aria-labelledby="insufficient-points-title"
- @click.self="insufficientOpen = false"
- >
- <div class="w-full max-w-[420px] rounded-[28px] bg-[var(--c-ffffff)] p-6 shadow-[0_28px_80px_var(--sh-8-27-54-280)] sm:p-7">
- <div class="flex items-start justify-between gap-4">
- <span class="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--c-fff2e8)] text-[var(--c-f06b1d)]">
- <UIcon name="i-lucide-coins" class="size-6" />
- </span>
- <button
- type="button"
- class="flex size-9 items-center justify-center rounded-full text-[var(--c-8492a6)] transition-colors hover:bg-[var(--c-f3f6fa)] hover:text-[var(--c-172033)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- aria-label="关闭点数不足提示"
- @click="insufficientOpen = false"
- >
- <UIcon name="i-lucide-x" class="size-5" />
- </button>
- </div>
- <h2 id="insufficient-points-title" class="mt-5 text-xl font-extrabold text-[var(--c-172033)]">点数不足</h2>
- <p class="mt-2 text-sm leading-6 text-[var(--c-6f7f93)]">本次制作需要 {{ quotePoints }} 点,充值后即可继续制作当前照片。</p>
- <div class="mt-5 grid grid-cols-2 divide-x divide-[var(--c-e7edf5)] rounded-2xl bg-[var(--c-f6f9fc)] px-3 py-4 text-center">
- <div>
- <div class="text-[11px] text-[var(--c-8a97a8)]">当前余额</div>
- <div class="mt-1 text-lg font-extrabold text-[var(--c-172033)]">{{ balancePoints }} 点</div>
- </div>
- <div>
- <div class="text-[11px] text-[var(--c-8a97a8)]">还差</div>
- <div class="mt-1 text-lg font-extrabold text-[var(--c-f06b1d)]">{{ missingPoints }} 点</div>
- </div>
- </div>
- <div class="mt-6 grid grid-cols-2 gap-3">
- <button
- type="button"
- class="h-11 rounded-full border border-[var(--c-dfe6ef)] text-sm font-extrabold text-[var(--c-64748a)] transition-colors hover:bg-[var(--c-f7f9fc)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- @click="insufficientOpen = false"
- >
- 暂不充值
- </button>
- <button
- type="button"
- class="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-087de5)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2"
- @click="goRecharge"
- >
- <UIcon name="i-lucide-wallet-cards" class="size-4" />
- 去充值
- </button>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
-
- <WorkHistoryDrawer v-model="historyOpen" type="photo" @select="openHistoryItem" />
- <WorkDetailModal v-model="detailOpen" :work="currentWork" />
- </div>
- </template>
-
- <script setup>
- const api = useApi()
- const { create, createRequestId } = useIdPhoto()
- const user = useUser()
- const toast = useToast()
- const route = useRoute()
- const router = useRouter()
-
- useHead({
- title: '智能证件照 - 奇想宇宙',
- meta: [
- {
- name: 'description',
- content: '在线制作标准证件照,支持自动抠图、背景替换、尺寸裁剪与自然优化。',
- },
- ],
- })
-
- const FALLBACK_BACKGROUNDS = [
- { key: 'blue', name: '常用蓝', color: '#438edb' },
- { key: 'education-blue', name: '学历浅蓝', color: '#64c5ff' },
- { key: 'white', name: '白色', color: '#ffffff' },
- { key: 'red', name: '常用红', color: '#9d1016' },
- { key: 'gray', name: '浅灰', color: '#f0f0f0' },
- ]
-
- const FALLBACK_BEAUTY = [
- { key: 'none', name: '关闭美化', desc: '保留原始肤色与细节' },
- { key: 'natural', name: '自然优化', desc: '轻微提亮、降噪和锐化' },
- ]
-
- const heroBenefits = ['常用证件照尺寸可选', '蓝、白、红等底色自由切换', '300 DPI 高清成品直接下载']
-
- const uploadTips = [
- { label: '正面平视', icon: 'i-lucide-scan-face' },
- { label: '光线均匀', icon: 'i-lucide-sun' },
- { label: '单人半身', icon: 'i-lucide-user-round' },
- ]
-
- const processingStages = [
- '正在上传并检查照片',
- '正在识别人像并去除背景',
- '正在按证件照标准裁剪',
- '正在生成高清成品',
- ]
- const PROCESSING_ESTIMATE_SECONDS = 12
-
- const fileInput = ref(null)
- const localFile = ref(null)
- const localPreview = ref('')
- const photoMeta = reactive({ width: 0, height: 0 })
- const dragActive = ref(false)
- const configLoading = ref(true)
- const categories = ref([])
- const specs = ref([])
- const backgrounds = ref(FALLBACK_BACKGROUNDS)
- const beautyOptions = ref(FALLBACK_BEAUTY)
- const billing = ref(null)
- const selectedCategory = ref('common')
- const selectedSpecKey = ref('one-inch')
- const specKeyword = ref('')
- const status = ref('idle')
- const errorMessage = ref('')
- const result = ref(null)
- const processingStage = ref(0)
- const processingProgress = ref(8)
- const processingElapsedSeconds = ref(0)
- const rerendering = ref(false)
- const adjustDirty = ref(false)
- const historyOpen = ref(false)
- const detailOpen = ref(false)
- const insufficientOpen = ref(false)
- const currentWork = ref({})
- let processingTimer = null
- let processingStartedAt = 0
- let objectUrl = ''
-
- const settings = reactive({
- background: '#438edb',
- beauty: 'natural',
- scale: 1,
- offset_x: 0,
- offset_y: 0,
- })
-
- function normalizeHexColor(value) {
- const color = String(value || '').trim().toLowerCase()
- return /^#[0-9a-f]{6}$/.test(color) ? color : ''
- }
-
- function setBackground(value) {
- const color = normalizeHexColor(value)
- if (color) settings.background = color
- }
-
- function isBackgroundSelected(value) {
- return normalizeHexColor(value) === normalizeHexColor(settings.background)
- }
-
- function isLightBackground(value) {
- const color = normalizeHexColor(value)
- if (!color) return false
- const red = Number.parseInt(color.slice(1, 3), 16)
- const green = Number.parseInt(color.slice(3, 5), 16)
- const blue = Number.parseInt(color.slice(5, 7), 16)
- return (red * 299 + green * 587 + blue * 114) / 1000 >= 170
- }
-
- const isCustomBackground = computed(
- () => !backgrounds.value.some(background => isBackgroundSelected(background.color)),
- )
-
- const selectedSpec = computed(() => specs.value.find(item => item.key === selectedSpecKey.value))
- const visibleSpecs = computed(() => {
- const keyword = specKeyword.value.toLowerCase()
- return specs.value.filter((item) => {
- const matchesCategory = keyword || item.category === selectedCategory.value
- const matchesKeyword = !keyword || `${item.name} ${item.print} ${item.width} ${item.height}`.toLowerCase().includes(keyword)
- return matchesCategory && matchesKeyword
- })
- })
- const displayImage = computed(() => result.value?.result_url || localPreview.value)
- const resultImageStyle = computed(() => {
- const width = Number(result.value?.width || 0)
- const height = Number(result.value?.height || 0)
- return width && height ? { aspectRatio: `${width} / ${height}` } : {}
- })
- const fileSizeText = computed(() => {
- const bytes = Number(localFile.value?.size || 0)
- if (!bytes) return ''
- return bytes >= 1024 * 1024
- ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
- : `${Math.ceil(bytes / 1024)} KB`
- })
- const canCreate = computed(() => Boolean(localFile.value && selectedSpec.value))
- const quotePoints = computed(() => Number(billing.value?.quote?.points || 0))
- const balancePoints = computed(() => Number(billing.value?.balance?.points ?? user.userInfo.value?.balance ?? 0))
- const isFreeUsage = computed(() => (
- !configLoading.value
- && Boolean(billing.value?.quote)
- && quotePoints.value === 0
- ))
- const isBalanceInsufficient = computed(() => (
- user.isLogin.value
- && quotePoints.value > 0
- && balancePoints.value >= 0
- && balancePoints.value < quotePoints.value
- ))
- const missingPoints = computed(() => Math.max(0, quotePoints.value - Math.max(0, balancePoints.value)))
- const billingText = computed(() => {
- if (configLoading.value) return '计费信息加载中'
- if (isFreeUsage.value) return '免费使用'
- if (!user.isLogin.value) return quotePoints.value ? `登录后制作 · ${quotePoints.value}点/张` : '登录后制作'
- if (balancePoints.value < 0) return quotePoints.value ? `不限点数 · 本次预计 ${quotePoints.value} 点` : '不限点数'
- if (isBalanceInsufficient.value) return `余额 ${balancePoints.value} 点,还差 ${missingPoints.value} 点`
- if (!quotePoints.value) return `当前余额 ${balancePoints.value} 点`
- return `余额 ${balancePoints.value} 点 · 本次预计 ${quotePoints.value} 点`
- })
- const createButtonText = computed(() => {
- if (status.value === 'processing') return '正在制作...'
- if (result.value) return '重新制作'
- if (isFreeUsage.value) return user.isLogin.value ? '免费制作' : '登录后免费制作'
- if (!user.isLogin.value) return quotePoints.value ? `登录后制作 · ${quotePoints.value}点` : '登录后制作'
- if (isBalanceInsufficient.value) return '点数不足 · 去充值'
- return quotePoints.value ? `立即制作 · ${quotePoints.value}点` : '立即制作'
- })
- const workspaceHint = computed(() => {
- if (status.value === 'processing') return '图片处理中,请稍候'
- if (result.value) return '制作完成,可下载或继续微调'
- if (localFile.value) return '确认右侧规格后开始制作'
- return '上传清晰正面照片以获得更稳定的效果'
- })
- const processingRemainingText = computed(() => {
- const remaining = Math.max(0, Math.ceil(PROCESSING_ESTIMATE_SECONDS - processingElapsedSeconds.value))
- return remaining > 0 ? `预计还需约 ${remaining} 秒` : '即将完成,请稍候'
- })
-
- function showToast(message, color = 'error') {
- toast.add({ title: message, color })
- }
-
- function triggerUpload() {
- fileInput.value?.click()
- }
-
- function onFileChange(event) {
- const file = event.target.files?.[0]
- if (file) selectFile(file)
- event.target.value = ''
- }
-
- function onDrop(event) {
- dragActive.value = false
- const file = event.dataTransfer?.files?.[0]
- if (file) selectFile(file)
- }
-
- async function selectFile(file) {
- errorMessage.value = ''
- const types = ['image/jpeg', 'image/png', 'image/webp']
- if (!types.includes(file.type)) {
- showToast('仅支持 JPG、PNG、WebP 图片')
- return
- }
- if (file.size > 10 * 1024 * 1024) {
- showToast('图片大小不能超过 10MB')
- return
- }
-
- revokeObjectUrl()
- objectUrl = URL.createObjectURL(file)
- localFile.value = file
- localPreview.value = objectUrl
- result.value = null
- status.value = 'ready'
- adjustDirty.value = false
- resetAdjustments()
- try {
- const size = await readImageSize(objectUrl)
- photoMeta.width = size.width
- photoMeta.height = size.height
- if (size.width < 300 || size.height < 300) {
- showToast('图片分辨率偏低,建议换一张更清晰的照片', 'warning')
- }
- } catch {
- clearPhoto()
- showToast('无法读取图片,请更换后重试')
- }
- }
-
- function readImageSize(url) {
- return new Promise((resolve, reject) => {
- const image = new Image()
- image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight })
- image.onerror = reject
- image.src = url
- })
- }
-
- function clearPhoto() {
- revokeObjectUrl()
- localFile.value = null
- localPreview.value = ''
- photoMeta.width = 0
- photoMeta.height = 0
- result.value = null
- status.value = 'idle'
- errorMessage.value = ''
- adjustDirty.value = false
- }
-
- function startOver() {
- clearPhoto()
- nextTick(triggerUpload)
- }
-
- function revokeObjectUrl() {
- if (objectUrl) URL.revokeObjectURL(objectUrl)
- objectUrl = ''
- }
-
- function startProcessingProgress() {
- clearInterval(processingTimer)
- processingStartedAt = Date.now()
- updateProcessingProgress()
- processingTimer = setInterval(updateProcessingProgress, 500)
- }
-
- function updateProcessingProgress() {
- const elapsed = Math.max(0, Date.now() - processingStartedAt)
- const seconds = elapsed / 1000
- processingElapsedSeconds.value = seconds
-
- if (seconds < 1.5) {
- processingStage.value = 0
- processingProgress.value = Math.round(8 + seconds / 1.5 * 12)
- return
- }
- if (seconds < 8) {
- processingStage.value = 1
- processingProgress.value = Math.round(20 + (seconds - 1.5) / 6.5 * 52)
- return
- }
- if (seconds < 10) {
- processingStage.value = 2
- processingProgress.value = Math.round(72 + (seconds - 8) / 2 * 16)
- return
- }
-
- processingStage.value = 3
- processingProgress.value = Math.min(96, Math.round(88 + Math.min(seconds - 10, 2) * 2 + Math.max(0, seconds - 12) / 3))
- }
-
- function stopProcessingProgress(success = false) {
- clearInterval(processingTimer)
- processingTimer = null
- if (success) {
- processingStage.value = processingStages.length - 1
- processingProgress.value = 100
- processingElapsedSeconds.value = PROCESSING_ESTIMATE_SECONDS
- }
- }
-
- async function createPhoto() {
- if (!user.isLogin.value) {
- user.showLogin.value = true
- return
- }
- if (!localFile.value) {
- showToast('请先上传一张照片')
- triggerUpload()
- return
- }
- if (!selectedSpec.value || status.value === 'processing') return
- if (isBalanceInsufficient.value) {
- insufficientOpen.value = true
- return
- }
-
- status.value = 'processing'
- errorMessage.value = ''
- startProcessingProgress()
- try {
- const response = await create(localFile.value, {
- request_id: createRequestId(),
- spec_key: selectedSpecKey.value,
- ...settings,
- })
- stopProcessingProgress(true)
- result.value = response.data || null
- status.value = 'success'
- adjustDirty.value = false
- currentWork.value = {
- ...(response.data || {}),
- id: response.data?.photo_open_id,
- work_type: 'photo',
- cover: response.data?.result_url,
- original: response.data?.source_url,
- }
- await user.refreshBalance()
- if (billing.value?.balance) billing.value.balance.points = user.userInfo.value.balance
- showToast('证件照制作完成', 'success')
- } catch (error) {
- stopProcessingProgress()
- if (Number(error?.code || error?.data?.code) === 1011) {
- status.value = 'ready'
- insufficientOpen.value = true
- return
- }
- status.value = 'error'
- errorMessage.value = friendlyError(error)
- showToast(errorMessage.value)
- }
- }
-
- function friendlyError(error) {
- const serviceCode = error?.data?.error
- const map = {
- NO_FACE: '没有检测到清晰人脸,请上传正面半身照',
- MULTIPLE_FACES: '检测到多张人脸,请上传单人照片',
- FACE_TOO_SMALL: '人脸在画面中太小,请上传更清晰的近距离照片',
- FACE_TILTED: '人脸倾斜角度较大,请上传正面平视照片',
- QUEUE_FULL: '当前制作人数较多,请稍后再试',
- }
- return map[serviceCode] || error?.message || '证件照制作失败,请稍后重试'
- }
-
- async function applyAdjustments() {
- if (!result.value?.photo_open_id || !adjustDirty.value || rerendering.value) return
- rerendering.value = true
- errorMessage.value = ''
- try {
- const response = await api.post('/idphoto/rerender', {
- photo_open_id: result.value.photo_open_id,
- ...settings,
- })
- result.value = { ...result.value, ...(response.data || {}) }
- currentWork.value = {
- ...currentWork.value,
- ...result.value,
- cover: result.value.result_url,
- original: result.value.source_url,
- }
- adjustDirty.value = false
- showToast('调整已应用', 'success')
- } catch (error) {
- errorMessage.value = friendlyError(error)
- showToast(errorMessage.value)
- } finally {
- rerendering.value = false
- }
- }
-
- function resetAdjustments() {
- settings.scale = 1
- settings.offset_x = 0
- settings.offset_y = 0
- }
-
- function adjustmentLabel(value) {
- const number = Number(value)
- if (Math.abs(number) < 0.01) return '居中'
- return number > 0 ? `+${Math.round(number * 100)}` : `${Math.round(number * 100)}`
- }
-
- function openHistory() {
- if (!user.isLogin.value) {
- user.showLogin.value = true
- return
- }
- historyOpen.value = true
- }
-
- function goRecharge() {
- insufficientOpen.value = false
- router.push('/recharge')
- }
-
- function openHistoryItem(item) {
- currentWork.value = item
- detailOpen.value = true
- }
-
- async function downloadResult() {
- const url = result.value?.result_url
- if (!url) return
- try {
- const response = await fetch(url)
- if (!response.ok) throw new Error('download failed')
- const blob = await response.blob()
- const object = URL.createObjectURL(blob)
- const anchor = document.createElement('a')
- anchor.href = object
- anchor.download = `${result.value.spec_name || '证件照'}-${result.value.width}x${result.value.height}.jpg`
- anchor.click()
- URL.revokeObjectURL(object)
- } catch {
- window.open(url, '_blank', 'noopener')
- }
- }
-
- function handleImageError() {
- errorMessage.value = result.value ? '成品图片加载失败,请稍后重试' : '照片预览失败,请重新选择'
- }
-
- async function loadConfig() {
- configLoading.value = true
- try {
- const response = await api.post('/idphoto/config')
- const data = response.data || {}
- categories.value = Array.isArray(data.categories) ? data.categories : []
- specs.value = Array.isArray(data.specs) ? data.specs : []
- backgrounds.value = Array.isArray(data.backgrounds) && data.backgrounds.length
- ? data.backgrounds
- : FALLBACK_BACKGROUNDS
- beautyOptions.value = Array.isArray(data.beauty_options) && data.beauty_options.length
- ? data.beauty_options
- : FALLBACK_BEAUTY
- billing.value = data.billing || null
- if (!selectedSpec.value && specs.value.length) {
- selectedSpecKey.value = specs.value[0].key
- selectedCategory.value = specs.value[0].category
- }
- } catch (error) {
- errorMessage.value = error.message || '证件照配置加载失败'
- showToast(errorMessage.value)
- } finally {
- configLoading.value = false
- }
- }
-
- async function loadWorkFromRoute() {
- const id = String(route.query.work || '')
- if (!id || !user.isLogin.value) return
- try {
- const response = await api.post('/idphoto/getdetail', { photo_open_id: id })
- if (response.data) {
- currentWork.value = { ...response.data, work_type: 'photo' }
- detailOpen.value = true
- }
- } catch {}
- }
-
- watch(
- () => [settings.background, settings.beauty, settings.scale, settings.offset_x, settings.offset_y],
- () => {
- if (result.value && !rerendering.value) adjustDirty.value = true
- },
- )
-
- watch(() => user.isLogin.value, (loggedIn) => {
- if (loggedIn) {
- user.refreshBalance()
- loadConfig()
- }
- })
-
- onMounted(async () => {
- await loadConfig()
- if (user.isLogin.value) {
- await user.refreshBalance()
- await loadWorkFromRoute()
- }
- })
-
- onBeforeUnmount(() => {
- clearInterval(processingTimer)
- revokeObjectUrl()
- })
- </script>
-
- <style scoped>
- .no-scrollbar {
- scrollbar-width: none;
- -ms-overflow-style: none;
- }
- .no-scrollbar::-webkit-scrollbar {
- display: none;
- }
-
- .id-range {
- height: 4px;
- appearance: none;
- border-radius: 999px;
- background: var(--c-dfe7f0);
- outline: none;
- }
- .id-range::-webkit-slider-thumb {
- width: 18px;
- height: 18px;
- appearance: none;
- cursor: pointer;
- border: 3px solid var(--c-ffffff);
- border-radius: 50%;
- background: var(--c-0b8cff);
- box-shadow: 0 2px 8px var(--sh-11-140-255-350);
- }
- .id-range::-moz-range-thumb {
- width: 14px;
- height: 14px;
- cursor: pointer;
- border: 3px solid var(--c-ffffff);
- border-radius: 50%;
- background: var(--c-0b8cff);
- box-shadow: 0 2px 8px var(--sh-11-140-255-350);
- }
-
- .id-dialog-enter-active,
- .id-dialog-leave-active {
- transition: opacity 180ms ease;
- }
- .id-dialog-enter-active > div,
- .id-dialog-leave-active > div {
- transition: transform 180ms ease, opacity 180ms ease;
- }
- .id-dialog-enter-from,
- .id-dialog-leave-to {
- opacity: 0;
- }
- .id-dialog-enter-from > div,
- .id-dialog-leave-to > div {
- opacity: 0;
- transform: translateY(12px) scale(0.98);
- }
-
- @media (prefers-reduced-motion: reduce) {
- * {
- scroll-behavior: auto !important;
- }
- .id-dialog-enter-active,
- .id-dialog-leave-active,
- .id-dialog-enter-active > div,
- .id-dialog-leave-active > div {
- transition: none;
- }
- }
- </style>
|