|
- <template>
- <div class="min-h-screen bg-[var(--c-f3f7fb)]">
- <!-- ========== Hero (统一使用 AppHero,保证各页高度/底色一致) ========== -->
- <AppHero
- kicker="ANIME AVATAR STUDIO"
- title="上传自拍,生成你的动漫头像"
- desc="用真实对比看效果。支持竖图、半身照和普通自拍,脸部清晰会更稳定。"
- >
- <template #actions>
- <button
- type="button"
- class="h-10 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_20px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-0a7ce0)] lg:h-11"
- @click="openCompare"
- >
- 查看对比
- </button>
- <button
- type="button"
- class="h-10 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11"
- @click="triggerUpload"
- >
- 上传照片
- </button>
- <button
- type="button"
- class="inline-flex h-10 items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11"
- @click="historyOpen = true"
- >
- <UIcon name="i-lucide-history" class="size-4" />
- 历史记录
- </button>
- </template>
- <template #aside>
- <HeroComparePreview
- v-if="demoPair.original && demoPair.anime"
- :before="demoPair.original"
- :after="demoPair.anime"
- after-label="动漫"
- @open="openCompare"
- />
- </template>
- </AppHero>
-
- <!-- ========== Main Grid ========== -->
- <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8">
- <div class="lg:grid lg:grid-cols-12 lg:gap-8">
- <!-- ===== LEFT: Form (col-span-7) ===== -->
- <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0">
- <!-- 1. 参考照片 Upload Card -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
- <div class="flex items-start justify-between gap-4 mb-4">
- <div class="min-w-0">
- <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">参考照片</h3>
- <p class="mt-1 text-xs text-[var(--c-8a97a8)]">建议使用正面、上半身、脸部无遮挡的照片</p>
- </div>
- <span class="flex-shrink-0 px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-ff6b6b)] bg-[var(--c-fff1f1)]">必选</span>
- </div>
- <!-- Upload Strip -->
- <div class="rounded-2xl border-2 border-[var(--c-e5edf7)] bg-[var(--c-f7faff)] p-4 flex items-center gap-4 cursor-pointer hover:border-[var(--c-1f8cff)]/40 transition-colors"
- @click="triggerUpload" @dragover.prevent @drop.prevent="onDrop">
- <div class="w-20 h-28 rounded-2xl bg-[var(--c-edf3fb)] overflow-hidden relative flex-shrink-0">
- <img v-if="previewImage" :src="previewImage" class="w-full h-full object-cover" alt="preview" />
- <div v-else class="w-full h-full flex items-center justify-center">
- <span class="w-9 h-9 rounded-full bg-[var(--c-1f8cff)] text-white text-2xl font-light flex items-center justify-center">+</span>
- </div>
- <div v-if="uploading" class="absolute inset-0 bg-[var(--c-111e34)]/60 text-white text-xs flex items-center justify-center">上传中</div>
- </div>
- <div class="flex-1 min-w-0">
- <div class="text-sm lg:text-base font-extrabold text-[var(--c-172033)]">{{ previewImage ? '已选择照片' : '选择一张清晰自拍' }}</div>
- <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">不用裁成正方形,竖图和半身照也可以</div>
- </div>
- <div class="w-16 h-9 rounded-full bg-[var(--c-1f8cff)] text-white text-xs font-extrabold flex items-center justify-center flex-shrink-0">{{ previewImage ? '更换' : '上传' }}</div>
- </div>
- <input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
- <!-- Tips -->
- <div class="mt-3 flex gap-2.5">
- <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">正面</span>
- <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">脸部清晰</span>
- <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">避免遮挡</span>
- </div>
- </div>
-
- <!-- 2. 头像风格 -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
- <div class="flex items-start justify-between gap-4">
- <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">头像风格</h3>
- <span class="text-xs text-[var(--c-8a97a8)] mt-1">点选切换</span>
- </div>
- <div v-if="styleLoading" class="mt-4 flex justify-center py-8">
- <span class="w-6 h-6 border-2 border-[var(--c-1f8cff)] border-t-transparent rounded-full animate-spin" />
- </div>
- <template v-else>
- <!-- 风格网格:移动 3 列 / 桌面 4 列,默认 2 行折叠 -->
- <div class="mt-4 grid grid-cols-3 lg:grid-cols-4 gap-2.5 lg:gap-3">
- <button v-for="s in visibleStyleList" :key="s.value" type="button"
- class="p-2 rounded-2xl border-2 transition-all text-center"
- :class="form.img2img_style === String(s.value)
- ? 'bg-[var(--c-eef7ff)] border-[var(--c-1f8cff)]'
- : 'bg-[var(--c-f6f9fd)] border-transparent hover:border-[var(--c-e5edf7)]'"
- @click="selectStyle(s)">
- <div class="w-full aspect-square rounded-xl overflow-hidden bg-[var(--c-eaf0f8)] flex items-center justify-center">
- <img v-if="s.pic" :src="s.pic" class="w-full h-full object-cover" :alt="s.name" />
- <span v-else class="text-xs text-[var(--c-77869a)]">{{ s.name }}</span>
- </div>
- <div class="mt-2 text-xs font-bold text-[var(--c-334155)] truncate">{{ s.name }}</div>
- </button>
- </div>
- <!-- 展开/收起按钮 -->
- <button v-if="hasMoreStyles" type="button"
- class="mt-3 w-full h-10 rounded-xl border border-[var(--c-e5edf7)] text-[var(--c-1f8cff)] text-sm font-bold hover:bg-[var(--c-eef7ff)] transition-colors flex items-center justify-center gap-1"
- @click="styleExpanded = !styleExpanded">
- <span>{{ styleExpanded ? '收起' : '展开全部' }}</span>
- <UIcon :name="styleExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-4" />
- </button>
- </template>
- </div>
-
- <!-- 3. 自由度 -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
- <div class="flex items-start justify-between gap-4">
- <div>
- <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">自由度</h3>
- <p class="mt-1 text-xs text-[var(--c-8a97a8)]">{{ strengthCopy }}</p>
- </div>
- <span class="text-lg font-extrabold text-[var(--c-1f8cff)]">{{ form.img2img_strength.toFixed(2) }}</span>
- </div>
- <input type="range" min="0.1" max="1" step="0.01" v-model.number="form.img2img_strength"
- class="mt-4 w-full h-2 rounded-full appearance-none bg-[var(--c-e7edf5)] accent-[var(--c-1f8cff)] cursor-pointer" />
- <div class="mt-2 flex justify-between text-xs text-[var(--c-8a97a8)]">
- <span>更像原图</span><span>更有创意</span>
- </div>
- </div>
-
- <!-- 4. 画面偏好 -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
- <div class="flex items-start justify-between gap-4">
- <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">画面偏好</h3>
- <span class="text-xs text-[var(--c-8a97a8)] mt-1">可选</span>
- </div>
- <div class="mt-4">
- <label class="block text-xs font-bold text-[var(--c-526174)] mb-2">描述词</label>
- <input v-model="form.content" type="text" maxlength="100" placeholder="例如:浅色背景、清爽、微笑"
- class="w-full h-12 rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" />
- </div>
- <div class="mt-4">
- <label class="block text-xs font-bold text-[var(--c-526174)] mb-2">反面提示词</label>
- <input v-model="form.no" type="text" maxlength="100" placeholder="例如:模糊、变形、低清晰度"
- class="w-full h-12 rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" />
- </div>
- </div>
-
- <!-- 5. Tips -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)] space-y-3">
- <div class="flex items-center">
- <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span>
- <span class="text-sm text-[var(--c-5f6f83)] leading-relaxed">上传照片后可直接生成,处理完成会进入作品详情页。</span>
- </div>
- <div class="flex items-center">
- <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span>
- <span class="text-sm text-[var(--c-5f6f83)] leading-relaxed">历史记录里可以查看生成进度,也可以继续打开已完成作品。</span>
- </div>
- </div>
-
- <!-- Desktop Submit -->
- <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
- <div class="flex-1 min-w-0">
- <div class="text-sm font-bold text-[var(--c-172033)] whitespace-nowrap">{{ balanceText }}</div>
- <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[var(--c-f04438)]">点数不足,请前往充值</div>
- </div>
- <button type="button"
- class="h-14 flex-shrink-0 rounded-full border border-[var(--c-e5edf7)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)] flex items-center gap-1.5"
- @click="historyOpen = true">
- <UIcon name="i-lucide-history" class="size-4" />
- 历史记录
- </button>
- <button type="button"
- class="px-10 h-14 rounded-full text-white font-bold text-base shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]"
- :class="submitting ? 'bg-[var(--c-1979e6)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] hover:opacity-90'"
- :disabled="!canSubmit || submitting" @click="handleSubmit">
- <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
- <span>{{ submitting ? '生成中...' : submitText }}</span>
- </button>
- </div>
- </div>
-
- <!-- ===== RIGHT: Result (col-span-5, sticky) ===== -->
- <div class="lg:col-span-5">
- <!-- 吸顶偏移 = header 高度(64px) + 间距,避免被导航遮挡 -->
- <div class="space-y-4 lg:sticky lg:top-20 lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto">
- <!-- Empty -->
- <div v-if="!resultState.visible" class="bg-[var(--c-ffffff)] rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center">
- <UIcon name="i-lucide-image" class="size-12 text-[var(--c-a0aabb)] mb-4" />
- <div class="text-base font-medium text-[var(--c-172033)] mb-1">等待生成</div>
- <div class="text-sm text-[var(--c-a0aabb)]">上传照片并选择风格后</div>
- <div class="text-sm text-[var(--c-a0aabb)]">点击"立即生成"即可看到效果</div>
- </div>
-
- <!-- Loading -->
- <div v-if="resultState.loading" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm">
- <div class="text-base font-bold text-[var(--c-172033)] mb-1">AI 正在创作中</div>
- <div class="text-sm text-[var(--c-7f8896)] mb-6">预计需要 10-30 秒,请耐心等待...</div>
- <div class="rounded-2xl bg-[var(--c-f7f9fd)] py-10 flex flex-col items-center">
- <div class="flex gap-1.5 mb-4">
- <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot" />
- <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot2" />
- <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot3" />
- </div>
- <div class="text-sm font-bold text-[var(--c-172033)]">正在生成你的动漫头像</div>
- <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div>
- </div>
- </div>
-
- <!-- Error -->
- <div v-if="resultState.error" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
- <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" />
- <div class="text-base font-medium text-[var(--c-f04438)] mb-2">生成失败</div>
- <div class="text-sm text-[var(--c-7f8896)] mb-4">{{ resultState.errorMsg }}</div>
- <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
- </div>
-
- <!-- Success -->
- <div v-if="resultState.success" class="space-y-4">
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-sm">
- <div class="text-base font-bold text-[var(--c-172033)] mb-4">效果对比</div>
- <div class="grid grid-cols-2 gap-3">
- <div>
- <div class="text-xs text-[var(--c-8a95a6)] mb-2">原图</div>
- <img v-if="previewImage" :src="previewImage" class="w-full aspect-square rounded-xl object-cover bg-[var(--c-f7f9fd)]" alt="original" />
- </div>
- <div>
- <div class="text-xs text-[var(--c-8a95a6)] mb-2">动漫头像</div>
- <div class="flex aspect-square w-full items-center justify-center overflow-hidden rounded-xl border-2 border-[var(--c-1f8cff)] bg-[var(--c-f7f9fd)]">
- <img
- v-if="genResultUrl && !resultImageFailed"
- :src="genResultUrl"
- class="h-full w-full object-cover"
- alt="动漫头像生成结果"
- @error="handleResultImageError"
- >
- <div v-else class="px-4 text-center">
- <UIcon name="i-lucide-image-off" class="mx-auto mb-2 size-8 text-[var(--c-a0aabb)]" />
- <div class="text-xs leading-relaxed text-[var(--c-7f8896)]">结果图片暂时无法加载</div>
- </div>
- </div>
- </div>
- </div>
- <div class="mt-5 flex gap-3">
- <button type="button"
- class="flex-1 h-12 rounded-full border-2 border-[var(--c-1f8cff)] text-[var(--c-1f8cff)] font-bold hover:bg-[var(--c-eef7ff)] transition-colors flex items-center justify-center"
- @click="openResultCompare">
- <UIcon name="i-lucide-split-square-horizontal" class="size-4 mr-1.5" />
- 对比查看
- </button>
- <button type="button"
- class="flex-1 h-12 rounded-full bg-[var(--c-1f8cff)] hover:bg-[var(--c-1979e6)] text-white font-bold text-base shadow-[0_8px_20px_var(--sh-31-140-255-200)] transition-colors flex items-center justify-center"
- @click="downloadResult">
- <UIcon name="i-lucide-download" class="size-4 mr-1.5" />
- 下载头像
- </button>
- </div>
- </div>
- <div class="flex gap-3">
- <button type="button"
- class="flex-1 h-12 rounded-full border-2 border-[var(--c-1f8cff)] text-[var(--c-1f8cff)] font-bold hover:bg-[var(--c-eef7ff)] transition-colors"
- :disabled="submitting" @click="handleSubmit">重新生成</button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- ========== Mobile Bottom Bar ========== -->
- <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-[var(--c-ffffff)]/95 backdrop-blur shadow-[0_-10px_30px_var(--sh-32-56-92-100)]"
- style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))">
- <div class="flex items-center justify-between mb-2.5 gap-3 text-sm">
- <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div>
- <button v-if="isBalanceInsufficient" class="text-[var(--c-1f8cff)] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button>
- </div>
- <div class="flex gap-3">
- <button class="w-[120px] h-12 rounded-full bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-sm font-extrabold" @click="goHistory">历史记录</button>
- <button class="flex-1 h-12 rounded-full text-white text-sm font-extrabold shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-opacity flex items-center justify-center"
- :class="(canSubmit && !submitting) ? 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] opacity-60'"
- :disabled="!canSubmit || submitting" @click="handleSubmit">
- <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
- {{ submitting ? '生成中...' : submitText }}
- </button>
- </div>
- </div>
-
- <!-- ========== 历史记录抽屉 + 详情弹窗 ========== -->
- <WorkHistoryDrawer v-model="historyOpen" type="anime" @select="openHistoryItem" />
- <WorkDetailModal v-model="detailOpen" :work="currentWork" />
-
- <!-- ========== Compare Modal (大图滑块对比,支持 demo 和生成结果) ========== -->
- <Teleport to="body">
- <Transition name="modal">
- <div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
- <div class="absolute inset-0 bg-[var(--c-0f172a)]/60" @click="closeCompare" />
- <div class="relative bg-[var(--c-ffffff)] rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
- <div class="flex items-center justify-between px-5 py-4 border-b border-[var(--c-f3f4f6)]">
- <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3>
- <button class="text-[var(--c-a0aabb)] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--c-f3f4f6)]" @click="closeCompare">×</button>
- </div>
- <div class="p-5">
- <div ref="largeCompareRef" class="relative w-full aspect-[3/4] max-h-[70vh] rounded-2xl overflow-hidden bg-[var(--c-dfe8f4)] cursor-ew-resize select-none"
- @mousedown="onCompareStart" @touchstart.passive="onCompareStart">
- <!-- 动漫图(底层,全尺寸) -->
- <img v-if="comparePair.anime" :src="comparePair.anime" class="absolute inset-0 w-full h-full object-cover pointer-events-none" alt="anime" />
- <!-- 原图(上层,用 clip-path 裁剪,与底层完全重叠) -->
- <img v-if="comparePair.original" :src="comparePair.original" class="absolute inset-0 w-full h-full object-cover pointer-events-none"
- :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }" alt="original" />
- <!-- 分割线 + 拖拽手柄 -->
- <div class="absolute top-0 bottom-0 w-1 -ml-px bg-[var(--c-ffffff)]/95 shadow-[0_0_12px_var(--sh-20-37-66-220)] pointer-events-none" :style="{ left: comparePercent + '%' }">
- <div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-14 rounded-full bg-[var(--c-ffffff)] shadow-[0_8px_18px_var(--sh-20-37-66-220)] flex items-center justify-center text-[var(--c-1f8cff)] text-lg font-extrabold gap-px">
- <span>‹</span><span>›</span>
- </div>
- </div>
- <div class="absolute top-3 left-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[var(--c-0f172a)]/50 pointer-events-none">原图</div>
- <div class="absolute top-3 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[var(--c-0f172a)]/50 pointer-events-none">{{ compareTarget === 'result' ? '生成图' : '动漫图' }}</div>
- </div>
- <div class="mt-3 text-center text-xs text-[var(--c-64748b)]">左右拖动中间按钮查看变化</div>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
- </div>
- </template>
-
- <script setup>
- import { computed, reactive, ref, onMounted, onUnmounted } from 'vue'
-
- const _api = useApi()
- const api = _api // _api 是 { get, post, request },这里直接别名保持调用方式不变
-
- await useLegacyAppSeo({
- appKey: 'anime_avatar',
- fallbackTitle: 'AI漫画头像-动漫头像生成',
- fallbackDescription: 'AI生成漫画头像是一种新兴的技术,人工智能通过用户上传的图像处理技术,可以快速生成具有卡通、动漫等不同风格的头像。这种技术可以应用于社交媒体、游戏和虚拟现实等领域,为用户提供个性化、有趣的头像选择。',
- fallbackKeywords: '漫画头像、动漫头像、漫画头像生成、ai动漫头像',
- canonicalPath: '/draw/anime/',
- })
-
- const user = useUser()
- const router = useRouter()
- const { uploadImage } = useUpload()
- const toast = useToast()
-
- function showToast(message, color = 'red') {
- toast.add({ title: message, color })
- }
-
- const ANIME_BILLING_MODEL = 'tencent_anime'
-
- // ==================== Form ====================
- const form = reactive({
- imgs: [], content: '', ai_type: 3, style_type: 1, ar: '1:1', no: '',
- model: ANIME_BILLING_MODEL, model_type: 6, light: '', text: '', speed: '',
- img2img_style: '201', age: 80, change_sex_type: '0', img2img_strength: 0.6, num: 1,
- app_name: 'anime-avatar',
- })
-
- // ==================== State ====================
- const fileInput = ref(null)
- const previewImage = ref('')
- const uploading = ref(false)
- const submitting = ref(false)
- const styleList = ref([{ value: '201', name: '默认', model: 2, pic: '' }])
- const styleLoading = ref(false)
- const styleExpanded = ref(false)
- const STYLE_COLLAPSED_COUNT = 8 // 2 行 × 4 列(桌面)
- const visibleStyleList = computed(() =>
- styleExpanded.value ? styleList.value : styleList.value.slice(0, STYLE_COLLAPSED_COUNT)
- )
- const hasMoreStyles = computed(() => styleList.value.length > STYLE_COLLAPSED_COUNT)
- const balance = reactive({ points: 0 })
- const quote = reactive({ points: 0 })
- const chatOpenId = ref('')
- const demoPair = ref({ original: '', anime: '' })
-
- const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' })
- const genResultUrl = ref('')
- const resultImageFailed = ref(false)
-
- let pollTimer = null
- let countTimer = null
- const pollCountdown = ref(3)
-
- // ==================== Computed ====================
- const canSubmit = computed(() => form.imgs.length > 0 && !uploading.value)
- const currentPoints = computed(() => Number(balance.points || 0))
- const isBalanceInsufficient = computed(() =>
- user.isLogin.value && Number(quote.points || 0) > 0 && currentPoints.value >= 0 && currentPoints.value < Number(quote.points || 0)
- )
- const balanceText = computed(() => {
- const c = Number(quote.points || 0)
- if (!user.isLogin.value) return c ? `动漫头像 · ${c}点/张` : '动漫头像'
- return c ? `余额${currentPoints.value}点 · 预计消耗${c}点` : `余额${currentPoints.value}点`
- })
- const submitText = computed(() => {
- const c = Number(quote.points || 0)
- if (!user.isLogin.value) return c ? `登录后生成 · ${c}点` : '登录后生成'
- return c ? `立即生成 · ${c}点` : '立即生成'
- })
- const strengthCopy = computed(() => '推荐 0.6 - 0.8,保留人脸特征')
-
- // ==================== Upload ====================
- function triggerUpload() {
- if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return }
- fileInput.value?.click()
- }
- function onFileChange(e) {
- const f = e.target.files?.[0]
- if (f) handleFile(f)
- e.target.value = ''
- }
- function onDrop(e) {
- const f = e.dataTransfer?.files?.[0]
- if (f) handleFile(f)
- }
- async function handleFile(file) {
- if (uploading.value) return
- uploading.value = true
- try {
- previewImage.value = URL.createObjectURL(file)
- const r = await uploadImage(file)
- if (r.data?.url) {
- form.imgs = [r.data.url]
- if (r.data.width) form.width = r.data.width
- if (r.data.height) form.height = r.data.height
- } else {
- previewImage.value = ''
- showToast(r.msg || '上传失败')
- }
- } catch (e) {
- previewImage.value = ''
- form.imgs = []
- showToast(e.msg || e.message || '上传失败')
- } finally {
- uploading.value = false
- }
- }
-
- // ==================== Style List (无需登录) ====================
- async function loadStyles() {
- styleLoading.value = true
- try {
- const r = await api.post('/anime/getstylelist', { model: '2' })
- const rows = Array.isArray(r.list) ? r.list : (Array.isArray(r.data) ? r.data : [])
- const list = rows.map((item) => ({
- ...item, value: String(item.value),
- pic: item.pic || item.img || item.image || item.cover || item.icon || item.url || '',
- }))
- if (list.length) {
- styleList.value = list
- if (!form.img2img_style || !list.some(s => s.value === form.img2img_style)) {
- selectStyle(list[0], false)
- }
- }
- } catch (e) { console.error('[anime-avatar] loadStyles failed:', e) }
- styleLoading.value = false
- }
- function selectStyle(item, resetStrength = true) {
- form.img2img_style = String(item.value)
- form.ai_type = 3
- if (resetStrength) form.img2img_strength = 0.6
- }
-
- // ==================== Demo (for hero compare) ====================
- async function loadDemo() {
- try {
- const r = await api.post('/common/getconfig', { keys: 'anime_demo_v2,anime_demo' })
- const rows = Array.isArray(r.list) ? r.list : []
- const v2 = rows.find(it => it.key === 'anime_demo_v2')
- const old = rows.find(it => it.key === 'anime_demo')
- let list = []
- if (v2 && v2.value) {
- try {
- const data = typeof v2.value === 'string' ? JSON.parse(v2.value) : v2.value
- const arr = Array.isArray(data) ? data : [data]
- list = arr.map(it => ({
- original: it.original || it.before || it.source || '',
- anime: it.anime || it.after || it.result || '',
- })).filter(it => it.original && it.anime).slice(0, 1)
- } catch {}
- }
- if (!list.length && old && old.value) {
- const pics = String(old.value).split(',').map(s => s.trim()).filter(Boolean)
- if (pics.length >= 2) list = [{ original: pics[0], anime: pics[1] }]
- }
- if (list.length) demoPair.value = list[0]
- } catch (e) { console.error('[anime-avatar] loadDemo failed:', e) }
- }
-
- // ==================== Billing ====================
- async function loadBilling() {
- try {
- const r = await api.post('/billing/config', { app_key: 'anime-avatar', model: ANIME_BILLING_MODEL })
- if (r.code === 0 && r.data) {
- quote.points = r.data.quote?.points || 0
- if (r.data.balance) balance.points = r.data.balance.points || r.data.balance.point_balance || 0
- }
- } catch (e) { console.error('[anime-avatar] loadBilling failed:', e) }
- }
- async function loadBalance() {
- try {
- const r = await api.post('/user/getbalance', {})
- if (r.code === 0 && r.data) balance.points = r.data.points || r.data.point_balance || 0
- } catch (e) { console.error('[anime-avatar] loadBalance failed:', e) }
- // 同步 header 显示的点数
- user.refreshBalance()
- }
-
- onMounted(() => {
- loadStyles()
- loadBilling()
- loadDemo()
- if (user.isLogin.value) loadBalance()
- })
- onUnmounted(() => clearPoll())
-
- // ==================== 历史记录 / 详情 ====================
- const historyOpen = ref(false)
- const detailOpen = ref(false)
- const currentWork = ref({})
-
- function openHistoryItem(item) {
- currentWork.value = item
- detailOpen.value = true
- }
-
- // ==================== Compare Modal ====================
- const compareVisible = ref(false)
- const comparePercent = ref(50)
- const largeCompareRef = ref(null)
- const compareTarget = ref('demo') // 'demo' | 'result'
- let dragging = false
-
- const comparePair = computed(() => {
- if (compareTarget.value === 'result' && previewImage.value && genResultUrl.value) {
- return { original: previewImage.value, anime: genResultUrl.value }
- }
- return demoPair.value
- })
-
- function openCompare() {
- if (!demoPair.value.original || !demoPair.value.anime) { showToast('暂无对比图'); return }
- comparePercent.value = 50
- compareTarget.value = 'demo'
- compareVisible.value = true
- }
- function openResultCompare() {
- if (!previewImage.value) {
- showToast('原图暂不可用,请重新上传')
- return
- }
- if (!genResultUrl.value || resultImageFailed.value) {
- showToast('结果图片暂不可用,请稍后重试')
- return
- }
- comparePercent.value = 50
- compareTarget.value = 'result'
- compareVisible.value = true
- }
- function closeCompare() { compareVisible.value = false; dragging = false }
- function onCompareStart(e) {
- dragging = true
- updateComparePercent(e)
- window.addEventListener('mousemove', onCompareMove)
- window.addEventListener('mouseup', onCompareEnd)
- window.addEventListener('touchmove', onCompareMove, { passive: false })
- window.addEventListener('touchend', onCompareEnd)
- }
- function onCompareMove(e) {
- if (!dragging) return
- if (e.cancelable) e.preventDefault()
- updateComparePercent(e.touches?.[0] || e)
- }
- function onCompareEnd() {
- dragging = false
- window.removeEventListener('mousemove', onCompareMove)
- window.removeEventListener('mouseup', onCompareEnd)
- window.removeEventListener('touchmove', onCompareMove)
- window.removeEventListener('touchend', onCompareEnd)
- }
- function updateComparePercent(e) {
- if (!largeCompareRef.value) return
- const rect = largeCompareRef.value.getBoundingClientRect()
- const x = (e.clientX || 0) - rect.left
- const percent = (x / rect.width) * 100
- comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1))))
- }
-
- function resolveResultImage(data = {}) {
- const candidates = [
- data.content,
- data.answer,
- data.result_url,
- data.resultUrl,
- data.image_url,
- data.image,
- data.url,
- ]
- for (const candidate of candidates) {
- if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
- if (candidate && typeof candidate === 'object') {
- const nested = candidate.url || candidate.image_url || candidate.result_url
- if (typeof nested === 'string' && nested.trim()) return nested.trim()
- }
- }
- return ''
- }
-
- function handleResultImageError() {
- resultImageFailed.value = true
- showToast('结果图片加载失败,请稍后重试')
- }
-
- // ==================== Submit ====================
- function clearPoll() {
- clearTimeout(pollTimer)
- clearInterval(countTimer)
- }
- async function startPoll() {
- clearPoll()
- pollCountdown.value = 3
- countTimer = setInterval(() => { pollCountdown.value = Math.max(0, pollCountdown.value - 1) }, 1000)
- pollTimer = setTimeout(async () => {
- try {
- const r = await api.post('/anime/getdetail', { chat_open_id: chatOpenId.value })
- const d = r.data || {}
- const s = Number(d.is_suc)
- if (s > 0) {
- const resultUrl = resolveResultImage(d)
- if (!resultUrl) {
- resultState.value = {
- visible: true,
- loading: false,
- error: true,
- success: false,
- errorMsg: '生成已完成,但未获取到结果图片,请稍后重试',
- }
- } else {
- genResultUrl.value = resultUrl
- resultImageFailed.value = false
- resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' }
- }
- clearPoll()
- loadBalance()
- } else if (s < 0) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: d.err_msg || d.msg || '生成失败,请重试' }
- clearPoll()
- } else {
- startPoll()
- }
- } catch (e) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '查询失败' }
- }
- }, 3000)
- }
-
- async function handleSubmit() {
- if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return }
- if (uploading.value) { showToast('图片正在上传'); return }
- if (!form.imgs.length) { showToast('请上传参考图'); return }
- if (submitting.value) return
-
- submitting.value = true
- resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' }
- genResultUrl.value = ''
- resultImageFailed.value = false
-
- try {
- const params = { ...form, imgs: form.imgs.slice(0, 1).join(',') }
- const r = await api.post('/anime/send', params)
-
- if (r.code === 1011) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '点数不足,请充值后重试' }
- submitting.value = false
- return
- }
- if (r.code === 1012) {
- form.content = ''
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '内容审核不通过,请调整后重试' }
- submitting.value = false
- return
- }
- if (r.code !== 0) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '提交失败' }
- submitting.value = false
- return
- }
-
- chatOpenId.value = r.data?.answer_chat_open_id || r.data?.chat_open_id || ''
- if (!chatOpenId.value) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '未获取到任务ID' }
- submitting.value = false
- return
- }
- startPoll()
- } catch (e) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '提交失败' }
- } finally {
- submitting.value = false
- }
- }
-
- async function downloadResult() {
- if (!genResultUrl.value || resultImageFailed.value) {
- showToast('结果图片暂不可用,请稍后重试')
- return
- }
- try {
- const r = await fetch(genResultUrl.value)
- const b = await r.blob()
- const a = document.createElement('a')
- a.href = URL.createObjectURL(b)
- a.download = `anime-avatar-${Date.now()}.png`
- a.click()
- URL.revokeObjectURL(a.href)
- } catch {
- window.open(genResultUrl.value, '_blank')
- }
- }
-
- function goRecharge() { router.push('/recharge') }
- // 历史记录改为抽屉,不再跳转页面
- function goHistory() { historyOpen.value = true }
- </script>
-
- <style scoped>
- .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
- .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
- .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
- @keyframes dotPulse {
- 0%, 100% { opacity: 0.35; transform: scale(0.82); }
- 50% { opacity: 1; transform: scale(1); }
- }
- .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
- .modal-enter-from, .modal-leave-to { opacity: 0; }
- </style>
|