You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

613 lines
17 KiB

  1. <template>
  2. <view class="page">
  3. <scroll-view class="scroll" scroll-y>
  4. <view class="wrap">
  5. <view class="hero-card portrait-hero">
  6. <view class="hero-copy">
  7. <text class="eyebrow">PORTRAIT PHOTO STUDIO</text>
  8. <text class="hero-title">生成你的职业形象照</text>
  9. <text class="hero-desc">从商务形象、普通职业到艺术写真,快速得到适合头像和简历的照片。</text>
  10. </view>
  11. <view class="hero-preview">
  12. <view class="portrait-card-mini main">
  13. <view class="portrait-face"></view>
  14. <view class="portrait-suit"></view>
  15. </view>
  16. <view class="portrait-card-mini sub">
  17. <view class="portrait-face"></view>
  18. <view class="portrait-suit"></view>
  19. </view>
  20. </view>
  21. </view>
  22. <view class="upload-card">
  23. <view class="card-head">
  24. <view>
  25. <text class="card-title"><text class="required">*</text>上传照片</text>
  26. <text class="card-desc">建议选择五官无遮挡、光线均匀的半身照</text>
  27. </view>
  28. <text class="card-tip">0/1</text>
  29. </view>
  30. <view class="upload-strip" @click="showTodo('选择人像照片')">
  31. <view class="upload-thumb"><text class="upload-plus">+</text></view>
  32. <view class="upload-copy">
  33. <text class="upload-title">上传正面清晰人像照片</text>
  34. <text class="upload-sub">支持自拍、半身照,生成商务照、职业照或写真</text>
  35. </view>
  36. <view class="upload-action">上传</view>
  37. </view>
  38. </view>
  39. <view class="section">
  40. <view class="section-head">
  41. <text class="tool-section-title">照片风格</text>
  42. <text class="section-tip">选择生成方向</text>
  43. </view>
  44. <view class="segmented">
  45. <view
  46. v-for="item in styleTabs"
  47. :key="item.key"
  48. class="segment"
  49. :class="{ active: activeStyle === item.key }"
  50. @click="activeStyle = item.key"
  51. >
  52. {{ item.name }}
  53. </view>
  54. </view>
  55. <view v-if="activeStyle === 'business'" class="config-panel">
  56. <view class="sub-block">
  57. <text class="sub-title">纯色背景</text>
  58. <view class="option-grid">
  59. <view v-for="item in businessBg" :key="item.name" class="option" :class="{ 'active-first': item.name === '白色' }">
  60. <view class="color-dot" :class="item.className"></view>{{ item.name }}
  61. </view>
  62. </view>
  63. </view>
  64. <view class="sub-block">
  65. <text class="sub-title">服装</text>
  66. <view class="option-grid two">
  67. <view class="option active-first">正装</view>
  68. <view class="option">衬衫</view>
  69. </view>
  70. </view>
  71. <view class="sub-block">
  72. <text class="sub-title">取景范围</text>
  73. <view class="option-grid three">
  74. <view class="option active-first">半身</view>
  75. <view class="option">全身</view>
  76. <view class="option">胸像</view>
  77. </view>
  78. </view>
  79. </view>
  80. <view v-if="activeStyle === 'career'" class="config-panel">
  81. <view class="sub-block">
  82. <text class="sub-title">背景</text>
  83. <view class="option-grid three">
  84. <view v-for="item in careerBg" :key="item" class="option" :class="{ 'active-first': item === '浅灰' }">{{ item }}</view>
  85. </view>
  86. </view>
  87. <view class="sub-block">
  88. <text class="sub-title">服装</text>
  89. <view class="option-grid">
  90. <view v-for="item in careerOutfit" :key="item" class="option" :class="{ 'active-first': item === '针织衫' }">{{ item }}</view>
  91. </view>
  92. </view>
  93. </view>
  94. <view v-if="activeStyle === 'art'" class="config-panel">
  95. <view class="option-grid three">
  96. <view v-for="item in artStyles" :key="item" class="option" :class="{ 'active-first': item === '古风' }">{{ item }}</view>
  97. </view>
  98. </view>
  99. </view>
  100. <view class="section">
  101. <view class="section-head">
  102. <text class="tool-section-title">图片比例</text>
  103. <text class="section-tip">构图尺寸</text>
  104. </view>
  105. <view class="ratio-grid">
  106. <view
  107. v-for="item in ratios"
  108. :key="item"
  109. class="ratio"
  110. :class="{ active: activeRatio === item }"
  111. @click="activeRatio = item"
  112. >
  113. {{ item }}
  114. </view>
  115. </view>
  116. </view>
  117. <view class="section">
  118. <view class="section-head">
  119. <text class="tool-section-title">生成数量</text>
  120. <text class="price">20点/张,共{{ quantity * 20 }}点</text>
  121. </view>
  122. <view class="quantity">
  123. <view class="quantity-btn" @click="decreaseQuantity">-</view>
  124. <text class="quantity-value">{{ quantity }}</text>
  125. <view class="quantity-btn" @click="increaseQuantity">+</view>
  126. </view>
  127. <text class="note">按单张收费,每次最多生成10张</text>
  128. </view>
  129. <view class="section">
  130. <view class="section-head">
  131. <text class="tool-section-title">补充描述</text>
  132. <text class="section-tip">可选</text>
  133. </view>
  134. <textarea class="textarea" placeholder="可补充职业、服装、背景、妆发或照片气质要求"></textarea>
  135. </view>
  136. </view>
  137. </scroll-view>
  138. <view class="bottom-bar">
  139. <view class="balance">
  140. <text class="balance-copy">{{ balanceText }}</text>
  141. <view v-if="isBalanceInsufficient" class="recharge-link" @click="goRecharge">去充值</view>
  142. </view>
  143. <view class="buttons">
  144. <view class="history-btn" @click="showTodo('历史记录')">历史记录</view>
  145. <view class="submit-btn" @click="showTodo('形象照生成')">
  146. <image class="submit-icon" src="/static/icons/bolt.svg" mode="aspectFit" />
  147. <text>{{ submitText }}</text>
  148. </view>
  149. </view>
  150. </view>
  151. </view>
  152. </template>
  153. <script setup>
  154. import { computed, ref, watch } from 'vue'
  155. import { onLoad, onShow } from '@dcloudio/uni-app'
  156. import { billingApi, userApi } from '@/api/index.js'
  157. import { useUserStore } from '@/store/user.js'
  158. import { useApp } from '@/utils/useApp.js'
  159. const userStore = useUserStore()
  160. const { toast } = useApp()
  161. const activeStyle = ref('business')
  162. const activeRatio = ref('1:1')
  163. const quantity = ref(1)
  164. const balance = ref({ points: 0 })
  165. const quote = ref({ points: 0 })
  166. const appKey = 'portrait-photo'
  167. const styleTabs = [
  168. { key: 'business', name: '商务形象照' },
  169. { key: 'career', name: '普通职业照' },
  170. { key: 'art', name: '艺术写真' }
  171. ]
  172. const businessBg = [
  173. { name: '白色', className: 'white' },
  174. { name: '浅灰', className: 'gray' },
  175. { name: '商务蓝', className: 'blue' },
  176. { name: '深蓝', className: 'deep-blue' }
  177. ]
  178. const careerBg = ['浅灰', '米色', '办公室', '书架', '咖啡角', '落地窗']
  179. const careerOutfit = ['针织衫', 'T恤配牛仔裤', '西装外套配T恤']
  180. const artStyles = ['古风', '藏族', '苗族', '都市青年', '潮人', '敦煌飞仙', '港风复古', '新中式', '法式油画']
  181. const ratios = ['1:1', '3:4', '4:3', '9:16', '16:9']
  182. const portraitModel = computed(() => activeStyle.value === 'art' ? 'premium' : 'standard')
  183. const currentPoints = computed(() => Number(balance.value.points || balance.value.point_balance || 0))
  184. const currentCost = computed(() => Number(quote.value.points || 0))
  185. const balanceText = computed(() => {
  186. const cost = currentCost.value
  187. if (!userStore.isLogin) return cost ? `形象照 · ${cost}点/次` : '形象照'
  188. return cost ? `余额${currentPoints.value}点 · 预计消耗${cost}点` : `余额${currentPoints.value}点`
  189. })
  190. const isBalanceInsufficient = computed(() => userStore.isLogin && currentCost.value > 0 && currentPoints.value < currentCost.value)
  191. const submitText = computed(() => currentCost.value ? `立即生成 · ${currentCost.value}点` : '立即生成')
  192. onLoad(() => {
  193. loadBillingConfig()
  194. })
  195. onShow(() => {
  196. if (userStore.isLogin) loadBalance()
  197. loadBillingConfig()
  198. })
  199. watch([activeStyle, quantity], () => {
  200. loadBillingConfig()
  201. })
  202. async function loadBalance() {
  203. try {
  204. const res = await userApi.getBalance()
  205. balance.value = res.data || {}
  206. } catch (e) {}
  207. }
  208. async function loadBillingConfig() {
  209. try {
  210. const res = await billingApi.getConfig({
  211. app_key: appKey,
  212. model: portraitModel.value,
  213. count: quantity.value
  214. })
  215. const data = res.data || {}
  216. quote.value = data.quote || {}
  217. if (data.balance) balance.value = data.balance
  218. } catch (e) {}
  219. }
  220. function goRecharge() {
  221. uni.navigateTo({ url: '/pages/recharge/recharge' })
  222. }
  223. function showTodo(name) {
  224. toast(`${name}逻辑待接入`)
  225. }
  226. function decreaseQuantity() {
  227. quantity.value = Math.max(1, quantity.value - 1)
  228. }
  229. function increaseQuantity() {
  230. quantity.value = Math.min(10, quantity.value + 1)
  231. }
  232. </script>
  233. <style lang="scss" scoped>
  234. .page {
  235. height: 100vh;
  236. background: #f3f7fb;
  237. display: flex;
  238. flex-direction: column;
  239. overflow: hidden;
  240. }
  241. .scroll { flex: 1; height: 0; }
  242. .wrap { padding: 24rpx 28rpx 300rpx; }
  243. .hero-card {
  244. position: relative;
  245. min-height: 260rpx;
  246. padding: 30rpx;
  247. border-radius: 28rpx;
  248. color: #fff;
  249. background: linear-gradient(135deg, #2447d8 0%, #0b8cff 56%, #55d6c6 100%);
  250. overflow: hidden;
  251. box-sizing: border-box;
  252. }
  253. .hero-card::before {
  254. content: '';
  255. position: absolute;
  256. right: -90rpx;
  257. top: -110rpx;
  258. width: 300rpx;
  259. height: 300rpx;
  260. border-radius: 50%;
  261. background: rgba(255, 255, 255, 0.16);
  262. }
  263. .hero-copy,
  264. .hero-preview {
  265. position: relative;
  266. z-index: 1;
  267. }
  268. .eyebrow {
  269. display: block;
  270. color: rgba(255, 255, 255, 0.72);
  271. font-size: 21rpx;
  272. font-weight: 800;
  273. }
  274. .hero-title {
  275. display: block;
  276. margin-top: 16rpx;
  277. width: 430rpx;
  278. color: #fff;
  279. font-size: 40rpx;
  280. font-weight: 900;
  281. line-height: 1.24;
  282. }
  283. .hero-desc {
  284. display: block;
  285. margin-top: 14rpx;
  286. width: 420rpx;
  287. color: rgba(255, 255, 255, 0.86);
  288. font-size: 24rpx;
  289. line-height: 1.5;
  290. }
  291. .hero-preview {
  292. position: absolute;
  293. right: 30rpx;
  294. bottom: 28rpx;
  295. width: 178rpx;
  296. height: 126rpx;
  297. }
  298. .portrait-card-mini {
  299. position: absolute;
  300. width: 92rpx;
  301. height: 120rpx;
  302. border-radius: 18rpx;
  303. overflow: hidden;
  304. box-shadow: 0 16rpx 30rpx rgba(0, 54, 125, 0.22);
  305. }
  306. .portrait-card-mini.main {
  307. left: 0;
  308. top: 0;
  309. background: linear-gradient(180deg, #e8f4ff 0%, #9bd0ff 100%);
  310. }
  311. .portrait-card-mini.sub {
  312. right: 0;
  313. bottom: 0;
  314. background: linear-gradient(180deg, #fff4e7 0%, #ebb875 100%);
  315. }
  316. .portrait-face {
  317. width: 38rpx;
  318. height: 42rpx;
  319. margin: 22rpx auto 0;
  320. border-radius: 50% 50% 45% 45%;
  321. background: #ffe0c4;
  322. }
  323. .portrait-suit {
  324. width: 70rpx;
  325. height: 48rpx;
  326. margin: 4rpx auto 0;
  327. border-radius: 30rpx 30rpx 12rpx 12rpx;
  328. background: #1f3a5f;
  329. }
  330. .upload-card,
  331. .section {
  332. margin-top: 22rpx;
  333. padding: 28rpx;
  334. border-radius: 24rpx;
  335. background: #fff;
  336. box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
  337. }
  338. .upload-card:first-child { margin-top: 0; }
  339. .card-head,
  340. .section-head {
  341. display: flex;
  342. align-items: baseline;
  343. }
  344. .card-title,
  345. .tool-section-title {
  346. display: block;
  347. color: #172033;
  348. font-size: 31rpx;
  349. font-weight: 800;
  350. }
  351. .required { margin-right: 4rpx; color: #ef4444; }
  352. .card-desc {
  353. display: block;
  354. margin-top: 8rpx;
  355. color: #8a95a6;
  356. font-size: 23rpx;
  357. line-height: 1.35;
  358. }
  359. .card-tip,
  360. .section-tip {
  361. margin-left: auto;
  362. color: #8a95a6;
  363. font-size: 23rpx;
  364. }
  365. .upload-strip {
  366. margin-top: 22rpx;
  367. min-height: 142rpx;
  368. padding: 18rpx;
  369. border: 2rpx dashed rgba(11, 140, 255, 0.28);
  370. border-radius: 20rpx;
  371. background: #f8fbff;
  372. display: flex;
  373. align-items: center;
  374. box-sizing: border-box;
  375. }
  376. .upload-thumb {
  377. width: 104rpx;
  378. height: 104rpx;
  379. border-radius: 18rpx;
  380. background: #eef6ff;
  381. display: flex;
  382. align-items: center;
  383. justify-content: center;
  384. flex-shrink: 0;
  385. }
  386. .upload-plus { color: #0b8cff; font-size: 48rpx; line-height: 1; }
  387. .upload-copy { min-width: 0; flex: 1; margin-left: 20rpx; }
  388. .upload-title { display: block; color: #172033; font-size: 29rpx; font-weight: 800; }
  389. .upload-sub { display: block; margin-top: 8rpx; color: #7f8896; font-size: 23rpx; line-height: 1.4; }
  390. .upload-action { margin-left: 18rpx; color: #0b8cff; font-size: 25rpx; font-weight: 800; }
  391. .segmented {
  392. margin-top: 22rpx;
  393. padding: 6rpx;
  394. border-radius: 18rpx;
  395. background: #f7f9fd;
  396. display: grid;
  397. grid-template-columns: repeat(3, minmax(0, 1fr));
  398. gap: 6rpx;
  399. }
  400. .segment {
  401. height: 66rpx;
  402. border-radius: 16rpx;
  403. color: #596579;
  404. font-size: 24rpx;
  405. font-weight: 800;
  406. line-height: 66rpx;
  407. text-align: center;
  408. }
  409. .segment.active {
  410. color: #0b8cff;
  411. background: #fff;
  412. box-shadow: 0 8rpx 18rpx rgba(11, 140, 255, 0.12);
  413. }
  414. .config-panel {
  415. margin-top: 24rpx;
  416. padding-top: 22rpx;
  417. border-top: 1rpx solid #edf2f7;
  418. }
  419. .sub-block + .sub-block { margin-top: 24rpx; }
  420. .sub-title {
  421. display: block;
  422. margin-bottom: 14rpx;
  423. color: #7f8896;
  424. font-size: 24rpx;
  425. font-weight: 800;
  426. }
  427. .option-grid {
  428. display: grid;
  429. grid-template-columns: repeat(2, minmax(0, 1fr));
  430. gap: 14rpx;
  431. }
  432. .option-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  433. .option-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  434. .option {
  435. min-height: 66rpx;
  436. padding: 0 12rpx;
  437. border-radius: 16rpx;
  438. color: #596579;
  439. font-size: 24rpx;
  440. font-weight: 800;
  441. background: #f7f9fd;
  442. border: 2rpx solid transparent;
  443. display: flex;
  444. align-items: center;
  445. justify-content: center;
  446. gap: 10rpx;
  447. text-align: center;
  448. box-sizing: border-box;
  449. }
  450. .option.active-first {
  451. color: #0b8cff;
  452. background: #eff8ff;
  453. border-color: #0b8cff;
  454. }
  455. .color-dot {
  456. width: 28rpx;
  457. height: 28rpx;
  458. border-radius: 50%;
  459. border: 1rpx solid rgba(15, 23, 42, 0.16);
  460. flex-shrink: 0;
  461. }
  462. .white { background: #fff; }
  463. .gray { background: #d9dde3; }
  464. .blue { background: #6aa7e8; }
  465. .deep-blue { background: #245a9c; }
  466. .ratio-grid {
  467. margin-top: 20rpx;
  468. display: grid;
  469. grid-template-columns: repeat(5, minmax(0, 1fr));
  470. gap: 10rpx;
  471. }
  472. .ratio {
  473. height: 64rpx;
  474. border-radius: 16rpx;
  475. color: #596579;
  476. font-size: 24rpx;
  477. font-weight: 800;
  478. line-height: 64rpx;
  479. text-align: center;
  480. background: #f7f9fd;
  481. border: 2rpx solid transparent;
  482. }
  483. .ratio.active {
  484. color: #0b8cff;
  485. background: #eff8ff;
  486. border-color: #0b8cff;
  487. }
  488. .price {
  489. margin-left: auto;
  490. color: #ff681f;
  491. font-size: 24rpx;
  492. font-weight: 800;
  493. }
  494. .quantity {
  495. margin-top: 22rpx;
  496. display: grid;
  497. grid-template-columns: 80rpx minmax(0, 1fr) 80rpx;
  498. gap: 18rpx;
  499. align-items: center;
  500. }
  501. .quantity-btn {
  502. height: 72rpx;
  503. border-radius: 16rpx;
  504. color: #fff;
  505. background: #0b8cff;
  506. font-size: 40rpx;
  507. font-weight: 800;
  508. line-height: 72rpx;
  509. text-align: center;
  510. }
  511. .quantity-value {
  512. height: 72rpx;
  513. border-radius: 16rpx;
  514. color: #172033;
  515. background: #f7f9fd;
  516. font-size: 32rpx;
  517. font-weight: 800;
  518. line-height: 72rpx;
  519. text-align: center;
  520. }
  521. .note {
  522. display: block;
  523. margin-top: 14rpx;
  524. color: #8a95a6;
  525. font-size: 23rpx;
  526. }
  527. .textarea {
  528. margin-top: 20rpx;
  529. width: 100%;
  530. min-height: 144rpx;
  531. padding: 22rpx;
  532. border-radius: 20rpx;
  533. color: #172033;
  534. font-size: 25rpx;
  535. line-height: 1.5;
  536. background: #f7f9fd;
  537. box-sizing: border-box;
  538. }
  539. .bottom-bar {
  540. position: fixed;
  541. left: 0;
  542. right: 0;
  543. bottom: 0;
  544. z-index: 80;
  545. padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
  546. background: rgba(255, 255, 255, 0.96);
  547. box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1);
  548. }
  549. .balance {
  550. margin-bottom: 14rpx;
  551. display: flex;
  552. align-items: center;
  553. justify-content: space-between;
  554. gap: 20rpx;
  555. color: #7b8797;
  556. font-size: 24rpx;
  557. line-height: 1.35;
  558. }
  559. .balance-copy { min-width: 0; flex: 1; }
  560. .recharge-link { color: #0b8cff; font-size: 24rpx; font-weight: 800; flex-shrink: 0; }
  561. .buttons { display: flex; gap: 22rpx; }
  562. .history-btn,
  563. .submit-btn {
  564. height: 86rpx;
  565. border-radius: 999rpx;
  566. font-size: 30rpx;
  567. font-weight: 800;
  568. display: flex;
  569. align-items: center;
  570. justify-content: center;
  571. }
  572. .history-btn { width: 210rpx; color: #1f8cff; background: #eef7ff; }
  573. .submit-btn {
  574. flex: 1;
  575. color: #fff;
  576. background: linear-gradient(135deg, #1f8cff, #41b9ff);
  577. box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28);
  578. }
  579. .submit-icon {
  580. width: 24rpx;
  581. height: 24rpx;
  582. margin-right: 8rpx;
  583. }
  584. </style>