|
- <template>
- <view class="page">
- <view class="content">
- <view class="hero-card">
- <view class="hero-copy">
- <text class="eyebrow">SMART ID PHOTO</text>
- <text class="hero-title">一张自拍,制作标准证件照</text>
- <text class="hero-desc">上传一张清晰正面照,自动换好底色、裁成所需尺寸,几秒获得可直接下载使用的高清证件照。</text>
- <view class="hero-tags">
- <text>300 DPI</text>
- <text>快速换底色</text>
- </view>
- </view>
- <image
- class="hero-image"
- src="https://cdn.aionline.cc/ai/anime/id-photo-demo.jpg"
- mode="aspectFill"
- />
- </view>
-
- <view id="id-photo-preview" class="id-panel preview-card">
- <view class="card-head">
- <view>
- <text class="card-title">照片预览</text>
- <text class="card-desc">{{ previewDescription }}</text>
- </view>
- <view class="history-link" @click="goHistory">历史记录</view>
- </view>
-
- <view v-if="!localPreview" class="upload-zone" @click="choosePhoto">
- <view class="upload-icon">
- <view class="upload-arrow"></view>
- </view>
- <text class="upload-title">点击上传一张照片</text>
- <text class="upload-desc">支持 JPG、PNG、WebP,不超过 10MB</text>
- <view class="upload-button">选择照片</view>
- <view class="photo-tips">
- <view class="tip-item"><view class="tip-dot"></view><text>正面平视</text></view>
- <view class="tip-item"><view class="tip-dot"></view><text>光线均匀</text></view>
- <view class="tip-item"><view class="tip-dot"></view><text>单人半身</text></view>
- </view>
- </view>
-
- <view v-else class="preview-area">
- <view class="preview-stage">
- <image
- v-if="resultImage"
- class="result-image"
- :style="photoFrameStyle"
- :src="resultImage"
- mode="aspectFill"
- />
- <image v-else class="source-image" :src="localPreview" mode="aspectFit" />
-
- <view v-if="creating" class="progress-mask">
- <view class="progress-panel">
- <view class="progress-top">
- <text class="progress-title">正在处理照片</text>
- <text class="progress-value">{{ progress }}%</text>
- </view>
- <view class="progress-track">
- <view class="progress-bar" :style="{ width: `${progress}%` }"></view>
- </view>
- <text class="progress-desc">
- {{ progressStage }} · 预计还需 {{ estimatedSeconds }} 秒
- </text>
- </view>
- </view>
- </view>
-
- <view class="preview-meta">
- <text>{{ selectedSpec ? `${selectedSpec.width} × ${selectedSpec.height}px` : photoMetaText }}</text>
- <text v-if="result">已生成 {{ result.dpi || selectedSpec?.dpi || 300 }} DPI</text>
- <text v-else>制作前不会上传原图</text>
- </view>
-
- <view v-if="errorMessage" class="error-box">
- <text>{{ errorMessage }}</text>
- </view>
-
- <view class="preview-actions">
- <view v-if="result" class="primary-action" @click="saveResult">保存照片</view>
- <view class="soft-action" @click="choosePhoto">{{ result ? '制作新照片' : '更换照片' }}</view>
- </view>
- </view>
- </view>
-
- <view class="id-panel">
- <view class="card-head">
- <view>
- <text class="card-title">选择规格</text>
- <text class="card-desc">规格由后台统一配置,成品严格输出所选像素</text>
- </view>
- <view class="dpi-badge">{{ selectedSpec?.dpi || 300 }} DPI</view>
- </view>
-
- <view class="search-box">
- <view class="search-icon"></view>
- <input
- v-model="specKeyword"
- class="search-input"
- placeholder="搜索尺寸或用途"
- placeholder-class="input-placeholder"
- />
- <text v-if="specKeyword" class="search-clear" @click="specKeyword = ''">×</text>
- </view>
-
- <scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
- <view class="category-list">
- <view
- v-for="item in categories"
- :key="item.key"
- class="category-item"
- :class="{ active: activeCategory === item.key }"
- @click="activeCategory = item.key"
- >
- {{ item.name }}
- </view>
- </view>
- </scroll-view>
-
- <view v-if="filteredSpecs.length" class="spec-grid">
- <view
- v-for="item in filteredSpecs"
- :key="item.key"
- class="spec-item"
- :class="{ active: selectedSpecKey === item.key }"
- @click="selectSpec(item)"
- >
- <view class="spec-name-row">
- <text class="spec-name">{{ item.name }}</text>
- <text v-if="item.hot" class="hot-badge">常用</text>
- </view>
- <text class="spec-pixel">{{ item.width }}×{{ item.height }}px</text>
- <text class="spec-print">{{ item.print || item.print_size }}</text>
- </view>
- </view>
- <view v-else class="empty-spec">没有匹配的证件照规格</view>
- </view>
-
- <view class="id-panel">
- <view class="card-head">
- <view>
- <text class="card-title">背景与自然优化</text>
- <text class="card-desc">换色与微调不重复扣点,已生成照片可快速应用</text>
- </view>
- <text v-if="result && settingsDirty" class="dirty-badge">待应用</text>
- </view>
-
- <text class="field-label">背景颜色</text>
- <view class="background-grid">
- <view
- v-for="item in backgrounds"
- :key="item.key"
- class="background-item"
- :class="{ active: settings.background === item.color.toLowerCase() }"
- @click="selectBackground(item.color)"
- >
- <view class="color-dot" :class="{ light: item.light }" :style="{ background: item.color }">
- <view v-if="settings.background === item.color.toLowerCase()" class="check-mark"></view>
- </view>
- <text>{{ item.name }}</text>
- </view>
- </view>
-
- <text class="field-label beauty-label">画面优化</text>
- <view class="beauty-grid">
- <view
- v-for="item in beautyOptions"
- :key="item.key"
- class="beauty-item"
- :class="{ active: settings.beauty === item.key }"
- @click="selectBeauty(item.key)"
- >
- <view class="beauty-radio"><view></view></view>
- <view>
- <text class="beauty-name">{{ item.name }}</text>
- <text class="beauty-desc">{{ item.desc }}</text>
- </view>
- </view>
- </view>
-
- <view v-if="result" class="adjust-section">
- <view class="adjust-head">
- <view>
- <text class="field-label">构图微调</text>
- <text class="adjust-help">拖动后点击应用,不会重新抠图或扣点</text>
- </view>
- <text class="reset-link" @click="resetAdjustments">恢复默认</text>
- </view>
-
- <view class="slider-row">
- <view class="slider-meta">
- <text>人物大小</text>
- <text>{{ Math.round(settings.scale * 100) }}%</text>
- </view>
- <slider
- :value="settings.scale"
- :min="0.75"
- :max="1.4"
- :step="0.01"
- active-color="#168cff"
- background-color="#e6edf5"
- block-color="#168cff"
- :block-size="18"
- @changing="updateSlider('scale', $event)"
- @change="updateSlider('scale', $event)"
- />
- </view>
-
- <view class="slider-row">
- <view class="slider-meta">
- <text>水平位置</text>
- <text>{{ adjustmentText(settings.offset_x, '左', '右') }}</text>
- </view>
- <slider
- :value="settings.offset_x"
- :min="-1"
- :max="1"
- :step="0.01"
- active-color="#168cff"
- background-color="#e6edf5"
- block-color="#168cff"
- :block-size="18"
- @changing="updateSlider('offset_x', $event)"
- @change="updateSlider('offset_x', $event)"
- />
- </view>
-
- <view class="slider-row">
- <view class="slider-meta">
- <text>垂直位置</text>
- <text>{{ adjustmentText(settings.offset_y, '上', '下') }}</text>
- </view>
- <slider
- :value="settings.offset_y"
- :min="-1"
- :max="1"
- :step="0.01"
- active-color="#168cff"
- background-color="#e6edf5"
- block-color="#168cff"
- :block-size="18"
- @changing="updateSlider('offset_y', $event)"
- @change="updateSlider('offset_y', $event)"
- />
- </view>
-
- <view
- class="apply-adjust"
- :class="{ disabled: !settingsDirty || rerendering }"
- @click="applyAdjustments"
- >
- {{ rerendering ? '正在快速应用...' : '应用调整' }}
- </view>
- </view>
- </view>
- </view>
-
- <view class="bottom-bar">
- <view class="billing-copy">
- <text class="billing-label">{{ billingLabel }}</text>
- <text v-if="billingSubtext" class="billing-sub">{{ billingSubtext }}</text>
- </view>
- <view class="bottom-actions">
- <view class="history-button" @click="goHistory">历史记录</view>
- <view
- class="submit-button"
- :class="{ disabled: primaryDisabled }"
- @click="handlePrimaryAction"
- >
- <image
- v-if="!creating && !rerendering && !result"
- class="submit-icon"
- src="https://cdn.aionline.cc/ai/icons/bolt.svg"
- mode="aspectFit"
- />
- <text>{{ primaryText }}</text>
- </view>
- </view>
- </view>
- </view>
- </template>
-
- <script setup>
- import { computed, nextTick, reactive, ref } from 'vue'
- import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
- import { idPhotoApi } from '@/api/index.js'
- import { createIdPhoto } from '@/utils/upload.js'
- import { guid } from '@/utils/util.js'
- import { useUserStore } from '@/store/user.js'
- import { useApp } from '@/utils/useApp.js'
-
- const STANDARD_BACKGROUNDS = [
- { key: 'blue', name: '常用蓝', color: '#438edb' },
- { key: 'white', name: '纯白', color: '#ffffff' },
- { key: 'certificate-red', name: '证件红', color: '#e60012' },
- { key: 'government-blue', name: '政务蓝', color: '#0066cc' },
- { key: 'office-blue', name: '柔和蓝', color: '#5b9bd5' },
- { key: 'visa-blue', name: '签证蓝', color: '#99ccff' },
- { key: 'soft-white', name: '浅灰白', color: '#f5f5f5' },
- { key: 'light-gray', name: '浅灰', color: '#c8c8c8' },
- { key: 'bright-red', name: '正红', color: '#ff0000' },
- { key: 'dark-red', name: '暗红', color: '#dc143c' }
- ]
- const FALLBACK_BEAUTY = [
- { key: 'none', name: '关闭美化', desc: '保留原始肤色与细节' },
- { key: 'natural', name: '自然优化', desc: '轻微提亮、降噪和锐化' }
- ]
- const ERROR_MESSAGES = {
- NO_FACE: '没有检测到清晰人脸,请上传正面、无遮挡的照片',
- MULTIPLE_FACES: '照片中检测到多张人脸,请上传单人照片',
- FACE_TOO_SMALL: '人脸在画面中太小,请上传更清晰的半身照',
- FACE_TILTED: '头部倾斜角度较大,请上传正面平视照片',
- QUEUE_FULL: '当前制作人数较多,请稍后再试',
- ID_PHOTO_PROCESSING: '这张照片正在制作中,请勿重复提交',
- INVALID_FACE_DATA: '人脸定位失败,请更换一张清晰照片'
- }
-
- const userStore = useUserStore()
- const { toast } = useApp()
- const categories = ref([])
- const specs = ref([])
- const backgrounds = ref(mergeBackgrounds())
- const beautyOptions = ref(FALLBACK_BEAUTY)
- const billing = ref({})
- const configLoading = ref(true)
- const activeCategory = ref('common')
- const selectedSpecKey = ref('')
- const specKeyword = ref('')
- const localPreview = ref('')
- const localFileInfo = ref({})
- const result = ref(null)
- const resultImage = ref('')
- const errorMessage = ref('')
- const creating = ref(false)
- const rerendering = ref(false)
- const progress = ref(0)
- const estimatedSeconds = ref(12)
- const settingsDirty = ref(false)
- const settings = reactive({
- background: '#438edb',
- beauty: 'natural',
- scale: 1,
- offset_x: 0,
- offset_y: 0
- })
-
- let progressTimer = 0
- let progressStartedAt = 0
- let initialWorkId = ''
- let initialized = false
-
- const selectedSpec = computed(() => {
- const configured = specs.value.find((item) => item.key === selectedSpecKey.value)
- if (configured) return configured
- if (!result.value) return null
- return normalizeSpec({
- key: result.value.spec_key,
- name: result.value.spec_name,
- width: result.value.width,
- height: result.value.height,
- dpi: result.value.dpi
- })
- })
- const filteredSpecs = computed(() => {
- const keyword = specKeyword.value.trim().toLowerCase()
- return specs.value.filter((item) => {
- const categoryMatched = keyword || item.category === activeCategory.value
- const keywordMatched = !keyword || `${item.name} ${item.width} ${item.height} ${item.print || item.print_size}`.toLowerCase().includes(keyword)
- return categoryMatched && keywordMatched
- })
- })
- const quotePoints = computed(() => Number(billing.value.quote?.points || 0))
- const balancePoints = computed(() => {
- const value = billing.value.balance?.points ?? billing.value.balance?.point_balance
- return value === undefined || value === null ? Number(userStore.balance || 0) : Number(value)
- })
- const isFreeUsage = computed(() => !configLoading.value && Boolean(billing.value.quote) && quotePoints.value === 0)
- const insufficient = computed(() => (
- userStore.isLogin &&
- quotePoints.value > 0 &&
- balancePoints.value >= 0 &&
- balancePoints.value < quotePoints.value
- ))
- const primaryDisabled = computed(() => (
- creating.value ||
- rerendering.value ||
- (userStore.isLogin && configLoading.value)
- ))
- const primaryText = computed(() => {
- if (creating.value) return `制作中 ${progress.value}%`
- if (rerendering.value) return '正在应用'
- if (result.value) return '保存证件照'
- if (!userStore.isLogin) {
- if (isFreeUsage.value) return '登录后制作 · 免费'
- return quotePoints.value > 0 ? `登录后制作 · ${quotePoints.value}点` : '登录后制作'
- }
- if (configLoading.value) return '正在读取配置'
- if (isFreeUsage.value) return '免费制作'
- return '立即生成'
- })
- const billingLabel = computed(() => {
- if (isFreeUsage.value) return '免费使用'
- if (configLoading.value) return '正在读取价格'
- if (!userStore.isLogin) return quotePoints.value ? `${quotePoints.value}点 / 次` : '登录后制作'
- return `本次 ${quotePoints.value}点`
- })
- const billingSubtext = computed(() => {
- if (isFreeUsage.value) return '制作成功后可直接保存'
- if (!userStore.isLogin || quotePoints.value <= 0) return ''
- return insufficient.value ? `余额 ${balancePoints.value}点,点数不足` : `当前余额 ${balancePoints.value}点`
- })
- const previewDescription = computed(() => {
- if (result.value) return '已完成,可继续换底色或微调构图'
- if (localPreview.value) return '确认规格与背景后开始制作'
- return '上传清晰正面照片可获得更稳定的效果'
- })
- const photoMetaText = computed(() => {
- const width = Number(localFileInfo.value.width || 0)
- const height = Number(localFileInfo.value.height || 0)
- return width && height ? `原图 ${width} × ${height}px` : '原图已就绪'
- })
- const progressStage = computed(() => {
- if (progress.value < 25) return '正在上传原图'
- if (progress.value < 72) return '正在识别人像并抠图'
- if (progress.value < 94) return '正在换色与标准裁剪'
- return '正在保存高清成品'
- })
- const photoRatio = computed(() => {
- const width = Number(result.value?.width || selectedSpec.value?.width || 295)
- const height = Number(result.value?.height || selectedSpec.value?.height || 413)
- return width > 0 && height > 0 ? width / height : 295 / 413
- })
- const photoFrameStyle = computed(() => {
- const maxWidth = 590
- const maxHeight = 620
- let width = maxWidth
- let height = width / photoRatio.value
- if (height > maxHeight) {
- height = maxHeight
- width = height * photoRatio.value
- }
- return {
- width: `${Math.round(width)}rpx`,
- height: `${Math.round(height)}rpx`
- }
- })
- onLoad((options = {}) => {
- initialWorkId = String(options.work || '')
- initialize()
- })
-
- onShow(() => {
- if (!initialized) return
- if (userStore.isLogin) {
- refreshBalance()
- if (initialWorkId && !result.value) loadDetail(initialWorkId)
- }
- loadConfig(false)
- })
-
- onUnload(() => {
- clearProgress()
- })
-
- async function initialize() {
- await loadConfig(true)
- if (initialWorkId) await loadDetail(initialWorkId)
- initialized = true
- }
-
- async function loadConfig(showError = false) {
- configLoading.value = true
- try {
- const res = await idPhotoApi.getConfig({ image_count: 1 })
- const data = res.data || {}
- categories.value = Array.isArray(data.categories) ? data.categories : []
- specs.value = Array.isArray(data.specs) ? data.specs.map(normalizeSpec) : []
- backgrounds.value = mergeBackgrounds(data.backgrounds)
- beautyOptions.value = Array.isArray(data.beauty_options) && data.beauty_options.length
- ? data.beauty_options
- : FALLBACK_BEAUTY
- billing.value = data.billing || {}
-
- if (!activeCategory.value || !categories.value.some((item) => item.key === activeCategory.value)) {
- activeCategory.value = categories.value[0]?.key || 'common'
- }
- if (
- !selectedSpecKey.value ||
- (!result.value && !specs.value.some((item) => item.key === selectedSpecKey.value))
- ) {
- selectedSpecKey.value = specs.value.find((item) => item.key === 'one-inch')?.key || specs.value[0]?.key || ''
- }
- } catch (e) {
- if (showError) toast(e.msg || '证件照配置加载失败')
- } finally {
- configLoading.value = false
- }
- }
-
- function normalizeSpec(item = {}) {
- return {
- ...item,
- key: item.key || item.spec_key,
- width: Number(item.width || 0),
- height: Number(item.height || 0),
- dpi: Number(item.dpi || 300),
- hot: Number(item.hot ?? item.is_hot ?? 0)
- }
- }
-
- function mergeBackgrounds(configured = []) {
- const list = STANDARD_BACKGROUNDS.concat(Array.isArray(configured) ? configured : [])
- const used = new Set()
- return list
- .map((item, index) => ({
- ...item,
- key: item.key || `background-${index}`,
- color: String(item.color || '').toLowerCase(),
- light: isLightBackground(item.color)
- }))
- .filter((item) => {
- if (!/^#[0-9a-f]{6}$/.test(item.color) || used.has(item.color)) return false
- used.add(item.color)
- return true
- })
- }
-
- function isLightBackground(color) {
- const value = String(color || '').replace('#', '')
- if (!/^[0-9a-fA-F]{6}$/.test(value)) return false
- const red = parseInt(value.slice(0, 2), 16)
- const green = parseInt(value.slice(2, 4), 16)
- const blue = parseInt(value.slice(4, 6), 16)
- return red * 0.299 + green * 0.587 + blue * 0.114 > 185
- }
-
- async function loadDetail(photoOpenId) {
- if (!requireLogin()) return
- try {
- uni.showLoading({ mask: true, title: '加载照片...' })
- const res = await idPhotoApi.getDetail({ photo_open_id: photoOpenId })
- await applyResultData(res.data || {})
- } catch (e) {
- toast(e.msg || '照片记录加载失败')
- } finally {
- uni.hideLoading()
- }
- }
-
- function choosePhoto() {
- if (creating.value || rerendering.value) return
- uni.chooseImage({
- count: 1,
- sizeType: ['original'],
- sourceType: ['album', 'camera'],
- success: async (res) => {
- const file = res.tempFiles?.[0] || {}
- if (Number(file.size || 0) > 10 * 1024 * 1024) {
- toast('图片大小不能超过 10MB')
- return
- }
- const path = res.tempFilePaths?.[0]
- if (!path) return
- let imageInfo = {}
- try {
- imageInfo = await getImageInfo(path)
- } catch (e) {}
- localPreview.value = path
- localFileInfo.value = {
- name: file.name || '',
- size: Number(file.size || 0),
- width: Number(imageInfo.width || 0),
- height: Number(imageInfo.height || 0)
- }
- result.value = null
- resultImage.value = ''
- errorMessage.value = ''
- settingsDirty.value = false
- settings.scale = 1
- settings.offset_x = 0
- settings.offset_y = 0
- }
- })
- }
-
- function getImageInfo(src) {
- return new Promise((resolve, reject) => {
- uni.getImageInfo({ src, success: resolve, fail: reject })
- })
- }
-
- function selectSpec(item) {
- if (result.value && result.value.spec_key !== item.key) {
- toast('已生成照片的规格不可直接修改,请选择“制作新照片”')
- return
- }
- selectedSpecKey.value = item.key
- }
-
- function selectBackground(color) {
- settings.background = String(color || '#438edb').toLowerCase()
- markDirty()
- }
-
- function selectBeauty(key) {
- settings.beauty = key
- markDirty()
- }
-
- function updateSlider(key, event) {
- settings[key] = Number(event.detail.value)
- markDirty()
- }
-
- function resetAdjustments() {
- settings.scale = 1
- settings.offset_x = 0
- settings.offset_y = 0
- markDirty()
- }
-
- function markDirty() {
- if (result.value) settingsDirty.value = true
- }
-
- function adjustmentText(value, negativeLabel, positiveLabel) {
- const number = Number(value || 0)
- if (Math.abs(number) < 0.01) return '居中'
- return `${number < 0 ? negativeLabel : positiveLabel} ${Math.round(Math.abs(number) * 100)}%`
- }
-
- function handlePrimaryAction() {
- if (primaryDisabled.value) return
- if (result.value) {
- saveResult()
- return
- }
- if (!requireLogin()) return
- if (!localPreview.value) {
- choosePhoto()
- return
- }
- createPhoto()
- }
-
- async function createPhoto() {
- if (!requireLogin()) return
- if (!selectedSpec.value) {
- toast('请选择证件照规格')
- return
- }
- if (insufficient.value) {
- showInsufficientModal()
- return
- }
-
- creating.value = true
- errorMessage.value = ''
- startProgress()
- try {
- const res = await createIdPhoto(localPreview.value, {
- request_id: guid(),
- spec_key: selectedSpec.value.key,
- background: settings.background,
- beauty: settings.beauty,
- scale: settings.scale,
- offset_x: settings.offset_x,
- offset_y: settings.offset_y
- })
- progress.value = 100
- estimatedSeconds.value = 0
- await applyResultData(res.data || {})
- refreshBalance()
- toast('证件照制作完成')
- } catch (e) {
- handleApiError(e, '证件照制作失败,请稍后重试')
- } finally {
- creating.value = false
- setTimeout(clearProgress, 260)
- }
- }
-
- async function applyAdjustments() {
- if (!result.value || !settingsDirty.value || rerendering.value) return
- if (!requireLogin()) return
- rerendering.value = true
- errorMessage.value = ''
- try {
- const res = await idPhotoApi.rerender({
- photo_open_id: result.value.photo_open_id,
- background: settings.background,
- beauty: settings.beauty,
- scale: settings.scale,
- offset_x: settings.offset_x,
- offset_y: settings.offset_y
- })
- await applyResultData(res.data || {}, true)
- settingsDirty.value = false
- await scrollToPreview()
- toast('调整已应用')
- syncPersistedResult(result.value.photo_open_id)
- } catch (e) {
- handleApiError(e, '应用调整失败,请稍后重试')
- } finally {
- rerendering.value = false
- }
- }
-
- async function applyResultData(data = {}, keepSelectedSpec = false) {
- result.value = { ...(result.value || {}), ...data }
- resultImage.value = data.result_url || data.answer || data.cover || resultImage.value
- localPreview.value = data.source_url || data.original || localPreview.value
- if (!keepSelectedSpec && data.spec_key) selectedSpecKey.value = data.spec_key
- if (data.background) settings.background = String(data.background).toLowerCase()
- if (data.beauty) settings.beauty = data.beauty
- settings.scale = Number(data.scale ?? settings.scale ?? 1)
- settings.offset_x = Number(data.offset_x ?? settings.offset_x ?? 0)
- settings.offset_y = Number(data.offset_y ?? settings.offset_y ?? 0)
- settingsDirty.value = false
- }
-
- function handleApiError(error, fallback) {
- if (Number(error?.code) === 1009) {
- userStore.clear()
- requireLogin()
- return
- }
- const errorCode = error?.data?.error || error?.error
- errorMessage.value = ERROR_MESSAGES[errorCode] || error?.msg || fallback
- toast(errorMessage.value)
- }
-
- function startProgress() {
- clearProgress()
- progress.value = 6
- estimatedSeconds.value = 12
- progressStartedAt = Date.now()
- progressTimer = setInterval(() => {
- const elapsed = Date.now() - progressStartedAt
- const next = Math.round(8 + (1 - Math.exp(-elapsed / 6500)) * 88)
- progress.value = Math.min(96, Math.max(progress.value, next))
- estimatedSeconds.value = Math.max(1, Math.ceil((13000 - elapsed) / 1000))
- }, 360)
- }
-
- function clearProgress() {
- clearInterval(progressTimer)
- progressTimer = 0
- if (!creating.value) {
- progress.value = 0
- estimatedSeconds.value = 12
- }
- }
-
- function requireLogin() {
- if (userStore.isLogin) return true
- let url = '/pages/login/login'
- // #ifdef H5
- url += `?redirect=${encodeURIComponent(location.pathname + location.search)}`
- // #endif
- uni.navigateTo({ url })
- return false
- }
-
- function showInsufficientModal() {
- uni.showModal({
- title: '点数不足',
- content: `当前余额 ${balancePoints.value} 点,本次制作需要 ${quotePoints.value} 点。`,
- confirmText: '去充值',
- success: (res) => {
- if (res.confirm) uni.navigateTo({ url: '/pages/recharge/recharge' })
- }
- })
- }
-
- function goHistory() {
- if (!requireLogin()) return
- uni.navigateTo({ url: '/pages/tool/id-photo-history' })
- }
-
- async function scrollToPreview() {
- await nextTick()
- uni.pageScrollTo({
- selector: '#id-photo-preview',
- duration: 320
- })
- }
-
- async function syncPersistedResult(photoOpenId, attempts = 8) {
- if (!photoOpenId) return ''
- for (let index = 0; index < attempts; index += 1) {
- if (index) await delay(700)
- try {
- const res = await idPhotoApi.getDetail({ photo_open_id: photoOpenId })
- const data = res.data || {}
- const url = data.result_url || ''
- if (url && !url.startsWith('data:')) {
- result.value = { ...(result.value || {}), ...data }
- resultImage.value = url
- return url
- }
- } catch (e) {}
- }
- return ''
- }
-
- async function saveResult() {
- if (!resultImage.value) return
- let url = resultImage.value
- if (url.startsWith('data:')) {
- const persisted = await syncPersistedResult(result.value?.photo_open_id)
- if (persisted) url = persisted
- }
-
- // #ifdef H5
- try {
- let downloadUrl = url
- let objectUrl = ''
- if (!url.startsWith('data:')) {
- const response = await fetch(url)
- if (!response.ok) throw new Error('download failed')
- objectUrl = URL.createObjectURL(await response.blob())
- downloadUrl = objectUrl
- }
- const link = document.createElement('a')
- link.href = downloadUrl
- link.download = `${result.value?.spec_name || '证件照'}.jpg`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
- toast('已开始下载')
- } catch (e) {
- window.open(url, '_blank')
- toast('已打开高清照片,可长按保存')
- }
- // #endif
-
- // #ifndef H5
- try {
- uni.showLoading({ mask: true, title: '正在保存...' })
- const filePath = url.startsWith('data:') ? await writeBase64Image(url) : await downloadImage(url)
- await saveToAlbum(filePath)
- toast('已保存到系统相册')
- } catch (e) {
- toast('保存失败,请检查相册权限后重试')
- } finally {
- uni.hideLoading()
- }
- // #endif
- }
-
- function downloadImage(url) {
- return new Promise((resolve, reject) => {
- uni.downloadFile({
- url,
- success: (res) => res.statusCode === 200 ? resolve(res.tempFilePath) : reject(res),
- fail: reject
- })
- })
- }
-
- function saveToAlbum(filePath) {
- return new Promise((resolve, reject) => {
- uni.saveImageToPhotosAlbum({ filePath, success: resolve, fail: reject })
- })
- }
-
- function writeBase64Image(dataUrl) {
- return new Promise((resolve, reject) => {
- // #ifdef MP-WEIXIN
- const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/)
- if (!match) {
- reject(new Error('图片数据无效'))
- return
- }
- const filePath = `${wx.env.USER_DATA_PATH}/id-photo-${Date.now()}.${match[1] === 'jpeg' ? 'jpg' : match[1]}`
- uni.getFileSystemManager().writeFile({
- filePath,
- data: match[2],
- encoding: 'base64',
- success: () => resolve(filePath),
- fail: reject
- })
- // #endif
- // #ifndef MP-WEIXIN
- reject(new Error('请稍后等待高清图片保存完成'))
- // #endif
- })
- }
-
- function delay(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms))
- }
-
- function refreshBalance() {
- userStore.fetchBalance()
- .then((points) => {
- billing.value = {
- ...billing.value,
- balance: {
- ...(billing.value.balance || {}),
- points
- }
- }
- })
- .catch(() => {})
- }
- </script>
-
- <style lang="scss" scoped>
- .page {
- min-height: 100vh;
- color: #172033;
- background:
- radial-gradient(circle at 100% 6%, rgba(67, 190, 255, 0.13), transparent 25%),
- #f3f7fb;
- }
-
- .content {
- padding: 24rpx 24rpx calc(220rpx + env(safe-area-inset-bottom));
- }
-
- .hero-card {
- position: relative;
- min-height: 300rpx;
- padding: 34rpx 238rpx 32rpx 30rpx;
- border-radius: 28rpx;
- background: linear-gradient(145deg, #e9f4ff 0%, #f5faff 56%, #e9fbf7 100%);
- box-shadow: 0 14rpx 36rpx rgba(43, 80, 123, 0.08);
- overflow: hidden;
- box-sizing: border-box;
- }
-
- .hero-copy {
- position: relative;
- z-index: 2;
- }
-
- .eyebrow {
- display: block;
- color: #0b8cff;
- font-size: 20rpx;
- font-weight: 900;
- letter-spacing: 3rpx;
- }
-
- .hero-title {
- display: block;
- margin-top: 14rpx;
- color: #071a37;
- font-size: 38rpx;
- font-weight: 900;
- line-height: 1.28;
- }
-
- .hero-desc {
- display: block;
- margin-top: 12rpx;
- color: #5d7290;
- font-size: 23rpx;
- line-height: 1.55;
- }
-
- .hero-tags {
- margin-top: 18rpx;
- display: flex;
- gap: 10rpx;
- }
-
- .hero-tags text {
- padding: 6rpx 12rpx;
- border-radius: 999rpx;
- color: #2770a9;
- background: rgba(255, 255, 255, 0.72);
- font-size: 19rpx;
- font-weight: 800;
- }
-
- .hero-image {
- position: absolute;
- z-index: 1;
- top: 22rpx;
- right: 30rpx;
- width: 180rpx;
- height: 252rpx;
- border: 8rpx solid rgba(255, 255, 255, 0.86);
- border-radius: 18rpx;
- box-shadow: 0 18rpx 34rpx rgba(26, 76, 118, 0.18);
- }
-
- .id-panel {
- width: 100%;
- margin-top: 22rpx;
- padding: 28rpx;
- border-radius: 26rpx;
- background: #ffffff;
- box-shadow: 0 10rpx 34rpx rgba(39, 70, 112, 0.06);
- box-sizing: border-box;
- }
-
- .card-head {
- display: flex;
- align-items: flex-start;
- gap: 20rpx;
- }
-
- .card-head > view:first-child {
- min-width: 0;
- flex: 1;
- }
-
- .card-title {
- display: block;
- color: #12213a;
- font-size: 31rpx;
- font-weight: 900;
- line-height: 1.25;
- }
-
- .card-desc {
- display: block;
- margin-top: 7rpx;
- color: #8794a7;
- font-size: 22rpx;
- line-height: 1.45;
- }
-
- .history-link {
- color: #168cff;
- font-size: 23rpx;
- font-weight: 800;
- line-height: 42rpx;
- flex-shrink: 0;
- }
-
- .upload-zone {
- margin-top: 24rpx;
- padding: 42rpx 22rpx 24rpx;
- border: 2rpx dashed #7bbcff;
- border-radius: 24rpx;
- background: #f3f9ff;
- text-align: center;
- }
-
- .upload-icon {
- width: 92rpx;
- height: 92rpx;
- margin: 0 auto;
- border-radius: 26rpx;
- background: #ffffff;
- box-shadow: 0 10rpx 26rpx rgba(22, 140, 255, 0.12);
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .upload-arrow {
- position: relative;
- width: 32rpx;
- height: 28rpx;
- border: 5rpx solid #168cff;
- border-top: 0;
- border-radius: 4rpx;
- }
-
- .upload-arrow::before {
- content: '';
- position: absolute;
- left: 11rpx;
- top: -20rpx;
- width: 6rpx;
- height: 24rpx;
- border-radius: 4rpx;
- background: #168cff;
- }
-
- .upload-arrow::after {
- content: '';
- position: absolute;
- left: 5rpx;
- top: -20rpx;
- width: 14rpx;
- height: 14rpx;
- border-top: 5rpx solid #168cff;
- border-left: 5rpx solid #168cff;
- transform: rotate(45deg);
- }
-
- .upload-title {
- display: block;
- margin-top: 22rpx;
- color: #10223d;
- font-size: 30rpx;
- font-weight: 900;
- }
-
- .upload-desc {
- display: block;
- margin-top: 10rpx;
- color: #718198;
- font-size: 22rpx;
- }
-
- .upload-button {
- width: 210rpx;
- height: 72rpx;
- margin: 26rpx auto 0;
- border-radius: 999rpx;
- color: #ffffff;
- background: linear-gradient(135deg, #0b8cff, #35b1ff);
- box-shadow: 0 12rpx 26rpx rgba(11, 140, 255, 0.24);
- font-size: 26rpx;
- font-weight: 900;
- line-height: 72rpx;
- }
-
- .photo-tips {
- margin-top: 34rpx;
- display: flex;
- justify-content: center;
- gap: 40rpx;
- }
-
- .tip-item {
- color: #65758c;
- font-size: 21rpx;
- display: flex;
- align-items: center;
- gap: 8rpx;
- }
-
- .tip-dot {
- width: 12rpx;
- height: 12rpx;
- border: 3rpx solid #18ad73;
- border-radius: 50%;
- }
-
- .preview-area {
- margin-top: 24rpx;
- }
-
- .preview-stage {
- position: relative;
- min-height: 620rpx;
- border-radius: 22rpx;
- background-color: #eef4f9;
- background-image: repeating-linear-gradient(
- 135deg,
- rgba(255, 255, 255, 0.8) 0,
- rgba(255, 255, 255, 0.8) 18rpx,
- rgba(224, 234, 243, 0.8) 18rpx,
- rgba(224, 234, 243, 0.8) 36rpx
- );
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- }
-
- .source-image {
- width: 100%;
- height: 620rpx;
- }
-
- .result-image {
- display: block;
- border-radius: 4rpx;
- }
-
- .progress-mask {
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- padding: 30rpx;
- background: rgba(5, 20, 40, 0.5);
- backdrop-filter: blur(5px);
- display: flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- }
-
- .progress-panel {
- width: 100%;
- padding: 28rpx;
- border-radius: 22rpx;
- background: rgba(255, 255, 255, 0.96);
- box-sizing: border-box;
- }
-
- .progress-top {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
-
- .progress-title {
- color: #11233e;
- font-size: 27rpx;
- font-weight: 900;
- }
-
- .progress-value {
- color: #168cff;
- font-size: 29rpx;
- font-weight: 900;
- }
-
- .progress-track {
- height: 14rpx;
- margin-top: 20rpx;
- border-radius: 999rpx;
- background: #e6eff8;
- overflow: hidden;
- }
-
- .progress-bar {
- height: 100%;
- border-radius: inherit;
- background: linear-gradient(90deg, #168cff, #42c0ff);
- transition: width 0.3s ease;
- }
-
- .progress-desc {
- display: block;
- margin-top: 16rpx;
- color: #758399;
- font-size: 22rpx;
- }
-
- .preview-meta {
- margin-top: 15rpx;
- color: #7b899b;
- font-size: 21rpx;
- display: flex;
- justify-content: space-between;
- gap: 20rpx;
- }
-
- .error-box {
- margin-top: 16rpx;
- padding: 18rpx 20rpx;
- border-radius: 16rpx;
- color: #c43e3e;
- background: #fff1f1;
- font-size: 22rpx;
- line-height: 1.45;
- }
-
- .preview-actions {
- margin-top: 22rpx;
- display: flex;
- gap: 14rpx;
- }
-
- .primary-action,
- .soft-action {
- height: 72rpx;
- border-radius: 999rpx;
- font-size: 23rpx;
- font-weight: 900;
- line-height: 72rpx;
- text-align: center;
- }
-
- .primary-action {
- flex: 1;
- color: #ffffff;
- background: #168cff;
- }
-
- .soft-action {
- flex: 1;
- color: #48617e;
- background: #eef4fa;
- }
-
- .dpi-badge,
- .dirty-badge {
- padding: 8rpx 13rpx;
- border-radius: 999rpx;
- color: #168cff;
- background: #edf7ff;
- font-size: 19rpx;
- font-weight: 900;
- flex-shrink: 0;
- }
-
- .dirty-badge {
- color: #a76108;
- background: #fff5dc;
- }
-
- .search-box {
- height: 72rpx;
- margin-top: 22rpx;
- padding: 0 20rpx;
- border-radius: 16rpx;
- background: #f4f7fb;
- display: flex;
- align-items: center;
- }
-
- .search-icon {
- position: relative;
- width: 22rpx;
- height: 22rpx;
- margin-right: 15rpx;
- border: 3rpx solid #91a0b4;
- border-radius: 50%;
- }
-
- .search-icon::after {
- content: '';
- position: absolute;
- right: -9rpx;
- bottom: -6rpx;
- width: 11rpx;
- height: 3rpx;
- background: #91a0b4;
- transform: rotate(45deg);
- }
-
- .search-input {
- min-width: 0;
- height: 72rpx;
- flex: 1;
- color: #172033;
- font-size: 24rpx;
- }
-
- .input-placeholder {
- color: #9aa6b7;
- }
-
- .search-clear {
- width: 48rpx;
- color: #9aa6b7;
- font-size: 38rpx;
- line-height: 48rpx;
- text-align: right;
- }
-
- .category-scroll {
- width: 100%;
- margin-top: 18rpx;
- white-space: nowrap;
- }
-
- .category-list {
- display: flex;
- gap: 12rpx;
- }
-
- .category-item {
- height: 58rpx;
- padding: 0 22rpx;
- border-radius: 999rpx;
- color: #627187;
- background: #f4f7fb;
- font-size: 23rpx;
- font-weight: 800;
- line-height: 58rpx;
- flex-shrink: 0;
- }
-
- .category-item.active {
- color: #ffffff;
- background: #13213a;
- }
-
- .spec-grid {
- margin-top: 18rpx;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 14rpx;
- }
-
- .spec-item {
- min-height: 132rpx;
- padding: 18rpx;
- border: 2rpx solid #e5ebf2;
- border-radius: 18rpx;
- background: #ffffff;
- box-sizing: border-box;
- }
-
- .spec-item.active {
- border-color: #168cff;
- background: #edf7ff;
- box-shadow: 0 8rpx 18rpx rgba(22, 140, 255, 0.08);
- }
-
- .spec-name-row {
- display: flex;
- align-items: center;
- gap: 8rpx;
- }
-
- .spec-name {
- color: #172033;
- font-size: 25rpx;
- font-weight: 900;
- }
-
- .hot-badge {
- padding: 3rpx 7rpx;
- border-radius: 999rpx;
- color: #ff6d24;
- background: #fff1e7;
- font-size: 17rpx;
- font-weight: 800;
- }
-
- .spec-pixel,
- .spec-print {
- display: block;
- margin-top: 9rpx;
- color: #718198;
- font-size: 20rpx;
- }
-
- .spec-print {
- margin-top: 4rpx;
- color: #9aa5b5;
- }
-
- .empty-spec {
- padding: 48rpx 0 24rpx;
- color: #95a1b2;
- font-size: 23rpx;
- text-align: center;
- }
-
- .field-label {
- display: block;
- margin-top: 26rpx;
- color: #41516a;
- font-size: 23rpx;
- font-weight: 900;
- }
-
- .background-grid {
- margin-top: 16rpx;
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 14rpx;
- }
-
- .background-item {
- min-height: 112rpx;
- padding: 14rpx 8rpx 12rpx;
- border: 2rpx solid #e5ebf2;
- border-radius: 18rpx;
- color: #58677e;
- background: #ffffff;
- font-size: 20rpx;
- font-weight: 800;
- text-align: center;
- box-sizing: border-box;
- }
-
- .background-item.active {
- color: #168cff;
- border-color: #168cff;
- background: #f1f8ff;
- }
-
- .color-dot {
- position: relative;
- width: 50rpx;
- height: 50rpx;
- margin: 0 auto 9rpx;
- border: 2rpx solid rgba(15, 23, 42, 0.1);
- border-radius: 50%;
- box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.35);
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .check-mark {
- position: relative;
- width: 16rpx;
- height: 9rpx;
- border-left: 4rpx solid #ffffff;
- border-bottom: 4rpx solid #ffffff;
- transform: translateY(-2rpx) rotate(-45deg);
- filter: drop-shadow(0 1rpx 1rpx rgba(0, 0, 0, 0.4));
- }
-
- .color-dot.light .check-mark {
- border-color: #168cff;
- filter: none;
- }
-
- .beauty-label {
- margin-top: 28rpx;
- }
-
- .beauty-grid {
- margin-top: 16rpx;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 14rpx;
- }
-
- .beauty-item {
- min-height: 102rpx;
- padding: 16rpx;
- border: 2rpx solid #e5ebf2;
- border-radius: 18rpx;
- display: flex;
- align-items: flex-start;
- gap: 12rpx;
- box-sizing: border-box;
- }
-
- .beauty-item.active {
- border-color: #168cff;
- background: #edf7ff;
- }
-
- .beauty-radio {
- width: 25rpx;
- height: 25rpx;
- margin-top: 3rpx;
- border: 3rpx solid #b6c1cf;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- }
-
- .beauty-item.active .beauty-radio {
- border-color: #168cff;
- }
-
- .beauty-item.active .beauty-radio view {
- width: 13rpx;
- height: 13rpx;
- border-radius: 50%;
- background: #168cff;
- }
-
- .beauty-name,
- .beauty-desc {
- display: block;
- }
-
- .beauty-name {
- color: #26364d;
- font-size: 23rpx;
- font-weight: 900;
- }
-
- .beauty-desc {
- margin-top: 7rpx;
- color: #8995a7;
- font-size: 19rpx;
- line-height: 1.35;
- }
-
- .adjust-section {
- margin-top: 28rpx;
- padding-top: 4rpx;
- border-top: 2rpx solid #edf1f5;
- }
-
- .adjust-head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 20rpx;
- }
-
- .adjust-help {
- display: block;
- margin-top: 6rpx;
- color: #919daf;
- font-size: 20rpx;
- }
-
- .reset-link {
- margin-top: 26rpx;
- color: #168cff;
- font-size: 21rpx;
- font-weight: 800;
- flex-shrink: 0;
- }
-
- .slider-row {
- margin-top: 22rpx;
- }
-
- .slider-meta {
- padding: 0 2rpx;
- color: #5f6e83;
- font-size: 21rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
-
- .slider-row slider {
- margin: 8rpx 0 0;
- }
-
- .apply-adjust {
- height: 74rpx;
- margin-top: 20rpx;
- border-radius: 999rpx;
- color: #ffffff;
- background: #168cff;
- font-size: 25rpx;
- font-weight: 900;
- line-height: 74rpx;
- text-align: center;
- }
-
- .apply-adjust.disabled {
- color: #a0adbd;
- background: #edf1f5;
- }
-
- .bottom-bar {
- position: fixed;
- z-index: 80;
- left: 0;
- right: 0;
- bottom: 0;
- padding: 16rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
- border-top: 2rpx solid rgba(224, 232, 241, 0.9);
- background: rgba(255, 255, 255, 0.97);
- box-shadow: 0 -12rpx 32rpx rgba(35, 61, 96, 0.09);
- box-sizing: border-box;
- }
-
- .billing-copy {
- min-height: 34rpx;
- margin-bottom: 14rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20rpx;
- }
-
- .billing-label,
- .billing-sub {
- display: block;
- }
-
- .billing-label {
- min-width: 0;
- flex: 1;
- color: #162640;
- font-size: 23rpx;
- font-weight: 900;
- }
-
- .billing-sub {
- flex-shrink: 0;
- color: #8491a4;
- font-size: 21rpx;
- line-height: 1.3;
- }
-
- .bottom-actions {
- display: flex;
- gap: 22rpx;
- }
-
- .history-button,
- .submit-button {
- height: 86rpx;
- border-radius: 999rpx;
- font-size: 30rpx;
- font-weight: 900;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .history-button {
- width: 210rpx;
- flex-shrink: 0;
- color: #168cff;
- background: #eef7ff;
- }
-
- .submit-button {
- flex: 1;
- color: #ffffff;
- background: linear-gradient(135deg, #0b8cff, #39b7ff);
- box-shadow: 0 12rpx 26rpx rgba(11, 140, 255, 0.24);
- }
-
- .submit-button.disabled {
- color: #95a6b8;
- background: #dfe9f3;
- box-shadow: none;
- }
-
- .submit-icon {
- width: 25rpx;
- height: 25rpx;
- margin-right: 8rpx;
- flex-shrink: 0;
- }
-
- @media (min-width: 768px) {
- .content {
- width: 720px;
- margin: 0 auto;
- box-sizing: border-box;
- }
-
- .bottom-bar {
- left: 50%;
- width: 720px;
- transform: translateX(-50%);
- border-left: 1px solid #e6edf5;
- border-right: 1px solid #e6edf5;
- }
- }
- </style>
|