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.
 
 
 

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