Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 

1734 linhas
42 KiB

  1. <template>
  2. <view class="page">
  3. <view class="content">
  4. <view class="hero-card">
  5. <view class="hero-copy">
  6. <text class="eyebrow">SMART ID PHOTO</text>
  7. <text class="hero-title">一张自拍,制作标准证件照</text>
  8. <text class="hero-desc">上传一张清晰正面照,自动换好底色、裁成所需尺寸,几秒获得可直接下载使用的高清证件照。</text>
  9. <view class="hero-tags">
  10. <text>300 DPI</text>
  11. <text>快速换底色</text>
  12. </view>
  13. </view>
  14. <image
  15. class="hero-image"
  16. src="https://cdn.aionline.cc/ai/anime/id-photo-demo.jpg"
  17. mode="aspectFill"
  18. />
  19. </view>
  20. <view id="id-photo-preview" class="id-panel preview-card">
  21. <view class="card-head">
  22. <view>
  23. <text class="card-title">照片预览</text>
  24. <text class="card-desc">{{ previewDescription }}</text>
  25. </view>
  26. <view class="history-link" @click="goHistory">历史记录</view>
  27. </view>
  28. <view v-if="!localPreview" class="upload-zone" @click="choosePhoto">
  29. <view class="upload-icon">
  30. <view class="upload-arrow"></view>
  31. </view>
  32. <text class="upload-title">点击上传一张照片</text>
  33. <text class="upload-desc">支持 JPG、PNG、WebP,不超过 10MB</text>
  34. <view class="upload-button">选择照片</view>
  35. <view class="photo-tips">
  36. <view class="tip-item"><view class="tip-dot"></view><text>正面平视</text></view>
  37. <view class="tip-item"><view class="tip-dot"></view><text>光线均匀</text></view>
  38. <view class="tip-item"><view class="tip-dot"></view><text>单人半身</text></view>
  39. </view>
  40. </view>
  41. <view v-else class="preview-area">
  42. <view class="preview-stage">
  43. <image
  44. v-if="resultImage"
  45. class="result-image"
  46. :style="photoFrameStyle"
  47. :src="resultImage"
  48. mode="aspectFill"
  49. />
  50. <image v-else class="source-image" :src="localPreview" mode="aspectFit" />
  51. <view v-if="creating" class="progress-mask">
  52. <view class="progress-panel">
  53. <view class="progress-top">
  54. <text class="progress-title">正在处理照片</text>
  55. <text class="progress-value">{{ progress }}%</text>
  56. </view>
  57. <view class="progress-track">
  58. <view class="progress-bar" :style="{ width: `${progress}%` }"></view>
  59. </view>
  60. <text class="progress-desc">
  61. {{ progressStage }} · 预计还需 {{ estimatedSeconds }} 秒
  62. </text>
  63. </view>
  64. </view>
  65. </view>
  66. <view class="preview-meta">
  67. <text>{{ selectedSpec ? `${selectedSpec.width} × ${selectedSpec.height}px` : photoMetaText }}</text>
  68. <text v-if="result">已生成 {{ result.dpi || selectedSpec?.dpi || 300 }} DPI</text>
  69. <text v-else>制作前不会上传原图</text>
  70. </view>
  71. <view v-if="errorMessage" class="error-box">
  72. <text>{{ errorMessage }}</text>
  73. </view>
  74. <view class="preview-actions">
  75. <view v-if="result" class="primary-action" @click="saveResult">保存照片</view>
  76. <view class="soft-action" @click="choosePhoto">{{ result ? '制作新照片' : '更换照片' }}</view>
  77. </view>
  78. </view>
  79. </view>
  80. <view class="id-panel">
  81. <view class="card-head">
  82. <view>
  83. <text class="card-title">选择规格</text>
  84. <text class="card-desc">规格由后台统一配置,成品严格输出所选像素</text>
  85. </view>
  86. <view class="dpi-badge">{{ selectedSpec?.dpi || 300 }} DPI</view>
  87. </view>
  88. <view class="search-box">
  89. <view class="search-icon"></view>
  90. <input
  91. v-model="specKeyword"
  92. class="search-input"
  93. placeholder="搜索尺寸或用途"
  94. placeholder-class="input-placeholder"
  95. />
  96. <text v-if="specKeyword" class="search-clear" @click="specKeyword = ''">×</text>
  97. </view>
  98. <scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
  99. <view class="category-list">
  100. <view
  101. v-for="item in categories"
  102. :key="item.key"
  103. class="category-item"
  104. :class="{ active: activeCategory === item.key }"
  105. @click="activeCategory = item.key"
  106. >
  107. {{ item.name }}
  108. </view>
  109. </view>
  110. </scroll-view>
  111. <view v-if="filteredSpecs.length" class="spec-grid">
  112. <view
  113. v-for="item in filteredSpecs"
  114. :key="item.key"
  115. class="spec-item"
  116. :class="{ active: selectedSpecKey === item.key }"
  117. @click="selectSpec(item)"
  118. >
  119. <view class="spec-name-row">
  120. <text class="spec-name">{{ item.name }}</text>
  121. <text v-if="item.hot" class="hot-badge">常用</text>
  122. </view>
  123. <text class="spec-pixel">{{ item.width }}×{{ item.height }}px</text>
  124. <text class="spec-print">{{ item.print || item.print_size }}</text>
  125. </view>
  126. </view>
  127. <view v-else class="empty-spec">没有匹配的证件照规格</view>
  128. </view>
  129. <view class="id-panel">
  130. <view class="card-head">
  131. <view>
  132. <text class="card-title">背景与自然优化</text>
  133. <text class="card-desc">换色与微调不重复扣点,已生成照片可快速应用</text>
  134. </view>
  135. <text v-if="result && settingsDirty" class="dirty-badge">待应用</text>
  136. </view>
  137. <text class="field-label">背景颜色</text>
  138. <view class="background-grid">
  139. <view
  140. v-for="item in backgrounds"
  141. :key="item.key"
  142. class="background-item"
  143. :class="{ active: settings.background === item.color.toLowerCase() }"
  144. @click="selectBackground(item.color)"
  145. >
  146. <view class="color-dot" :class="{ light: item.light }" :style="{ background: item.color }">
  147. <view v-if="settings.background === item.color.toLowerCase()" class="check-mark"></view>
  148. </view>
  149. <text>{{ item.name }}</text>
  150. </view>
  151. </view>
  152. <text class="field-label beauty-label">画面优化</text>
  153. <view class="beauty-grid">
  154. <view
  155. v-for="item in beautyOptions"
  156. :key="item.key"
  157. class="beauty-item"
  158. :class="{ active: settings.beauty === item.key }"
  159. @click="selectBeauty(item.key)"
  160. >
  161. <view class="beauty-radio"><view></view></view>
  162. <view>
  163. <text class="beauty-name">{{ item.name }}</text>
  164. <text class="beauty-desc">{{ item.desc }}</text>
  165. </view>
  166. </view>
  167. </view>
  168. <view v-if="result" class="adjust-section">
  169. <view class="adjust-head">
  170. <view>
  171. <text class="field-label">构图微调</text>
  172. <text class="adjust-help">拖动后点击应用,不会重新抠图或扣点</text>
  173. </view>
  174. <text class="reset-link" @click="resetAdjustments">恢复默认</text>
  175. </view>
  176. <view class="slider-row">
  177. <view class="slider-meta">
  178. <text>人物大小</text>
  179. <text>{{ Math.round(settings.scale * 100) }}%</text>
  180. </view>
  181. <slider
  182. :value="settings.scale"
  183. :min="0.75"
  184. :max="1.4"
  185. :step="0.01"
  186. active-color="#168cff"
  187. background-color="#e6edf5"
  188. block-color="#168cff"
  189. :block-size="18"
  190. @changing="updateSlider('scale', $event)"
  191. @change="updateSlider('scale', $event)"
  192. />
  193. </view>
  194. <view class="slider-row">
  195. <view class="slider-meta">
  196. <text>水平位置</text>
  197. <text>{{ adjustmentText(settings.offset_x, '左', '右') }}</text>
  198. </view>
  199. <slider
  200. :value="settings.offset_x"
  201. :min="-1"
  202. :max="1"
  203. :step="0.01"
  204. active-color="#168cff"
  205. background-color="#e6edf5"
  206. block-color="#168cff"
  207. :block-size="18"
  208. @changing="updateSlider('offset_x', $event)"
  209. @change="updateSlider('offset_x', $event)"
  210. />
  211. </view>
  212. <view class="slider-row">
  213. <view class="slider-meta">
  214. <text>垂直位置</text>
  215. <text>{{ adjustmentText(settings.offset_y, '上', '下') }}</text>
  216. </view>
  217. <slider
  218. :value="settings.offset_y"
  219. :min="-1"
  220. :max="1"
  221. :step="0.01"
  222. active-color="#168cff"
  223. background-color="#e6edf5"
  224. block-color="#168cff"
  225. :block-size="18"
  226. @changing="updateSlider('offset_y', $event)"
  227. @change="updateSlider('offset_y', $event)"
  228. />
  229. </view>
  230. <view
  231. class="apply-adjust"
  232. :class="{ disabled: !settingsDirty || rerendering }"
  233. @click="applyAdjustments"
  234. >
  235. {{ rerendering ? '正在快速应用...' : '应用调整' }}
  236. </view>
  237. </view>
  238. </view>
  239. </view>
  240. <view class="bottom-bar">
  241. <view class="billing-copy">
  242. <text class="billing-label">{{ billingLabel }}</text>
  243. <text v-if="billingSubtext" class="billing-sub">{{ billingSubtext }}</text>
  244. </view>
  245. <view class="bottom-actions">
  246. <view class="history-button" @click="goHistory">历史记录</view>
  247. <view
  248. class="submit-button"
  249. :class="{ disabled: primaryDisabled }"
  250. @click="handlePrimaryAction"
  251. >
  252. <image
  253. v-if="!creating && !rerendering && !result"
  254. class="submit-icon"
  255. src="/static/icons/bolt.svg"
  256. mode="aspectFit"
  257. />
  258. <text>{{ primaryText }}</text>
  259. </view>
  260. </view>
  261. </view>
  262. </view>
  263. </template>
  264. <script setup>
  265. import { computed, nextTick, reactive, ref } from 'vue'
  266. import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
  267. import { idPhotoApi } from '@/api/index.js'
  268. import { createIdPhoto } from '@/utils/upload.js'
  269. import { guid } from '@/utils/util.js'
  270. import { useUserStore } from '@/store/user.js'
  271. import { useApp } from '@/utils/useApp.js'
  272. const STANDARD_BACKGROUNDS = [
  273. { key: 'blue', name: '常用蓝', color: '#438edb' },
  274. { key: 'white', name: '纯白', color: '#ffffff' },
  275. { key: 'certificate-red', name: '证件红', color: '#e60012' },
  276. { key: 'government-blue', name: '政务蓝', color: '#0066cc' },
  277. { key: 'office-blue', name: '柔和蓝', color: '#5b9bd5' },
  278. { key: 'visa-blue', name: '签证蓝', color: '#99ccff' },
  279. { key: 'soft-white', name: '浅灰白', color: '#f5f5f5' },
  280. { key: 'light-gray', name: '浅灰', color: '#c8c8c8' },
  281. { key: 'bright-red', name: '正红', color: '#ff0000' },
  282. { key: 'dark-red', name: '暗红', color: '#dc143c' }
  283. ]
  284. const FALLBACK_BEAUTY = [
  285. { key: 'none', name: '关闭美化', desc: '保留原始肤色与细节' },
  286. { key: 'natural', name: '自然优化', desc: '轻微提亮、降噪和锐化' }
  287. ]
  288. const ERROR_MESSAGES = {
  289. NO_FACE: '没有检测到清晰人脸,请上传正面、无遮挡的照片',
  290. MULTIPLE_FACES: '照片中检测到多张人脸,请上传单人照片',
  291. FACE_TOO_SMALL: '人脸在画面中太小,请上传更清晰的半身照',
  292. FACE_TILTED: '头部倾斜角度较大,请上传正面平视照片',
  293. QUEUE_FULL: '当前制作人数较多,请稍后再试',
  294. ID_PHOTO_PROCESSING: '这张照片正在制作中,请勿重复提交',
  295. INVALID_FACE_DATA: '人脸定位失败,请更换一张清晰照片'
  296. }
  297. const userStore = useUserStore()
  298. const { toast } = useApp()
  299. const categories = ref([])
  300. const specs = ref([])
  301. const backgrounds = ref(mergeBackgrounds())
  302. const beautyOptions = ref(FALLBACK_BEAUTY)
  303. const billing = ref({})
  304. const configLoading = ref(true)
  305. const activeCategory = ref('common')
  306. const selectedSpecKey = ref('')
  307. const specKeyword = ref('')
  308. const localPreview = ref('')
  309. const localFileInfo = ref({})
  310. const result = ref(null)
  311. const resultImage = ref('')
  312. const errorMessage = ref('')
  313. const creating = ref(false)
  314. const rerendering = ref(false)
  315. const progress = ref(0)
  316. const estimatedSeconds = ref(12)
  317. const settingsDirty = ref(false)
  318. const settings = reactive({
  319. background: '#438edb',
  320. beauty: 'natural',
  321. scale: 1,
  322. offset_x: 0,
  323. offset_y: 0
  324. })
  325. let progressTimer = 0
  326. let progressStartedAt = 0
  327. let initialWorkId = ''
  328. let initialized = false
  329. const selectedSpec = computed(() => {
  330. const configured = specs.value.find((item) => item.key === selectedSpecKey.value)
  331. if (configured) return configured
  332. if (!result.value) return null
  333. return normalizeSpec({
  334. key: result.value.spec_key,
  335. name: result.value.spec_name,
  336. width: result.value.width,
  337. height: result.value.height,
  338. dpi: result.value.dpi
  339. })
  340. })
  341. const filteredSpecs = computed(() => {
  342. const keyword = specKeyword.value.trim().toLowerCase()
  343. return specs.value.filter((item) => {
  344. const categoryMatched = keyword || item.category === activeCategory.value
  345. const keywordMatched = !keyword || `${item.name} ${item.width} ${item.height} ${item.print || item.print_size}`.toLowerCase().includes(keyword)
  346. return categoryMatched && keywordMatched
  347. })
  348. })
  349. const quotePoints = computed(() => Number(billing.value.quote?.points || 0))
  350. const balancePoints = computed(() => {
  351. const value = billing.value.balance?.points ?? billing.value.balance?.point_balance
  352. return value === undefined || value === null ? Number(userStore.balance || 0) : Number(value)
  353. })
  354. const isFreeUsage = computed(() => !configLoading.value && Boolean(billing.value.quote) && quotePoints.value === 0)
  355. const insufficient = computed(() => (
  356. userStore.isLogin &&
  357. quotePoints.value > 0 &&
  358. balancePoints.value >= 0 &&
  359. balancePoints.value < quotePoints.value
  360. ))
  361. const primaryDisabled = computed(() => (
  362. creating.value ||
  363. rerendering.value ||
  364. (userStore.isLogin && configLoading.value)
  365. ))
  366. const primaryText = computed(() => {
  367. if (creating.value) return `制作中 ${progress.value}%`
  368. if (rerendering.value) return '正在应用'
  369. if (result.value) return '保存证件照'
  370. if (!userStore.isLogin) {
  371. if (isFreeUsage.value) return '登录后制作 · 免费'
  372. return quotePoints.value > 0 ? `登录后制作 · ${quotePoints.value}点` : '登录后制作'
  373. }
  374. if (configLoading.value) return '正在读取配置'
  375. if (isFreeUsage.value) return '免费制作'
  376. return '立即生成'
  377. })
  378. const billingLabel = computed(() => {
  379. if (isFreeUsage.value) return '免费使用'
  380. if (configLoading.value) return '正在读取价格'
  381. if (!userStore.isLogin) return quotePoints.value ? `${quotePoints.value}点 / 次` : '登录后制作'
  382. return `本次 ${quotePoints.value}点`
  383. })
  384. const billingSubtext = computed(() => {
  385. if (isFreeUsage.value) return '制作成功后可直接保存'
  386. if (!userStore.isLogin || quotePoints.value <= 0) return ''
  387. return insufficient.value ? `余额 ${balancePoints.value}点,点数不足` : `当前余额 ${balancePoints.value}点`
  388. })
  389. const previewDescription = computed(() => {
  390. if (result.value) return '已完成,可继续换底色或微调构图'
  391. if (localPreview.value) return '确认规格与背景后开始制作'
  392. return '上传清晰正面照片可获得更稳定的效果'
  393. })
  394. const photoMetaText = computed(() => {
  395. const width = Number(localFileInfo.value.width || 0)
  396. const height = Number(localFileInfo.value.height || 0)
  397. return width && height ? `原图 ${width} × ${height}px` : '原图已就绪'
  398. })
  399. const progressStage = computed(() => {
  400. if (progress.value < 25) return '正在上传原图'
  401. if (progress.value < 72) return '正在识别人像并抠图'
  402. if (progress.value < 94) return '正在换色与标准裁剪'
  403. return '正在保存高清成品'
  404. })
  405. const photoRatio = computed(() => {
  406. const width = Number(result.value?.width || selectedSpec.value?.width || 295)
  407. const height = Number(result.value?.height || selectedSpec.value?.height || 413)
  408. return width > 0 && height > 0 ? width / height : 295 / 413
  409. })
  410. const photoFrameStyle = computed(() => {
  411. const maxWidth = 590
  412. const maxHeight = 620
  413. let width = maxWidth
  414. let height = width / photoRatio.value
  415. if (height > maxHeight) {
  416. height = maxHeight
  417. width = height * photoRatio.value
  418. }
  419. return {
  420. width: `${Math.round(width)}rpx`,
  421. height: `${Math.round(height)}rpx`
  422. }
  423. })
  424. onLoad((options = {}) => {
  425. initialWorkId = String(options.work || '')
  426. initialize()
  427. })
  428. onShow(() => {
  429. if (!initialized) return
  430. if (userStore.isLogin) {
  431. refreshBalance()
  432. if (initialWorkId && !result.value) loadDetail(initialWorkId)
  433. }
  434. loadConfig(false)
  435. })
  436. onUnload(() => {
  437. clearProgress()
  438. })
  439. async function initialize() {
  440. await loadConfig(true)
  441. if (initialWorkId) await loadDetail(initialWorkId)
  442. initialized = true
  443. }
  444. async function loadConfig(showError = false) {
  445. configLoading.value = true
  446. try {
  447. const res = await idPhotoApi.getConfig({ image_count: 1 })
  448. const data = res.data || {}
  449. categories.value = Array.isArray(data.categories) ? data.categories : []
  450. specs.value = Array.isArray(data.specs) ? data.specs.map(normalizeSpec) : []
  451. backgrounds.value = mergeBackgrounds(data.backgrounds)
  452. beautyOptions.value = Array.isArray(data.beauty_options) && data.beauty_options.length
  453. ? data.beauty_options
  454. : FALLBACK_BEAUTY
  455. billing.value = data.billing || {}
  456. if (!activeCategory.value || !categories.value.some((item) => item.key === activeCategory.value)) {
  457. activeCategory.value = categories.value[0]?.key || 'common'
  458. }
  459. if (
  460. !selectedSpecKey.value ||
  461. (!result.value && !specs.value.some((item) => item.key === selectedSpecKey.value))
  462. ) {
  463. selectedSpecKey.value = specs.value.find((item) => item.key === 'one-inch')?.key || specs.value[0]?.key || ''
  464. }
  465. } catch (e) {
  466. if (showError) toast(e.msg || '证件照配置加载失败')
  467. } finally {
  468. configLoading.value = false
  469. }
  470. }
  471. function normalizeSpec(item = {}) {
  472. return {
  473. ...item,
  474. key: item.key || item.spec_key,
  475. width: Number(item.width || 0),
  476. height: Number(item.height || 0),
  477. dpi: Number(item.dpi || 300),
  478. hot: Number(item.hot ?? item.is_hot ?? 0)
  479. }
  480. }
  481. function mergeBackgrounds(configured = []) {
  482. const list = STANDARD_BACKGROUNDS.concat(Array.isArray(configured) ? configured : [])
  483. const used = new Set()
  484. return list
  485. .map((item, index) => ({
  486. ...item,
  487. key: item.key || `background-${index}`,
  488. color: String(item.color || '').toLowerCase(),
  489. light: isLightBackground(item.color)
  490. }))
  491. .filter((item) => {
  492. if (!/^#[0-9a-f]{6}$/.test(item.color) || used.has(item.color)) return false
  493. used.add(item.color)
  494. return true
  495. })
  496. }
  497. function isLightBackground(color) {
  498. const value = String(color || '').replace('#', '')
  499. if (!/^[0-9a-fA-F]{6}$/.test(value)) return false
  500. const red = parseInt(value.slice(0, 2), 16)
  501. const green = parseInt(value.slice(2, 4), 16)
  502. const blue = parseInt(value.slice(4, 6), 16)
  503. return red * 0.299 + green * 0.587 + blue * 0.114 > 185
  504. }
  505. async function loadDetail(photoOpenId) {
  506. if (!requireLogin()) return
  507. try {
  508. uni.showLoading({ mask: true, title: '加载照片...' })
  509. const res = await idPhotoApi.getDetail({ photo_open_id: photoOpenId })
  510. await applyResultData(res.data || {})
  511. } catch (e) {
  512. toast(e.msg || '照片记录加载失败')
  513. } finally {
  514. uni.hideLoading()
  515. }
  516. }
  517. function choosePhoto() {
  518. if (creating.value || rerendering.value) return
  519. uni.chooseImage({
  520. count: 1,
  521. sizeType: ['original'],
  522. sourceType: ['album', 'camera'],
  523. success: async (res) => {
  524. const file = res.tempFiles?.[0] || {}
  525. if (Number(file.size || 0) > 10 * 1024 * 1024) {
  526. toast('图片大小不能超过 10MB')
  527. return
  528. }
  529. const path = res.tempFilePaths?.[0]
  530. if (!path) return
  531. let imageInfo = {}
  532. try {
  533. imageInfo = await getImageInfo(path)
  534. } catch (e) {}
  535. localPreview.value = path
  536. localFileInfo.value = {
  537. name: file.name || '',
  538. size: Number(file.size || 0),
  539. width: Number(imageInfo.width || 0),
  540. height: Number(imageInfo.height || 0)
  541. }
  542. result.value = null
  543. resultImage.value = ''
  544. errorMessage.value = ''
  545. settingsDirty.value = false
  546. settings.scale = 1
  547. settings.offset_x = 0
  548. settings.offset_y = 0
  549. }
  550. })
  551. }
  552. function getImageInfo(src) {
  553. return new Promise((resolve, reject) => {
  554. uni.getImageInfo({ src, success: resolve, fail: reject })
  555. })
  556. }
  557. function selectSpec(item) {
  558. if (result.value && result.value.spec_key !== item.key) {
  559. toast('已生成照片的规格不可直接修改,请选择“制作新照片”')
  560. return
  561. }
  562. selectedSpecKey.value = item.key
  563. }
  564. function selectBackground(color) {
  565. settings.background = String(color || '#438edb').toLowerCase()
  566. markDirty()
  567. }
  568. function selectBeauty(key) {
  569. settings.beauty = key
  570. markDirty()
  571. }
  572. function updateSlider(key, event) {
  573. settings[key] = Number(event.detail.value)
  574. markDirty()
  575. }
  576. function resetAdjustments() {
  577. settings.scale = 1
  578. settings.offset_x = 0
  579. settings.offset_y = 0
  580. markDirty()
  581. }
  582. function markDirty() {
  583. if (result.value) settingsDirty.value = true
  584. }
  585. function adjustmentText(value, negativeLabel, positiveLabel) {
  586. const number = Number(value || 0)
  587. if (Math.abs(number) < 0.01) return '居中'
  588. return `${number < 0 ? negativeLabel : positiveLabel} ${Math.round(Math.abs(number) * 100)}%`
  589. }
  590. function handlePrimaryAction() {
  591. if (primaryDisabled.value) return
  592. if (result.value) {
  593. saveResult()
  594. return
  595. }
  596. if (!requireLogin()) return
  597. if (!localPreview.value) {
  598. choosePhoto()
  599. return
  600. }
  601. createPhoto()
  602. }
  603. async function createPhoto() {
  604. if (!requireLogin()) return
  605. if (!selectedSpec.value) {
  606. toast('请选择证件照规格')
  607. return
  608. }
  609. if (insufficient.value) {
  610. showInsufficientModal()
  611. return
  612. }
  613. creating.value = true
  614. errorMessage.value = ''
  615. startProgress()
  616. try {
  617. const res = await createIdPhoto(localPreview.value, {
  618. request_id: guid(),
  619. spec_key: selectedSpec.value.key,
  620. background: settings.background,
  621. beauty: settings.beauty,
  622. scale: settings.scale,
  623. offset_x: settings.offset_x,
  624. offset_y: settings.offset_y
  625. })
  626. progress.value = 100
  627. estimatedSeconds.value = 0
  628. await applyResultData(res.data || {})
  629. refreshBalance()
  630. toast('证件照制作完成')
  631. } catch (e) {
  632. handleApiError(e, '证件照制作失败,请稍后重试')
  633. } finally {
  634. creating.value = false
  635. setTimeout(clearProgress, 260)
  636. }
  637. }
  638. async function applyAdjustments() {
  639. if (!result.value || !settingsDirty.value || rerendering.value) return
  640. if (!requireLogin()) return
  641. rerendering.value = true
  642. errorMessage.value = ''
  643. try {
  644. const res = await idPhotoApi.rerender({
  645. photo_open_id: result.value.photo_open_id,
  646. background: settings.background,
  647. beauty: settings.beauty,
  648. scale: settings.scale,
  649. offset_x: settings.offset_x,
  650. offset_y: settings.offset_y
  651. })
  652. await applyResultData(res.data || {}, true)
  653. settingsDirty.value = false
  654. await scrollToPreview()
  655. toast('调整已应用')
  656. syncPersistedResult(result.value.photo_open_id)
  657. } catch (e) {
  658. handleApiError(e, '应用调整失败,请稍后重试')
  659. } finally {
  660. rerendering.value = false
  661. }
  662. }
  663. async function applyResultData(data = {}, keepSelectedSpec = false) {
  664. result.value = { ...(result.value || {}), ...data }
  665. resultImage.value = data.result_url || data.answer || data.cover || resultImage.value
  666. localPreview.value = data.source_url || data.original || localPreview.value
  667. if (!keepSelectedSpec && data.spec_key) selectedSpecKey.value = data.spec_key
  668. if (data.background) settings.background = String(data.background).toLowerCase()
  669. if (data.beauty) settings.beauty = data.beauty
  670. settings.scale = Number(data.scale ?? settings.scale ?? 1)
  671. settings.offset_x = Number(data.offset_x ?? settings.offset_x ?? 0)
  672. settings.offset_y = Number(data.offset_y ?? settings.offset_y ?? 0)
  673. settingsDirty.value = false
  674. }
  675. function handleApiError(error, fallback) {
  676. if (Number(error?.code) === 1009) {
  677. userStore.clear()
  678. requireLogin()
  679. return
  680. }
  681. const errorCode = error?.data?.error || error?.error
  682. errorMessage.value = ERROR_MESSAGES[errorCode] || error?.msg || fallback
  683. toast(errorMessage.value)
  684. }
  685. function startProgress() {
  686. clearProgress()
  687. progress.value = 6
  688. estimatedSeconds.value = 12
  689. progressStartedAt = Date.now()
  690. progressTimer = setInterval(() => {
  691. const elapsed = Date.now() - progressStartedAt
  692. const next = Math.round(8 + (1 - Math.exp(-elapsed / 6500)) * 88)
  693. progress.value = Math.min(96, Math.max(progress.value, next))
  694. estimatedSeconds.value = Math.max(1, Math.ceil((13000 - elapsed) / 1000))
  695. }, 360)
  696. }
  697. function clearProgress() {
  698. clearInterval(progressTimer)
  699. progressTimer = 0
  700. if (!creating.value) {
  701. progress.value = 0
  702. estimatedSeconds.value = 12
  703. }
  704. }
  705. function requireLogin() {
  706. if (userStore.isLogin) return true
  707. let url = '/pages/login/login'
  708. // #ifdef H5
  709. url += `?redirect=${encodeURIComponent(location.pathname + location.search)}`
  710. // #endif
  711. uni.navigateTo({ url })
  712. return false
  713. }
  714. function showInsufficientModal() {
  715. uni.showModal({
  716. title: '点数不足',
  717. content: `当前余额 ${balancePoints.value} 点,本次制作需要 ${quotePoints.value} 点。`,
  718. confirmText: '去充值',
  719. success: (res) => {
  720. if (res.confirm) uni.navigateTo({ url: '/pages/recharge/recharge' })
  721. }
  722. })
  723. }
  724. function goHistory() {
  725. if (!requireLogin()) return
  726. uni.navigateTo({ url: '/pages/tool/id-photo-history' })
  727. }
  728. async function scrollToPreview() {
  729. await nextTick()
  730. uni.pageScrollTo({
  731. selector: '#id-photo-preview',
  732. duration: 320
  733. })
  734. }
  735. async function syncPersistedResult(photoOpenId, attempts = 8) {
  736. if (!photoOpenId) return ''
  737. for (let index = 0; index < attempts; index += 1) {
  738. if (index) await delay(700)
  739. try {
  740. const res = await idPhotoApi.getDetail({ photo_open_id: photoOpenId })
  741. const data = res.data || {}
  742. const url = data.result_url || ''
  743. if (url && !url.startsWith('data:')) {
  744. result.value = { ...(result.value || {}), ...data }
  745. resultImage.value = url
  746. return url
  747. }
  748. } catch (e) {}
  749. }
  750. return ''
  751. }
  752. async function saveResult() {
  753. if (!resultImage.value) return
  754. let url = resultImage.value
  755. if (url.startsWith('data:')) {
  756. const persisted = await syncPersistedResult(result.value?.photo_open_id)
  757. if (persisted) url = persisted
  758. }
  759. // #ifdef H5
  760. try {
  761. let downloadUrl = url
  762. let objectUrl = ''
  763. if (!url.startsWith('data:')) {
  764. const response = await fetch(url)
  765. if (!response.ok) throw new Error('download failed')
  766. objectUrl = URL.createObjectURL(await response.blob())
  767. downloadUrl = objectUrl
  768. }
  769. const link = document.createElement('a')
  770. link.href = downloadUrl
  771. link.download = `${result.value?.spec_name || '证件照'}.jpg`
  772. document.body.appendChild(link)
  773. link.click()
  774. document.body.removeChild(link)
  775. if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
  776. toast('已开始下载')
  777. } catch (e) {
  778. window.open(url, '_blank')
  779. toast('已打开高清照片,可长按保存')
  780. }
  781. // #endif
  782. // #ifndef H5
  783. try {
  784. uni.showLoading({ mask: true, title: '正在保存...' })
  785. const filePath = url.startsWith('data:') ? await writeBase64Image(url) : await downloadImage(url)
  786. await saveToAlbum(filePath)
  787. toast('已保存到系统相册')
  788. } catch (e) {
  789. toast('保存失败,请检查相册权限后重试')
  790. } finally {
  791. uni.hideLoading()
  792. }
  793. // #endif
  794. }
  795. function downloadImage(url) {
  796. return new Promise((resolve, reject) => {
  797. uni.downloadFile({
  798. url,
  799. success: (res) => res.statusCode === 200 ? resolve(res.tempFilePath) : reject(res),
  800. fail: reject
  801. })
  802. })
  803. }
  804. function saveToAlbum(filePath) {
  805. return new Promise((resolve, reject) => {
  806. uni.saveImageToPhotosAlbum({ filePath, success: resolve, fail: reject })
  807. })
  808. }
  809. function writeBase64Image(dataUrl) {
  810. return new Promise((resolve, reject) => {
  811. // #ifdef MP-WEIXIN
  812. const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/)
  813. if (!match) {
  814. reject(new Error('图片数据无效'))
  815. return
  816. }
  817. const filePath = `${wx.env.USER_DATA_PATH}/id-photo-${Date.now()}.${match[1] === 'jpeg' ? 'jpg' : match[1]}`
  818. uni.getFileSystemManager().writeFile({
  819. filePath,
  820. data: match[2],
  821. encoding: 'base64',
  822. success: () => resolve(filePath),
  823. fail: reject
  824. })
  825. // #endif
  826. // #ifndef MP-WEIXIN
  827. reject(new Error('请稍后等待高清图片保存完成'))
  828. // #endif
  829. })
  830. }
  831. function delay(ms) {
  832. return new Promise((resolve) => setTimeout(resolve, ms))
  833. }
  834. function refreshBalance() {
  835. userStore.fetchBalance()
  836. .then((points) => {
  837. billing.value = {
  838. ...billing.value,
  839. balance: {
  840. ...(billing.value.balance || {}),
  841. points
  842. }
  843. }
  844. })
  845. .catch(() => {})
  846. }
  847. </script>
  848. <style lang="scss" scoped>
  849. .page {
  850. min-height: 100vh;
  851. color: #172033;
  852. background:
  853. radial-gradient(circle at 100% 6%, rgba(67, 190, 255, 0.13), transparent 25%),
  854. #f3f7fb;
  855. }
  856. .content {
  857. padding: 24rpx 24rpx calc(220rpx + env(safe-area-inset-bottom));
  858. }
  859. .hero-card {
  860. position: relative;
  861. min-height: 300rpx;
  862. padding: 34rpx 238rpx 32rpx 30rpx;
  863. border-radius: 28rpx;
  864. background: linear-gradient(145deg, #e9f4ff 0%, #f5faff 56%, #e9fbf7 100%);
  865. box-shadow: 0 14rpx 36rpx rgba(43, 80, 123, 0.08);
  866. overflow: hidden;
  867. box-sizing: border-box;
  868. }
  869. .hero-copy {
  870. position: relative;
  871. z-index: 2;
  872. }
  873. .eyebrow {
  874. display: block;
  875. color: #0b8cff;
  876. font-size: 20rpx;
  877. font-weight: 900;
  878. letter-spacing: 3rpx;
  879. }
  880. .hero-title {
  881. display: block;
  882. margin-top: 14rpx;
  883. color: #071a37;
  884. font-size: 38rpx;
  885. font-weight: 900;
  886. line-height: 1.28;
  887. }
  888. .hero-desc {
  889. display: block;
  890. margin-top: 12rpx;
  891. color: #5d7290;
  892. font-size: 23rpx;
  893. line-height: 1.55;
  894. }
  895. .hero-tags {
  896. margin-top: 18rpx;
  897. display: flex;
  898. gap: 10rpx;
  899. }
  900. .hero-tags text {
  901. padding: 6rpx 12rpx;
  902. border-radius: 999rpx;
  903. color: #2770a9;
  904. background: rgba(255, 255, 255, 0.72);
  905. font-size: 19rpx;
  906. font-weight: 800;
  907. }
  908. .hero-image {
  909. position: absolute;
  910. z-index: 1;
  911. top: 22rpx;
  912. right: 30rpx;
  913. width: 180rpx;
  914. height: 252rpx;
  915. border: 8rpx solid rgba(255, 255, 255, 0.86);
  916. border-radius: 18rpx;
  917. box-shadow: 0 18rpx 34rpx rgba(26, 76, 118, 0.18);
  918. }
  919. .id-panel {
  920. width: 100%;
  921. margin-top: 22rpx;
  922. padding: 28rpx;
  923. border-radius: 26rpx;
  924. background: #ffffff;
  925. box-shadow: 0 10rpx 34rpx rgba(39, 70, 112, 0.06);
  926. box-sizing: border-box;
  927. }
  928. .card-head {
  929. display: flex;
  930. align-items: flex-start;
  931. gap: 20rpx;
  932. }
  933. .card-head > view:first-child {
  934. min-width: 0;
  935. flex: 1;
  936. }
  937. .card-title {
  938. display: block;
  939. color: #12213a;
  940. font-size: 31rpx;
  941. font-weight: 900;
  942. line-height: 1.25;
  943. }
  944. .card-desc {
  945. display: block;
  946. margin-top: 7rpx;
  947. color: #8794a7;
  948. font-size: 22rpx;
  949. line-height: 1.45;
  950. }
  951. .history-link {
  952. color: #168cff;
  953. font-size: 23rpx;
  954. font-weight: 800;
  955. line-height: 42rpx;
  956. flex-shrink: 0;
  957. }
  958. .upload-zone {
  959. margin-top: 24rpx;
  960. padding: 42rpx 22rpx 24rpx;
  961. border: 2rpx dashed #7bbcff;
  962. border-radius: 24rpx;
  963. background: #f3f9ff;
  964. text-align: center;
  965. }
  966. .upload-icon {
  967. width: 92rpx;
  968. height: 92rpx;
  969. margin: 0 auto;
  970. border-radius: 26rpx;
  971. background: #ffffff;
  972. box-shadow: 0 10rpx 26rpx rgba(22, 140, 255, 0.12);
  973. display: flex;
  974. align-items: center;
  975. justify-content: center;
  976. }
  977. .upload-arrow {
  978. position: relative;
  979. width: 32rpx;
  980. height: 28rpx;
  981. border: 5rpx solid #168cff;
  982. border-top: 0;
  983. border-radius: 4rpx;
  984. }
  985. .upload-arrow::before {
  986. content: '';
  987. position: absolute;
  988. left: 11rpx;
  989. top: -20rpx;
  990. width: 6rpx;
  991. height: 24rpx;
  992. border-radius: 4rpx;
  993. background: #168cff;
  994. }
  995. .upload-arrow::after {
  996. content: '';
  997. position: absolute;
  998. left: 5rpx;
  999. top: -20rpx;
  1000. width: 14rpx;
  1001. height: 14rpx;
  1002. border-top: 5rpx solid #168cff;
  1003. border-left: 5rpx solid #168cff;
  1004. transform: rotate(45deg);
  1005. }
  1006. .upload-title {
  1007. display: block;
  1008. margin-top: 22rpx;
  1009. color: #10223d;
  1010. font-size: 30rpx;
  1011. font-weight: 900;
  1012. }
  1013. .upload-desc {
  1014. display: block;
  1015. margin-top: 10rpx;
  1016. color: #718198;
  1017. font-size: 22rpx;
  1018. }
  1019. .upload-button {
  1020. width: 210rpx;
  1021. height: 72rpx;
  1022. margin: 26rpx auto 0;
  1023. border-radius: 999rpx;
  1024. color: #ffffff;
  1025. background: linear-gradient(135deg, #0b8cff, #35b1ff);
  1026. box-shadow: 0 12rpx 26rpx rgba(11, 140, 255, 0.24);
  1027. font-size: 26rpx;
  1028. font-weight: 900;
  1029. line-height: 72rpx;
  1030. }
  1031. .photo-tips {
  1032. margin-top: 34rpx;
  1033. display: flex;
  1034. justify-content: center;
  1035. gap: 40rpx;
  1036. }
  1037. .tip-item {
  1038. color: #65758c;
  1039. font-size: 21rpx;
  1040. display: flex;
  1041. align-items: center;
  1042. gap: 8rpx;
  1043. }
  1044. .tip-dot {
  1045. width: 12rpx;
  1046. height: 12rpx;
  1047. border: 3rpx solid #18ad73;
  1048. border-radius: 50%;
  1049. }
  1050. .preview-area {
  1051. margin-top: 24rpx;
  1052. }
  1053. .preview-stage {
  1054. position: relative;
  1055. min-height: 620rpx;
  1056. border-radius: 22rpx;
  1057. background-color: #eef4f9;
  1058. background-image: repeating-linear-gradient(
  1059. 135deg,
  1060. rgba(255, 255, 255, 0.8) 0,
  1061. rgba(255, 255, 255, 0.8) 18rpx,
  1062. rgba(224, 234, 243, 0.8) 18rpx,
  1063. rgba(224, 234, 243, 0.8) 36rpx
  1064. );
  1065. display: flex;
  1066. align-items: center;
  1067. justify-content: center;
  1068. overflow: hidden;
  1069. }
  1070. .source-image {
  1071. width: 100%;
  1072. height: 620rpx;
  1073. }
  1074. .result-image {
  1075. display: block;
  1076. border-radius: 4rpx;
  1077. }
  1078. .progress-mask {
  1079. position: absolute;
  1080. left: 0;
  1081. right: 0;
  1082. top: 0;
  1083. bottom: 0;
  1084. padding: 30rpx;
  1085. background: rgba(5, 20, 40, 0.5);
  1086. backdrop-filter: blur(5px);
  1087. display: flex;
  1088. align-items: center;
  1089. justify-content: center;
  1090. box-sizing: border-box;
  1091. }
  1092. .progress-panel {
  1093. width: 100%;
  1094. padding: 28rpx;
  1095. border-radius: 22rpx;
  1096. background: rgba(255, 255, 255, 0.96);
  1097. box-sizing: border-box;
  1098. }
  1099. .progress-top {
  1100. display: flex;
  1101. align-items: center;
  1102. justify-content: space-between;
  1103. }
  1104. .progress-title {
  1105. color: #11233e;
  1106. font-size: 27rpx;
  1107. font-weight: 900;
  1108. }
  1109. .progress-value {
  1110. color: #168cff;
  1111. font-size: 29rpx;
  1112. font-weight: 900;
  1113. }
  1114. .progress-track {
  1115. height: 14rpx;
  1116. margin-top: 20rpx;
  1117. border-radius: 999rpx;
  1118. background: #e6eff8;
  1119. overflow: hidden;
  1120. }
  1121. .progress-bar {
  1122. height: 100%;
  1123. border-radius: inherit;
  1124. background: linear-gradient(90deg, #168cff, #42c0ff);
  1125. transition: width 0.3s ease;
  1126. }
  1127. .progress-desc {
  1128. display: block;
  1129. margin-top: 16rpx;
  1130. color: #758399;
  1131. font-size: 22rpx;
  1132. }
  1133. .preview-meta {
  1134. margin-top: 15rpx;
  1135. color: #7b899b;
  1136. font-size: 21rpx;
  1137. display: flex;
  1138. justify-content: space-between;
  1139. gap: 20rpx;
  1140. }
  1141. .error-box {
  1142. margin-top: 16rpx;
  1143. padding: 18rpx 20rpx;
  1144. border-radius: 16rpx;
  1145. color: #c43e3e;
  1146. background: #fff1f1;
  1147. font-size: 22rpx;
  1148. line-height: 1.45;
  1149. }
  1150. .preview-actions {
  1151. margin-top: 22rpx;
  1152. display: flex;
  1153. gap: 14rpx;
  1154. }
  1155. .primary-action,
  1156. .soft-action {
  1157. height: 72rpx;
  1158. border-radius: 999rpx;
  1159. font-size: 23rpx;
  1160. font-weight: 900;
  1161. line-height: 72rpx;
  1162. text-align: center;
  1163. }
  1164. .primary-action {
  1165. flex: 1;
  1166. color: #ffffff;
  1167. background: #168cff;
  1168. }
  1169. .soft-action {
  1170. flex: 1;
  1171. color: #48617e;
  1172. background: #eef4fa;
  1173. }
  1174. .dpi-badge,
  1175. .dirty-badge {
  1176. padding: 8rpx 13rpx;
  1177. border-radius: 999rpx;
  1178. color: #168cff;
  1179. background: #edf7ff;
  1180. font-size: 19rpx;
  1181. font-weight: 900;
  1182. flex-shrink: 0;
  1183. }
  1184. .dirty-badge {
  1185. color: #a76108;
  1186. background: #fff5dc;
  1187. }
  1188. .search-box {
  1189. height: 72rpx;
  1190. margin-top: 22rpx;
  1191. padding: 0 20rpx;
  1192. border-radius: 16rpx;
  1193. background: #f4f7fb;
  1194. display: flex;
  1195. align-items: center;
  1196. }
  1197. .search-icon {
  1198. position: relative;
  1199. width: 22rpx;
  1200. height: 22rpx;
  1201. margin-right: 15rpx;
  1202. border: 3rpx solid #91a0b4;
  1203. border-radius: 50%;
  1204. }
  1205. .search-icon::after {
  1206. content: '';
  1207. position: absolute;
  1208. right: -9rpx;
  1209. bottom: -6rpx;
  1210. width: 11rpx;
  1211. height: 3rpx;
  1212. background: #91a0b4;
  1213. transform: rotate(45deg);
  1214. }
  1215. .search-input {
  1216. min-width: 0;
  1217. height: 72rpx;
  1218. flex: 1;
  1219. color: #172033;
  1220. font-size: 24rpx;
  1221. }
  1222. .input-placeholder {
  1223. color: #9aa6b7;
  1224. }
  1225. .search-clear {
  1226. width: 48rpx;
  1227. color: #9aa6b7;
  1228. font-size: 38rpx;
  1229. line-height: 48rpx;
  1230. text-align: right;
  1231. }
  1232. .category-scroll {
  1233. width: 100%;
  1234. margin-top: 18rpx;
  1235. white-space: nowrap;
  1236. }
  1237. .category-list {
  1238. display: flex;
  1239. gap: 12rpx;
  1240. }
  1241. .category-item {
  1242. height: 58rpx;
  1243. padding: 0 22rpx;
  1244. border-radius: 999rpx;
  1245. color: #627187;
  1246. background: #f4f7fb;
  1247. font-size: 23rpx;
  1248. font-weight: 800;
  1249. line-height: 58rpx;
  1250. flex-shrink: 0;
  1251. }
  1252. .category-item.active {
  1253. color: #ffffff;
  1254. background: #13213a;
  1255. }
  1256. .spec-grid {
  1257. margin-top: 18rpx;
  1258. display: grid;
  1259. grid-template-columns: repeat(2, minmax(0, 1fr));
  1260. gap: 14rpx;
  1261. }
  1262. .spec-item {
  1263. min-height: 132rpx;
  1264. padding: 18rpx;
  1265. border: 2rpx solid #e5ebf2;
  1266. border-radius: 18rpx;
  1267. background: #ffffff;
  1268. box-sizing: border-box;
  1269. }
  1270. .spec-item.active {
  1271. border-color: #168cff;
  1272. background: #edf7ff;
  1273. box-shadow: 0 8rpx 18rpx rgba(22, 140, 255, 0.08);
  1274. }
  1275. .spec-name-row {
  1276. display: flex;
  1277. align-items: center;
  1278. gap: 8rpx;
  1279. }
  1280. .spec-name {
  1281. color: #172033;
  1282. font-size: 25rpx;
  1283. font-weight: 900;
  1284. }
  1285. .hot-badge {
  1286. padding: 3rpx 7rpx;
  1287. border-radius: 999rpx;
  1288. color: #ff6d24;
  1289. background: #fff1e7;
  1290. font-size: 17rpx;
  1291. font-weight: 800;
  1292. }
  1293. .spec-pixel,
  1294. .spec-print {
  1295. display: block;
  1296. margin-top: 9rpx;
  1297. color: #718198;
  1298. font-size: 20rpx;
  1299. }
  1300. .spec-print {
  1301. margin-top: 4rpx;
  1302. color: #9aa5b5;
  1303. }
  1304. .empty-spec {
  1305. padding: 48rpx 0 24rpx;
  1306. color: #95a1b2;
  1307. font-size: 23rpx;
  1308. text-align: center;
  1309. }
  1310. .field-label {
  1311. display: block;
  1312. margin-top: 26rpx;
  1313. color: #41516a;
  1314. font-size: 23rpx;
  1315. font-weight: 900;
  1316. }
  1317. .background-grid {
  1318. margin-top: 16rpx;
  1319. display: grid;
  1320. grid-template-columns: repeat(3, minmax(0, 1fr));
  1321. gap: 14rpx;
  1322. }
  1323. .background-item {
  1324. min-height: 112rpx;
  1325. padding: 14rpx 8rpx 12rpx;
  1326. border: 2rpx solid #e5ebf2;
  1327. border-radius: 18rpx;
  1328. color: #58677e;
  1329. background: #ffffff;
  1330. font-size: 20rpx;
  1331. font-weight: 800;
  1332. text-align: center;
  1333. box-sizing: border-box;
  1334. }
  1335. .background-item.active {
  1336. color: #168cff;
  1337. border-color: #168cff;
  1338. background: #f1f8ff;
  1339. }
  1340. .color-dot {
  1341. position: relative;
  1342. width: 50rpx;
  1343. height: 50rpx;
  1344. margin: 0 auto 9rpx;
  1345. border: 2rpx solid rgba(15, 23, 42, 0.1);
  1346. border-radius: 50%;
  1347. box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.35);
  1348. display: flex;
  1349. align-items: center;
  1350. justify-content: center;
  1351. }
  1352. .check-mark {
  1353. position: relative;
  1354. width: 16rpx;
  1355. height: 9rpx;
  1356. border-left: 4rpx solid #ffffff;
  1357. border-bottom: 4rpx solid #ffffff;
  1358. transform: translateY(-2rpx) rotate(-45deg);
  1359. filter: drop-shadow(0 1rpx 1rpx rgba(0, 0, 0, 0.4));
  1360. }
  1361. .color-dot.light .check-mark {
  1362. border-color: #168cff;
  1363. filter: none;
  1364. }
  1365. .beauty-label {
  1366. margin-top: 28rpx;
  1367. }
  1368. .beauty-grid {
  1369. margin-top: 16rpx;
  1370. display: grid;
  1371. grid-template-columns: repeat(2, minmax(0, 1fr));
  1372. gap: 14rpx;
  1373. }
  1374. .beauty-item {
  1375. min-height: 102rpx;
  1376. padding: 16rpx;
  1377. border: 2rpx solid #e5ebf2;
  1378. border-radius: 18rpx;
  1379. display: flex;
  1380. align-items: flex-start;
  1381. gap: 12rpx;
  1382. box-sizing: border-box;
  1383. }
  1384. .beauty-item.active {
  1385. border-color: #168cff;
  1386. background: #edf7ff;
  1387. }
  1388. .beauty-radio {
  1389. width: 25rpx;
  1390. height: 25rpx;
  1391. margin-top: 3rpx;
  1392. border: 3rpx solid #b6c1cf;
  1393. border-radius: 50%;
  1394. display: flex;
  1395. align-items: center;
  1396. justify-content: center;
  1397. flex-shrink: 0;
  1398. }
  1399. .beauty-item.active .beauty-radio {
  1400. border-color: #168cff;
  1401. }
  1402. .beauty-item.active .beauty-radio view {
  1403. width: 13rpx;
  1404. height: 13rpx;
  1405. border-radius: 50%;
  1406. background: #168cff;
  1407. }
  1408. .beauty-name,
  1409. .beauty-desc {
  1410. display: block;
  1411. }
  1412. .beauty-name {
  1413. color: #26364d;
  1414. font-size: 23rpx;
  1415. font-weight: 900;
  1416. }
  1417. .beauty-desc {
  1418. margin-top: 7rpx;
  1419. color: #8995a7;
  1420. font-size: 19rpx;
  1421. line-height: 1.35;
  1422. }
  1423. .adjust-section {
  1424. margin-top: 28rpx;
  1425. padding-top: 4rpx;
  1426. border-top: 2rpx solid #edf1f5;
  1427. }
  1428. .adjust-head {
  1429. display: flex;
  1430. align-items: flex-start;
  1431. justify-content: space-between;
  1432. gap: 20rpx;
  1433. }
  1434. .adjust-help {
  1435. display: block;
  1436. margin-top: 6rpx;
  1437. color: #919daf;
  1438. font-size: 20rpx;
  1439. }
  1440. .reset-link {
  1441. margin-top: 26rpx;
  1442. color: #168cff;
  1443. font-size: 21rpx;
  1444. font-weight: 800;
  1445. flex-shrink: 0;
  1446. }
  1447. .slider-row {
  1448. margin-top: 22rpx;
  1449. }
  1450. .slider-meta {
  1451. padding: 0 2rpx;
  1452. color: #5f6e83;
  1453. font-size: 21rpx;
  1454. display: flex;
  1455. align-items: center;
  1456. justify-content: space-between;
  1457. }
  1458. .slider-row slider {
  1459. margin: 8rpx 0 0;
  1460. }
  1461. .apply-adjust {
  1462. height: 74rpx;
  1463. margin-top: 20rpx;
  1464. border-radius: 999rpx;
  1465. color: #ffffff;
  1466. background: #168cff;
  1467. font-size: 25rpx;
  1468. font-weight: 900;
  1469. line-height: 74rpx;
  1470. text-align: center;
  1471. }
  1472. .apply-adjust.disabled {
  1473. color: #a0adbd;
  1474. background: #edf1f5;
  1475. }
  1476. .bottom-bar {
  1477. position: fixed;
  1478. z-index: 80;
  1479. left: 0;
  1480. right: 0;
  1481. bottom: 0;
  1482. padding: 16rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
  1483. border-top: 2rpx solid rgba(224, 232, 241, 0.9);
  1484. background: rgba(255, 255, 255, 0.97);
  1485. box-shadow: 0 -12rpx 32rpx rgba(35, 61, 96, 0.09);
  1486. box-sizing: border-box;
  1487. }
  1488. .billing-copy {
  1489. min-height: 34rpx;
  1490. margin-bottom: 14rpx;
  1491. display: flex;
  1492. align-items: center;
  1493. justify-content: space-between;
  1494. gap: 20rpx;
  1495. }
  1496. .billing-label,
  1497. .billing-sub {
  1498. display: block;
  1499. }
  1500. .billing-label {
  1501. min-width: 0;
  1502. flex: 1;
  1503. color: #162640;
  1504. font-size: 23rpx;
  1505. font-weight: 900;
  1506. }
  1507. .billing-sub {
  1508. flex-shrink: 0;
  1509. color: #8491a4;
  1510. font-size: 21rpx;
  1511. line-height: 1.3;
  1512. }
  1513. .bottom-actions {
  1514. display: flex;
  1515. gap: 22rpx;
  1516. }
  1517. .history-button,
  1518. .submit-button {
  1519. height: 86rpx;
  1520. border-radius: 999rpx;
  1521. font-size: 30rpx;
  1522. font-weight: 900;
  1523. display: flex;
  1524. align-items: center;
  1525. justify-content: center;
  1526. }
  1527. .history-button {
  1528. width: 210rpx;
  1529. flex-shrink: 0;
  1530. color: #168cff;
  1531. background: #eef7ff;
  1532. }
  1533. .submit-button {
  1534. flex: 1;
  1535. color: #ffffff;
  1536. background: linear-gradient(135deg, #0b8cff, #39b7ff);
  1537. box-shadow: 0 12rpx 26rpx rgba(11, 140, 255, 0.24);
  1538. }
  1539. .submit-button.disabled {
  1540. color: #95a6b8;
  1541. background: #dfe9f3;
  1542. box-shadow: none;
  1543. }
  1544. .submit-icon {
  1545. width: 25rpx;
  1546. height: 25rpx;
  1547. margin-right: 8rpx;
  1548. flex-shrink: 0;
  1549. }
  1550. @media (min-width: 768px) {
  1551. .content {
  1552. width: 720px;
  1553. margin: 0 auto;
  1554. box-sizing: border-box;
  1555. }
  1556. .bottom-bar {
  1557. left: 50%;
  1558. width: 720px;
  1559. transform: translateX(-50%);
  1560. border-left: 1px solid #e6edf5;
  1561. border-right: 1px solid #e6edf5;
  1562. }
  1563. }
  1564. </style>