| @@ -45,6 +45,11 @@ export default { | |||
| INTERFACE_POINT_GET_LOG_LIST: '/point/loglist', // 点数账单流水 | |||
| INTERFACE_POINT_POST_CREATE_ORDER: '/point/createorder', // 创建充值订单 | |||
| INTERFACE_POINT_GET_ORDER_STATUS: '/point/orderstatus', // 订单状态轮询 | |||
| INTERFACE_POINT_GET_EARN_CONFIG: '/point/earnconfig', // 积分获取页配置(签到 + 邀请 + 看广告) | |||
| INTERFACE_POINT_POST_CHECKIN: '/point/checkin', // 每日签到领取积分 | |||
| INTERFACE_POINT_POST_AD_TICKET: '/point/adticket', // 看广告前领取一次性凭证 | |||
| INTERFACE_POINT_POST_AD_REWARD: '/point/adreward', // 看完广告发放积分 | |||
| INTERFACE_POINT_POST_AD_CANCEL: '/point/adcancel', // 广告未看完,作废凭证 | |||
| INTERFACE_BILLING_GET_CONFIG: '/billing/config', // 产品计费配置 | |||
| INTERFACE_BILLING_POST_QUOTE: '/billing/quote', // 产品计价 | |||
| @@ -63,6 +68,25 @@ export default { | |||
| INTERFACE_ANIME_GET_DETAIL: '/anime/getdetail', // 动漫头像详情 | |||
| INTERFACE_ANIME_POST_DEL: '/anime/del', // 动漫头像删除 | |||
| INTERFACE_ANIME_GET_STYLE_LIST: '/anime/getstylelist', // 动漫头像风格 | |||
| INTERFACE_IDPHOTO_GET_CONFIG: '/idphoto/config', | |||
| INTERFACE_IDPHOTO_POST_CREATE: '/idphoto/create', | |||
| INTERFACE_IDPHOTO_POST_RERENDER: '/idphoto/rerender', | |||
| INTERFACE_IDPHOTO_GET_DETAIL: '/idphoto/getdetail', | |||
| INTERFACE_IDPHOTO_POST_DEL: '/idphoto/del', | |||
| // 形象照/职业照/写真(混元生图 3.0,一次可出多张) | |||
| INTERFACE_PORTRAIT_GET_CONFIG: '/portrait/config', | |||
| INTERFACE_PORTRAIT_POST_CREATE: '/portrait/create', | |||
| INTERFACE_PORTRAIT_GET_DETAIL: '/portrait/getdetail', | |||
| INTERFACE_PORTRAIT_GET_LIST: '/portrait/getlist', | |||
| INTERFACE_PORTRAIT_POST_DEL: '/portrait/del', | |||
| // 老照片修复/上色(混元生图 3.0,异步任务 + 轮询) | |||
| INTERFACE_OLDPHOTO_GET_CONFIG: '/oldphoto/config', | |||
| INTERFACE_OLDPHOTO_POST_CREATE: '/oldphoto/create', | |||
| INTERFACE_OLDPHOTO_GET_DETAIL: '/oldphoto/getdetail', | |||
| INTERFACE_OLDPHOTO_GET_LIST: '/oldphoto/getlist', | |||
| INTERFACE_OLDPHOTO_POST_DEL: '/oldphoto/del', | |||
| // 机器人/文本生成(旧项目 AI 写作/取名接口) | |||
| INTERFACE_BOT_POST_EDIT: '/robot/edit', // 创建/编辑机器人 | |||
| @@ -66,7 +66,15 @@ export const pointApi = { | |||
| // 创建充值订单(小程序返回虚拟支付参数) | |||
| createOrder: (params) => request({ url: API.INTERFACE_POINT_POST_CREATE_ORDER, method: 'POST', params }), | |||
| // 订单状态轮询 | |||
| getOrderStatus: (params) => request({ url: API.INTERFACE_POINT_GET_ORDER_STATUS, method: 'POST', params }) | |||
| getOrderStatus: (params) => request({ url: API.INTERFACE_POINT_GET_ORDER_STATUS, method: 'POST', params }), | |||
| // 积分获取页配置(签到、邀请、看广告配置与今日进度,免登录可调) | |||
| getEarnConfig: (params) => request({ url: API.INTERFACE_POINT_GET_EARN_CONFIG, method: 'POST', params }), | |||
| // 每日签到,服务端按用户与日期幂等发放 | |||
| checkin: (params) => request({ url: API.INTERFACE_POINT_POST_CHECKIN, method: 'POST', params }), | |||
| // 看广告:先取一次性凭证,看完再兑换积分,未看完则作废 | |||
| getAdTicket: (params) => request({ url: API.INTERFACE_POINT_POST_AD_TICKET, method: 'POST', params }), | |||
| getAdReward: (params) => request({ url: API.INTERFACE_POINT_POST_AD_REWARD, method: 'POST', params }), | |||
| cancelAdTicket: (params) => request({ url: API.INTERFACE_POINT_POST_AD_CANCEL, method: 'POST', params }) | |||
| } | |||
| export const billingApi = { | |||
| @@ -74,6 +82,34 @@ export const billingApi = { | |||
| quote: (params) => request({ url: API.INTERFACE_BILLING_POST_QUOTE, method: 'POST', params }) | |||
| } | |||
| export const idPhotoApi = { | |||
| getConfig: (params) => request({ url: API.INTERFACE_IDPHOTO_GET_CONFIG, method: 'POST', params }), | |||
| getList: (params = {}) => request({ | |||
| url: API.INTERFACE_WORK_GET_LIST, | |||
| method: 'POST', | |||
| params: { ...params, type: 'photo' } | |||
| }), | |||
| rerender: (params) => request({ url: API.INTERFACE_IDPHOTO_POST_RERENDER, method: 'POST', params }), | |||
| getDetail: (params) => request({ url: API.INTERFACE_IDPHOTO_GET_DETAIL, method: 'POST', params }), | |||
| delete: (params) => request({ url: API.INTERFACE_IDPHOTO_POST_DEL, method: 'POST', params }) | |||
| } | |||
| export const portraitApi = { | |||
| getConfig: (params) => request({ url: API.INTERFACE_PORTRAIT_GET_CONFIG, method: 'POST', params }), | |||
| getDetail: (params) => request({ url: API.INTERFACE_PORTRAIT_GET_DETAIL, method: 'POST', params }), | |||
| getList: (params) => request({ url: API.INTERFACE_PORTRAIT_GET_LIST, method: 'POST', params }), | |||
| delete: (params) => request({ url: API.INTERFACE_PORTRAIT_POST_DEL, method: 'POST', params }) | |||
| // create 走 multipart 直传,见 utils/upload.js 的 createPortrait | |||
| } | |||
| export const oldPhotoApi = { | |||
| getConfig: (params) => request({ url: API.INTERFACE_OLDPHOTO_GET_CONFIG, method: 'POST', params }), | |||
| getDetail: (params) => request({ url: API.INTERFACE_OLDPHOTO_GET_DETAIL, method: 'POST', params }), | |||
| getList: (params) => request({ url: API.INTERFACE_OLDPHOTO_GET_LIST, method: 'POST', params }), | |||
| delete: (params) => request({ url: API.INTERFACE_OLDPHOTO_POST_DEL, method: 'POST', params }) | |||
| // create 走 multipart 直传,见 utils/upload.js 的 createOldPhoto | |||
| } | |||
| export const toolApi = { | |||
| getMyWorkList: (params) => request({ url: API.INTERFACE_WORK_GET_LIST, method: 'POST', params }), | |||
| getAppList: (params) => request({ url: API.INTERFACE_APP_GET_LIST, method: 'POST', params }), | |||
| @@ -114,5 +150,8 @@ export default { | |||
| vip: vipApi, | |||
| point: pointApi, | |||
| billing: billingApi, | |||
| idPhoto: idPhotoApi, | |||
| oldPhoto: oldPhotoApi, | |||
| portrait: portraitApi, | |||
| tool: toolApi | |||
| } | |||
| @@ -71,18 +71,54 @@ | |||
| "navigationBarTitleText": "证件照" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/id-photo-history", | |||
| "style": { | |||
| "navigationBarTitleText": "证件照记录" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/id-photo-detail", | |||
| "style": { | |||
| "navigationBarTitleText": "证件照详情" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/portrait-photo", | |||
| "style": { | |||
| "navigationBarTitleText": "形象照/职业照/写真" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/portrait-history", | |||
| "style": { | |||
| "navigationBarTitleText": "生成记录" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/portrait-detail", | |||
| "style": { | |||
| "navigationBarTitleText": "形象照详情" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/old-photo", | |||
| "style": { | |||
| "navigationBarTitleText": "老照片修复/上色" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/old-photo-history", | |||
| "style": { | |||
| "navigationBarTitleText": "历史记录" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/old-photo-detail", | |||
| "style": { | |||
| "navigationBarTitleText": "老照片详情" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/tool/work-detail", | |||
| "style": { | |||
| @@ -122,6 +158,36 @@ | |||
| "backgroundColor": "#f3f7fb" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/point/earn", | |||
| "style": { | |||
| "navigationBarTitleText": "获得点数" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/my/wechat", | |||
| "style": { | |||
| "navigationBarTitleText": "关注公众号" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/my/feedback", | |||
| "style": { | |||
| "navigationBarTitleText": "意见反馈" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/my/contact", | |||
| "style": { | |||
| "navigationBarTitleText": "联系我们" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/my/setting", | |||
| "style": { | |||
| "navigationBarTitleText": "设置" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/invite/invite", | |||
| "style": { | |||
| @@ -1,45 +1,130 @@ | |||
| <template> | |||
| <view class="agreement-page"> | |||
| <!-- #ifdef H5 --> | |||
| <view class="txt" v-html="detail.content"></view> | |||
| <!-- #endif --> | |||
| <!-- #ifndef H5 --> | |||
| <rich-text class="txt" :nodes="detail.content || ''"></rich-text> | |||
| <!-- #endif --> | |||
| <!-- 加载中:协议正文有 3 万多字,等待期间给骨架反馈 --> | |||
| <view v-if="loading" class="skeleton"> | |||
| <view v-for="i in 8" :key="i" class="skeleton-line" :class="{ short: i % 4 === 0 }"></view> | |||
| </view> | |||
| <!-- 富文本正文:统一用 uview-plus 的 up-parse 渲染,各端表现一致 --> | |||
| <up-parse v-else-if="content" :content="content" :tag-style="tagStyle" selectable /> | |||
| <!-- 空态 / 失败态:内容缺失时不能是白屏 --> | |||
| <view v-else class="empty"> | |||
| <text class="empty-title">{{ errorMsg ? '内容加载失败' : '内容暂未发布' }}</text> | |||
| <text class="empty-desc">{{ errorMsg || '该内容正在整理中,可稍后再来查看。' }}</text> | |||
| <view class="empty-btn" @click="loadHtml">重新加载</view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { computed, ref } from 'vue' | |||
| import { onLoad } from '@dcloudio/uni-app' | |||
| import { commonApi } from '@/api/index.js' | |||
| import { cleanRichText } from '@/utils/richtext.js' | |||
| /** | |||
| * 富文本标签样式重置。 | |||
| * 后台正文是 Word 导出的,标签用得很随意(p 套 span、ul 无 li 缩进、字号写死 14px), | |||
| * 这里统一接管排版:正文 28rpx / 行高 1.8,段间距用 margin 而不是空段落。 | |||
| */ | |||
| const tagStyle = { | |||
| p: 'margin: 0 0 20rpx; padding: 0; line-height: 1.8; font-size: 28rpx; color: #172033; word-break: break-word;', | |||
| div: 'line-height: 1.8; font-size: 28rpx; color: #172033; word-break: break-word;', | |||
| span: 'font-size: 28rpx; line-height: 1.8; color: #172033;', | |||
| h1: 'margin: 34rpx 0 16rpx; padding: 0; font-size: 34rpx; font-weight: 700; line-height: 1.45; color: #172033;', | |||
| h2: 'margin: 30rpx 0 14rpx; padding: 0; font-size: 32rpx; font-weight: 700; line-height: 1.45; color: #172033;', | |||
| h3: 'margin: 28rpx 0 12rpx; padding: 0; font-size: 30rpx; font-weight: 700; line-height: 1.45; color: #172033;', | |||
| h4: 'margin: 26rpx 0 12rpx; padding: 0; font-size: 29rpx; font-weight: 700; line-height: 1.45; color: #172033;', | |||
| ul: 'margin: 16rpx 0; padding-left: 44rpx; list-style: disc;', | |||
| ol: 'margin: 16rpx 0; padding-left: 44rpx; list-style: decimal;', | |||
| li: 'margin: 0 0 10rpx; line-height: 1.8; font-size: 28rpx; color: #172033;', | |||
| strong: 'font-weight: 700; color: #172033;', | |||
| b: 'font-weight: 700; color: #172033;', | |||
| a: 'color: #1f8cff; word-break: break-all;', | |||
| br: 'line-height: 1.8;', | |||
| hr: 'margin: 28rpx 0; border: none; border-top: 1rpx solid #eef2f7;', | |||
| img: 'max-width: 100%; height: auto; border-radius: 12rpx;', | |||
| table: 'width: 100%; margin: 20rpx 0; border-collapse: collapse; font-size: 26rpx;', | |||
| td: 'padding: 12rpx; border: 1rpx solid #e6ebf2; line-height: 1.6;', | |||
| th: 'padding: 12rpx; border: 1rpx solid #e6ebf2; background: #f7f9fd; font-weight: 700; line-height: 1.6;' | |||
| } | |||
| const detail = ref({}) | |||
| const loading = ref(true) | |||
| const errorMsg = ref('') | |||
| let configKey = '' | |||
| // 清洗后的正文:去空段落、剥内联字体与颜色,排版交给上面的 tagStyle | |||
| const content = computed(() => cleanRichText(detail.value.content)) | |||
| onLoad((options) => { | |||
| getHtmlInfo(options.key) | |||
| configKey = (options && options.key) || '' | |||
| loadHtml() | |||
| }) | |||
| async function getHtmlInfo(key) { | |||
| async function loadHtml() { | |||
| if (!configKey) { | |||
| loading.value = false | |||
| errorMsg.value = '缺少内容标识' | |||
| return | |||
| } | |||
| loading.value = true | |||
| errorMsg.value = '' | |||
| try { | |||
| const res = await commonApi.getHtml({ key }) | |||
| const res = await commonApi.getHtml({ key: configKey }) | |||
| detail.value = res.data || {} | |||
| if (res.data && res.data.title) { | |||
| uni.setNavigationBarTitle({ title: res.data.title }) | |||
| if (detail.value.title) { | |||
| uni.setNavigationBarTitle({ title: detail.value.title }) | |||
| } | |||
| } catch (e) { | |||
| // 忽略 | |||
| errorMsg.value = (e && e.msg) || '网络异常,请稍后重试' | |||
| } finally { | |||
| loading.value = false | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .agreement-page { | |||
| padding: 30rpx; | |||
| .txt { | |||
| line-height: 1.6; | |||
| font-size: 28rpx; | |||
| color: $ai-text; | |||
| } | |||
| min-height: 100vh; | |||
| padding: 30rpx 30rpx 60rpx; | |||
| background: #ffffff; | |||
| box-sizing: border-box; | |||
| } | |||
| .skeleton-line { | |||
| height: 30rpx; | |||
| margin-bottom: 22rpx; | |||
| border-radius: 8rpx; | |||
| background: #f1f5fa; | |||
| &.short { width: 60%; } | |||
| } | |||
| .empty { | |||
| padding: 160rpx 40rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| text-align: center; | |||
| } | |||
| .empty-title { color: $ai-text; font-size: 32rpx; font-weight: 700; } | |||
| .empty-desc { | |||
| margin-top: 14rpx; | |||
| color: $ai-muted; | |||
| font-size: 26rpx; | |||
| line-height: 1.6; | |||
| } | |||
| .empty-btn { | |||
| margin-top: 30rpx; | |||
| width: 240rpx; | |||
| height: 76rpx; | |||
| border-radius: 999rpx; | |||
| color: #1f8cff; | |||
| background: #eef7ff; | |||
| font-size: 27rpx; | |||
| font-weight: 700; | |||
| line-height: 76rpx; | |||
| } | |||
| </style> | |||
| @@ -54,6 +54,11 @@ | |||
| <text class="ledger-type" :class="item.type">{{ item.type_text }}</text> | |||
| <text>{{ item.time }}</text> | |||
| </view> | |||
| <!-- 充值流水带上订单号,方便对账 / 找客服核单,点一下可复制 --> | |||
| <view v-if="orderNo(item)" class="ledger-order" @click="copyOrderNo(orderNo(item))"> | |||
| <text class="ledger-order-text">订单号 {{ orderNo(item) }}</text> | |||
| <text class="ledger-order-copy">复制</text> | |||
| </view> | |||
| </view> | |||
| <text class="ledger-amount" :class="item.amount > 0 ? 'plus' : 'minus'"> | |||
| {{ formatAmount(item.amount) }} | |||
| @@ -255,6 +260,24 @@ function formatAmount(value) { | |||
| return num > 0 ? `+${num}` : String(num) | |||
| } | |||
| /** | |||
| * 充值流水的订单号。 | |||
| * 后端 point_log.biz_no 在充值时存的就是 point_order.order_no(消费存的是计费流水号,不展示)。 | |||
| */ | |||
| function orderNo(item) { | |||
| if (!item || item.type !== 'recharge') return '' | |||
| return String(item.biz_no || '').trim() | |||
| } | |||
| function copyOrderNo(value) { | |||
| if (!value) return | |||
| uni.setClipboardData({ | |||
| data: value, | |||
| success: () => toast('订单号已复制'), | |||
| fail: () => toast('复制失败,请长按选择') | |||
| }) | |||
| } | |||
| function getTypeMark(type) { | |||
| return typeMark[type] || '记' | |||
| } | |||
| @@ -574,6 +597,30 @@ function goRecharge() { | |||
| font-size: 22rpx; | |||
| } | |||
| .ledger-order { | |||
| margin-top: 10rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 10rpx; | |||
| } | |||
| .ledger-order-text { | |||
| min-width: 0; | |||
| flex: 1; | |||
| color: #9aa4b2; | |||
| font-size: 21rpx; | |||
| overflow: hidden; | |||
| white-space: nowrap; | |||
| text-overflow: ellipsis; | |||
| } | |||
| .ledger-order-copy { | |||
| flex-shrink: 0; | |||
| color: #0b8cff; | |||
| font-size: 21rpx; | |||
| font-weight: 800; | |||
| } | |||
| .ledger-type { | |||
| height: 34rpx; | |||
| padding: 0 14rpx; | |||
| @@ -3,10 +3,9 @@ | |||
| <view class="logo-section"> | |||
| <image | |||
| class="logo-img" | |||
| src="https://cdn.aionline.cc/logo/logo_aionline.svg" | |||
| src="https://cdn.aionline.cc/logo/logo_aionline_black.svg" | |||
| mode="widthFix" | |||
| ></image> | |||
| <text class="login-tip">登录后同步作品、点数和创作记录</text> | |||
| </view> | |||
| <view class="login-actions"> | |||
| @@ -23,14 +22,14 @@ | |||
| @getphonenumber="getPhoneNumber" | |||
| @click="onPhoneLoginClick" | |||
| > | |||
| <up-icon class="btn-icon" name="phone-fill" size="24" color="#46bb36"></up-icon> | |||
| <up-icon class="btn-icon" name="phone-fill" size="24" color="#0b8cff"></up-icon> | |||
| <text>手机号登录</text> | |||
| </button> | |||
| <!-- #endif --> | |||
| <!-- #ifndef MP-WEIXIN --> | |||
| <button class="login-btn phone" hover-class="login-btn-hover" @click="onPhoneLoginClick"> | |||
| <up-icon class="btn-icon" name="phone-fill" size="24" color="#46bb36"></up-icon> | |||
| <up-icon class="btn-icon" name="phone-fill" size="24" color="#0b8cff"></up-icon> | |||
| <text>手机号登录</text> | |||
| </button> | |||
| <!-- #endif --> | |||
| @@ -69,6 +68,7 @@ import { userApi } from '@/api/index.js' | |||
| import { useUserStore } from '@/store/user.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| import { isInWechat } from '@/utils/util.js' | |||
| import { rememberLoginSource, returnAfterLogin } from '@/utils/login.js' | |||
| import CONFIG from '@/utils/config.js' | |||
| const userStore = useUserStore() | |||
| @@ -89,9 +89,9 @@ onLoad((options = {}) => { | |||
| type.value = options.type || '1' | |||
| source.value = options.source || '' | |||
| const redirect = options.redirect || options.url || '' | |||
| rememberLoginSource(redirect) | |||
| // #ifdef H5 | |||
| if (redirect) uni.setStorageSync('login_redirect', decodeURIComponent(redirect)) | |||
| if (options.code) postLogin(options.code) // 公众号授权回跳 | |||
| // #endif | |||
| @@ -260,10 +260,10 @@ function afterLogin(res) { | |||
| if (inviteCode) uni.removeStorageSync('invite_code') | |||
| // 统一跳回首页 | |||
| // 返回发起登录的页面,navigateBack/reLaunch 都会触发目标页重新读取登录态 | |||
| setTimeout(() => { | |||
| uni.switchTab({ url: '/pages/toolbox/toolbox' }) | |||
| }, 1200) | |||
| returnAfterLogin() | |||
| }, 800) | |||
| } | |||
| </script> | |||
| @@ -289,13 +289,6 @@ function afterLogin(res) { | |||
| display: block; | |||
| } | |||
| .login-tip { | |||
| margin-top: 24rpx; | |||
| color: #7f8896; | |||
| font-size: 26rpx; | |||
| line-height: 1.4; | |||
| } | |||
| .login-actions { | |||
| margin-top: 92rpx; | |||
| display: flex; | |||
| @@ -329,14 +322,14 @@ function afterLogin(res) { | |||
| &.wechat { | |||
| color: #fff; | |||
| background: #46bb36; | |||
| box-shadow: 0 14rpx 28rpx rgba(70, 187, 54, 0.18); | |||
| background: #0b8cff; | |||
| box-shadow: 0 14rpx 28rpx rgba(11, 140, 255, 0.2); | |||
| } | |||
| &.phone { | |||
| color: #46bb36; | |||
| color: #0b8cff; | |||
| background: #ffffff; | |||
| border: 2rpx solid rgba(70, 187, 54, 0.55); | |||
| border: 2rpx solid rgba(11, 140, 255, 0.48); | |||
| } | |||
| } | |||
| @@ -396,8 +389,8 @@ function afterLogin(res) { | |||
| flex-shrink: 0; | |||
| &.checked { | |||
| background: #46bb36; | |||
| border-color: #46bb36; | |||
| background: #0b8cff; | |||
| border-color: #0b8cff; | |||
| } | |||
| } | |||
| @@ -23,9 +23,11 @@ | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { onLoad } from '@dcloudio/uni-app' | |||
| import { userApi } from '@/api/index.js' | |||
| import { useUserStore } from '@/store/user.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| import { rememberLoginSource, returnAfterLogin } from '@/utils/login.js' | |||
| const userStore = useUserStore() | |||
| const { toast } = useApp() | |||
| @@ -37,6 +39,10 @@ const postLoading = ref(false) | |||
| const counting = ref(false) | |||
| const count = ref(60) | |||
| onLoad((options = {}) => { | |||
| rememberLoginSource(options.redirect || options.url || '') | |||
| }) | |||
| function sendCode() { | |||
| if (counting.value) return | |||
| if (!/^1\d{10}$/.test(phone.value)) return toast('请输入正确的手机号') | |||
| @@ -66,17 +72,7 @@ async function onLogin() { | |||
| const res = await userApi.loginPhone({ phone: phone.value, code: code.value }) | |||
| userStore.setLogin({ token: res.data.token, userInfo: res.data }) | |||
| toast('登录成功!') | |||
| // #ifdef H5 | |||
| const redirect = uni.getStorageSync('login_redirect') || '' | |||
| if (redirect) { | |||
| uni.removeStorageSync('login_redirect') | |||
| setTimeout(() => uni.reLaunch({ url: redirect }), 800) | |||
| return | |||
| } | |||
| // #endif | |||
| setTimeout(() => uni.switchTab({ url: '/pages/toolbox/toolbox' }), 800) | |||
| setTimeout(() => returnAfterLogin(), 800) | |||
| } catch (e) { | |||
| toast(e.msg || '登录失败,请重试!') | |||
| } finally { | |||
| @@ -0,0 +1,166 @@ | |||
| <template> | |||
| <view class="wrap"> | |||
| <view class="hero-card"> | |||
| <text class="eyebrow">CONTACT US</text> | |||
| <text class="hero-title">联系我们</text> | |||
| <text class="hero-desc">使用问题、商业合作都可以通过下面的方式找到我们。</text> | |||
| </view> | |||
| <view class="ct-card"> | |||
| <view v-for="item in contacts" :key="item.key" class="row" @click="copy(item.value)"> | |||
| <view class="row-copy"> | |||
| <text class="row-label">{{ item.label }}</text> | |||
| <text class="row-value">{{ item.value }}</text> | |||
| </view> | |||
| <view class="row-btn">复制</view> | |||
| </view> | |||
| </view> | |||
| <!-- 微信客服会话只有小程序端有 --> | |||
| <!-- #ifdef MP-WEIXIN --> | |||
| <view class="ct-card"> | |||
| <view class="row-copy"> | |||
| <text class="row-label">在线客服</text> | |||
| <text class="row-value light">工作日 09:00 - 18:00 在线,其他时段留言也会回复</text> | |||
| </view> | |||
| <button class="service-btn" open-type="contact" session-from="mp_contact"> | |||
| 进入客服中心 | |||
| </button> | |||
| </view> | |||
| <!-- #endif --> | |||
| <view class="tip-card"> | |||
| <text class="tip-title">反馈更快被处理的小技巧</text> | |||
| <text class="tip-line">· 描述问题时带上出现的页面与操作步骤</text> | |||
| <text class="tip-line">· 附上问题截图,我们能更快复现</text> | |||
| <text class="tip-line">· 也可以直接在「意见反馈」里提交,会同步给我们</text> | |||
| <view class="tip-link" @click="goFeedback">去意见反馈</view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const { toast } = useApp() | |||
| // 邮箱与旧版保持一致 | |||
| const contacts = [ | |||
| { key: 'contact', label: '联系邮箱', value: 'contact@aionline.cc' }, | |||
| { key: 'market', label: '商业合作', value: 'market@aionline.cc' } | |||
| ] | |||
| function copy(content) { | |||
| uni.setClipboardData({ | |||
| data: content, | |||
| success: () => toast('已复制'), | |||
| fail: () => toast('复制失败') | |||
| }) | |||
| } | |||
| function goFeedback() { | |||
| uni.navigateTo({ url: '/pages/my/feedback' }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .wrap { | |||
| min-height: 100vh; | |||
| padding: 24rpx 28rpx 60rpx; | |||
| background: #f3f7fb; | |||
| box-sizing: border-box; | |||
| } | |||
| .hero-card { | |||
| padding: 32rpx; | |||
| border-radius: 26rpx; | |||
| background: linear-gradient(135deg, #1f8cff, #41b9ff); | |||
| } | |||
| .eyebrow { display: block; color: rgba(255, 255, 255, 0.75); font-size: 21rpx; font-weight: 800; } | |||
| .hero-title { display: block; margin-top: 12rpx; color: #fff; font-size: 40rpx; font-weight: 900; } | |||
| .hero-desc { | |||
| display: block; | |||
| margin-top: 12rpx; | |||
| color: rgba(255, 255, 255, 0.85); | |||
| font-size: 23rpx; | |||
| line-height: 1.5; | |||
| } | |||
| /* 类名带 ct- 前缀,避开全局 .card 的 margin/box-shadow */ | |||
| .ct-card { | |||
| margin-top: 22rpx; | |||
| padding: 28rpx; | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| } | |||
| .row { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 20rpx; | |||
| padding: 16rpx 0; | |||
| } | |||
| .row + .row { border-top: 2rpx solid #f1f5fa; } | |||
| .row-copy { flex: 1; min-width: 0; } | |||
| .row-label { display: block; color: #8a95a6; font-size: 23rpx; } | |||
| .row-value { | |||
| display: block; | |||
| margin-top: 8rpx; | |||
| color: #172033; | |||
| font-size: 29rpx; | |||
| font-weight: 800; | |||
| word-break: break-all; | |||
| } | |||
| .row-value.light { color: #7f8896; font-size: 24rpx; font-weight: 400; line-height: 1.5; } | |||
| .row-btn { | |||
| flex-shrink: 0; | |||
| height: 60rpx; | |||
| padding: 0 24rpx; | |||
| border-radius: 999rpx; | |||
| color: #1f8cff; | |||
| background: #eef7ff; | |||
| font-size: 24rpx; | |||
| font-weight: 800; | |||
| line-height: 60rpx; | |||
| } | |||
| .service-btn { | |||
| margin-top: 22rpx; | |||
| height: 84rpx; | |||
| border-radius: 999rpx; | |||
| color: #fff; | |||
| background: linear-gradient(135deg, #1f8cff, #41b9ff); | |||
| font-size: 28rpx; | |||
| font-weight: 800; | |||
| line-height: 84rpx; | |||
| border: none; | |||
| &::after { border: none; } | |||
| } | |||
| .tip-card { | |||
| margin-top: 22rpx; | |||
| padding: 28rpx; | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| } | |||
| .tip-title { display: block; margin-bottom: 12rpx; color: #172033; font-size: 28rpx; font-weight: 800; } | |||
| .tip-line { | |||
| display: block; | |||
| margin-top: 8rpx; | |||
| color: #7f8896; | |||
| font-size: 23rpx; | |||
| line-height: 1.6; | |||
| } | |||
| .tip-link { | |||
| margin-top: 20rpx; | |||
| height: 72rpx; | |||
| border-radius: 999rpx; | |||
| color: #1f8cff; | |||
| background: #eef7ff; | |||
| font-size: 26rpx; | |||
| font-weight: 800; | |||
| line-height: 72rpx; | |||
| text-align: center; | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,321 @@ | |||
| <template> | |||
| <view class="wrap"> | |||
| <view v-if="tipText" class="tip-card">{{ tipText }}</view> | |||
| <!-- 反馈类型 --> | |||
| <view class="fb-card"> | |||
| <text class="fb-card-title"><text class="required">*</text>问题类型</text> | |||
| <view class="type-grid"> | |||
| <view | |||
| v-for="item in TYPES" | |||
| :key="item.value" | |||
| class="type-item" | |||
| :class="{ active: form.type === item.value }" | |||
| @click="form.type = item.value" | |||
| > | |||
| {{ item.label }} | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <!-- 反馈内容 --> | |||
| <view class="fb-card"> | |||
| <view class="fb-card-head"> | |||
| <text class="fb-card-title"><text class="required">*</text>反馈内容</text> | |||
| <text class="fb-card-count">{{ form.content.length }}/500</text> | |||
| </view> | |||
| <textarea | |||
| v-model="form.content" | |||
| class="textarea" | |||
| maxlength="500" | |||
| placeholder="请描述你遇到的问题或建议,越具体我们越容易定位" | |||
| /> | |||
| </view> | |||
| <!-- 联系电话 --> | |||
| <view class="fb-card"> | |||
| <text class="fb-card-title"><text class="required">*</text>联系电话</text> | |||
| <input | |||
| v-model="form.phone" | |||
| class="input" | |||
| type="number" | |||
| maxlength="11" | |||
| placeholder="方便我们回复你的处理结果" | |||
| /> | |||
| </view> | |||
| <!-- 截图 --> | |||
| <view class="fb-card"> | |||
| <view class="fb-card-head"> | |||
| <text class="fb-card-title"><text class="required">*</text>问题截图</text> | |||
| <text class="fb-card-count">{{ form.imgs.length }}/9</text> | |||
| </view> | |||
| <view class="img-grid"> | |||
| <view v-for="(url, index) in form.imgs" :key="url" class="img-item"> | |||
| <image class="img" :src="url" mode="aspectFill" @click="previewImg(index)" /> | |||
| <view class="img-del" @click.stop="removeImg(index)">×</view> | |||
| </view> | |||
| <view v-if="form.imgs.length < 9" class="img-add" @click="chooseImage"> | |||
| <text class="add-plus">+</text> | |||
| <text class="add-text">上传截图</text> | |||
| </view> | |||
| </view> | |||
| <text class="fb-card-desc">支持 PNG / JPG,单张不超过 10M</text> | |||
| </view> | |||
| <view class="bottom-bar"> | |||
| <view class="submit-btn" :class="{ disabled: submitting }" @click="submit"> | |||
| {{ submitting ? '提交中...' : '提交反馈' }} | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { reactive, ref } from 'vue' | |||
| import { onLoad } from '@dcloudio/uni-app' | |||
| import { commonApi } from '@/api/index.js' | |||
| import { uploadImage } from '@/utils/upload.js' | |||
| import { useUserStore } from '@/store/user.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const userStore = useUserStore() | |||
| const { toast } = useApp() | |||
| // 与后端 feedback.type 约定一致:1反馈使用问题 2提出产品意见 3其他咨询 | |||
| const TYPES = [ | |||
| { value: 1, label: '反馈使用问题' }, | |||
| { value: 2, label: '提出产品意见' }, | |||
| { value: 3, label: '其他咨询' } | |||
| ] | |||
| const CONFIG_KEY = 'feedback_tip_content' | |||
| const form = reactive({ type: 1, content: '', phone: '', imgs: [] }) | |||
| const tipText = ref('') | |||
| const submitting = ref(false) | |||
| onLoad(() => { | |||
| loadTip() | |||
| // 注意:/user/getinfo 返回的手机号是脱敏的(138****1234),不能用来预填,让用户自己输 | |||
| }) | |||
| async function loadTip() { | |||
| try { | |||
| const res = await commonApi.getConfig({ keys: CONFIG_KEY }) | |||
| const rows = Array.isArray(res.list) ? res.list : [] | |||
| const item = rows.find((it) => it.key === CONFIG_KEY) | |||
| if (item && item.value) tipText.value = item.value | |||
| } catch (e) { | |||
| // 配置缺失就不展示提示条 | |||
| } | |||
| } | |||
| function chooseImage() { | |||
| if (!ensureLogin()) return | |||
| const count = 9 - form.imgs.length | |||
| if (count <= 0) return | |||
| uni.chooseImage({ | |||
| count, | |||
| sizeType: ['compressed'], | |||
| sourceType: ['album', 'camera'], | |||
| success: async (res) => { | |||
| const files = res.tempFiles || [] | |||
| const paths = res.tempFilePaths || [] | |||
| uni.showLoading({ title: '上传中...', mask: true }) | |||
| try { | |||
| for (let i = 0; i < paths.length; i++) { | |||
| if (Number((files[i] || {}).size || 0) > 10 * 1024 * 1024) { | |||
| toast('单张图片不能超过 10M') | |||
| continue | |||
| } | |||
| const ret = await uploadImage(paths[i], files[i] || {}) | |||
| const url = (ret.data && ret.data.url) || '' | |||
| if (url) form.imgs.push(url) | |||
| } | |||
| } catch (e) { | |||
| toast((e && e.msg) || '图片上传失败') | |||
| } finally { | |||
| uni.hideLoading() | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| function removeImg(index) { | |||
| form.imgs.splice(index, 1) | |||
| } | |||
| function previewImg(index) { | |||
| uni.previewImage({ urls: form.imgs, current: form.imgs[index] }) | |||
| } | |||
| async function submit() { | |||
| if (!ensureLogin()) return | |||
| if (submitting.value) return | |||
| if (!form.type) return toast('请选择问题类型') | |||
| if (!form.content.trim()) return toast('请输入反馈内容') | |||
| if (!/^1\d{10}$/.test(form.phone)) return toast('请输入正确的手机号') | |||
| if (!form.imgs.length) return toast('请上传问题截图') | |||
| submitting.value = true | |||
| try { | |||
| await commonApi.feedback({ | |||
| type: form.type, | |||
| content: form.content.trim(), | |||
| phone: form.phone, | |||
| imgs: form.imgs.join(',') | |||
| }) | |||
| toast('反馈成功,感谢你的建议') | |||
| setTimeout(() => uni.navigateBack(), 1200) | |||
| } catch (e) { | |||
| toast((e && e.msg) || '反馈失败,请重试') | |||
| } finally { | |||
| submitting.value = false | |||
| } | |||
| } | |||
| function ensureLogin() { | |||
| if (userStore.isLogin) return true | |||
| uni.navigateTo({ url: '/pages/login/login' }) | |||
| return false | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .wrap { | |||
| min-height: 100vh; | |||
| padding: 24rpx 28rpx calc(200rpx + env(safe-area-inset-bottom)); | |||
| background: #f3f7fb; | |||
| box-sizing: border-box; | |||
| } | |||
| .tip-card { | |||
| padding: 22rpx 24rpx; | |||
| border-radius: 20rpx; | |||
| color: #b7791f; | |||
| background: #fff8e6; | |||
| font-size: 24rpx; | |||
| line-height: 1.6; | |||
| } | |||
| .fb-card { | |||
| margin-top: 22rpx; | |||
| padding: 28rpx; | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| } | |||
| .fb-card-head { display: flex; align-items: baseline; } | |||
| .fb-card-title { display: block; color: #172033; font-size: 30rpx; font-weight: 800; } | |||
| .fb-card-count { margin-left: auto; color: #8a95a6; font-size: 23rpx; } | |||
| .fb-card-desc { display: block; margin-top: 14rpx; color: #8a95a6; font-size: 22rpx; } | |||
| .required { margin-right: 4rpx; color: #ef4444; } | |||
| .type-grid { | |||
| margin-top: 20rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||
| gap: 14rpx; | |||
| } | |||
| .type-item { | |||
| height: 76rpx; | |||
| border-radius: 18rpx; | |||
| color: #596579; | |||
| background: #f7f9fd; | |||
| border: 2rpx solid transparent; | |||
| font-size: 24rpx; | |||
| font-weight: 700; | |||
| line-height: 76rpx; | |||
| text-align: center; | |||
| box-sizing: border-box; | |||
| overflow: hidden; | |||
| white-space: nowrap; | |||
| text-overflow: ellipsis; | |||
| } | |||
| .type-item.active { color: #1f8cff; background: #eff8ff; border-color: #1f8cff; } | |||
| .textarea { | |||
| margin-top: 20rpx; | |||
| width: 100%; | |||
| min-height: 220rpx; | |||
| padding: 22rpx; | |||
| border-radius: 20rpx; | |||
| color: #172033; | |||
| background: #f7f9fd; | |||
| font-size: 25rpx; | |||
| line-height: 1.55; | |||
| box-sizing: border-box; | |||
| } | |||
| .input { | |||
| margin-top: 20rpx; | |||
| width: 100%; | |||
| height: 88rpx; | |||
| padding: 0 22rpx; | |||
| border-radius: 20rpx; | |||
| color: #172033; | |||
| background: #f7f9fd; | |||
| font-size: 27rpx; | |||
| box-sizing: border-box; | |||
| } | |||
| .img-grid { | |||
| margin-top: 20rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(4, minmax(0, 1fr)); | |||
| gap: 14rpx; | |||
| } | |||
| .img-item, .img-add { | |||
| position: relative; | |||
| width: 100%; | |||
| height: 150rpx; | |||
| border-radius: 18rpx; | |||
| overflow: hidden; | |||
| } | |||
| .img { width: 100%; height: 100%; } | |||
| .img-del { | |||
| position: absolute; | |||
| right: 0; | |||
| top: 0; | |||
| width: 40rpx; | |||
| height: 40rpx; | |||
| border-radius: 0 0 0 16rpx; | |||
| color: #fff; | |||
| background: rgba(15, 23, 42, 0.55); | |||
| font-size: 26rpx; | |||
| line-height: 38rpx; | |||
| text-align: center; | |||
| } | |||
| .img-add { | |||
| border: 2rpx dashed rgba(31, 140, 255, 0.35); | |||
| background: #f8fbff; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| box-sizing: border-box; | |||
| } | |||
| .add-plus { color: #1f8cff; font-size: 44rpx; line-height: 1; } | |||
| .add-text { margin-top: 8rpx; color: #8a95a6; font-size: 21rpx; } | |||
| .bottom-bar { | |||
| position: fixed; | |||
| left: 0; | |||
| right: 0; | |||
| bottom: 0; | |||
| padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom)); | |||
| background: rgba(255, 255, 255, 0.96); | |||
| box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1); | |||
| } | |||
| .submit-btn { | |||
| height: 88rpx; | |||
| border-radius: 999rpx; | |||
| color: #fff; | |||
| background: linear-gradient(135deg, #1f8cff, #41b9ff); | |||
| font-size: 30rpx; | |||
| font-weight: 800; | |||
| line-height: 88rpx; | |||
| text-align: center; | |||
| box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28); | |||
| } | |||
| .submit-btn.disabled { opacity: 0.6; } | |||
| </style> | |||
| @@ -0,0 +1,127 @@ | |||
| <template> | |||
| <view class="wrap"> | |||
| <!-- 关于 --> | |||
| <text class="group-title">关于</text> | |||
| <view class="st-card"> | |||
| <view v-for="item in aboutRows" :key="item.key" class="row" @click="onRow(item)"> | |||
| <text class="row-name">{{ item.name }}</text> | |||
| <text v-if="item.value" class="row-value">{{ item.value }}</text> | |||
| <up-icon v-else name="arrow-right" color="#c3cddb" size="14" /> | |||
| </view> | |||
| </view> | |||
| <!-- 账号 --> | |||
| <template v-if="userStore.isLogin"> | |||
| <text class="group-title">账号</text> | |||
| <view class="st-card"> | |||
| <view class="row" @click="logout"> | |||
| <text class="row-name danger">退出登录</text> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed } from 'vue' | |||
| import { useUserStore } from '@/store/user.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| import appConfig from '@/utils/config.js' | |||
| const userStore = useUserStore() | |||
| const { toast } = useApp() | |||
| // 微信端非正式版的环境名,展示给用户看更直观 | |||
| const ENV_TEXT = { | |||
| develop: '开发版', | |||
| trial: '体验版' | |||
| } | |||
| // 协议正文由后台「内容管理」维护,key 与旧项目一致:service / privacy | |||
| const aboutRows = computed(() => [ | |||
| { key: 'service', name: '服务条款' }, | |||
| { key: 'privacy', name: '隐私政策' }, | |||
| { key: 'version', name: '当前版本', value: version.value } | |||
| ]) | |||
| /** | |||
| * 版本号展示规则: | |||
| * - 正式版:只显示一个版本号,优先用微信侧的线上版本号(即上传代码包时填的版本), | |||
| * 取不到再回落到 APP_VERSION。不并排显示两个版本号,避免出现「2.0.0(2.0.0)」。 | |||
| * - 开发版 / 体验版:微信的 version 字段为空字符串,显示 APP_VERSION 并在括号里标注环境, | |||
| * 方便一眼看出当前不是线上包。 | |||
| * APP_VERSION 在 utils/config.js 维护,与 manifest.json 的 versionName 保持一致。 | |||
| */ | |||
| const version = computed(() => { | |||
| const appVersion = appConfig.APP_VERSION || '1.0.0' | |||
| // #ifdef MP-WEIXIN | |||
| try { | |||
| const mp = (uni.getAccountInfoSync() || {}).miniProgram || {} | |||
| const envVersion = mp.envVersion | |||
| if (envVersion && envVersion !== 'release') { | |||
| return `${appVersion}(${ENV_TEXT[envVersion] || envVersion})` | |||
| } | |||
| return mp.version || appVersion | |||
| } catch (e) { | |||
| // 取不到就只显示应用版本 | |||
| } | |||
| // #endif | |||
| return appVersion | |||
| }) | |||
| function onRow(item) { | |||
| if (item.key === 'version') return | |||
| uni.navigateTo({ url: `/pages/agreement/agreement?key=${item.key}` }) | |||
| } | |||
| function logout() { | |||
| uni.showModal({ | |||
| title: '提示', | |||
| content: '确定退出登录?', | |||
| confirmText: '退出登录', | |||
| confirmColor: '#ef4444', | |||
| success: (res) => { | |||
| if (!res.confirm) return | |||
| userStore.clear() | |||
| toast('已退出登录') | |||
| setTimeout(() => uni.navigateBack(), 800) | |||
| } | |||
| }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .wrap { | |||
| min-height: 100vh; | |||
| padding: 12rpx 28rpx 60rpx; | |||
| background: #f3f7fb; | |||
| box-sizing: border-box; | |||
| } | |||
| .group-title { | |||
| display: block; | |||
| margin: 24rpx 8rpx 14rpx; | |||
| color: #8a95a6; | |||
| font-size: 23rpx; | |||
| font-weight: 800; | |||
| } | |||
| /* 类名带 st- 前缀,避开全局 .card 的 margin / box-shadow */ | |||
| .st-card { | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| overflow: hidden; | |||
| } | |||
| .row { | |||
| min-height: 100rpx; | |||
| padding: 26rpx 28rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| box-sizing: border-box; | |||
| } | |||
| .row + .row { border-top: 2rpx solid #f4f7fb; } | |||
| .row-name { flex: 1; min-width: 0; color: #172033; font-size: 29rpx; } | |||
| .row-name.danger { color: #ef4444; text-align: center; } | |||
| .row-value { color: #8a95a6; font-size: 26rpx; } | |||
| </style> | |||
| @@ -0,0 +1,140 @@ | |||
| <template> | |||
| <view class="wrap"> | |||
| <view class="hero-card"> | |||
| <text class="eyebrow">AIONLINE 助手</text> | |||
| <text class="hero-title">关注公众号</text> | |||
| <text class="hero-desc">分享 AI 资讯与使用技巧,并定期发放福利。</text> | |||
| </view> | |||
| <view class="qr-card"> | |||
| <image | |||
| class="qr-img" | |||
| :src="qrUrl" | |||
| mode="aspectFit" | |||
| show-menu-by-longpress | |||
| @click="previewQr" | |||
| /> | |||
| <text class="qr-name">AIonline助手</text> | |||
| <view class="steps"> | |||
| <!-- 小程序 / H5 里可以长按识别,App 只能保存到相册再去微信扫 --> | |||
| <!-- #ifdef APP-PLUS --> | |||
| <view class="step"><text class="step-index">1</text><text class="step-text">点击二维码查看大图</text></view> | |||
| <view class="step"><text class="step-index">2</text><text class="step-text">长按保存到相册</text></view> | |||
| <view class="step"><text class="step-index">3</text><text class="step-text">打开微信扫一扫识别</text></view> | |||
| <!-- #endif --> | |||
| <!-- #ifndef APP-PLUS --> | |||
| <view class="step"><text class="step-index">1</text><text class="step-text">长按上方二维码</text></view> | |||
| <view class="step"><text class="step-index">2</text><text class="step-text">识别并前往图中的公众号</text></view> | |||
| <view class="step"><text class="step-index">3</text><text class="step-text">点击关注即可</text></view> | |||
| <!-- #endif --> | |||
| </view> | |||
| </view> | |||
| <view class="tip-card"> | |||
| <text class="tip-title">关注后可以做什么</text> | |||
| <text class="tip-line">· 第一时间收到新功能与活动通知</text> | |||
| <text class="tip-line">· 获取 AI 绘画、写作的实用提示词</text> | |||
| <text class="tip-line">· 参与不定期的点数与会员福利活动</text> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { onLoad } from '@dcloudio/uni-app' | |||
| import { commonApi } from '@/api/index.js' | |||
| // 二维码优先读配置项,便于运营换图;取不到用默认图兜底(与 PC 站页脚同一张) | |||
| const DEFAULT_QR = 'https://cdn.aionline.cc/ai/aionline_qr.jpg' | |||
| const CONFIG_KEY = 'wechat_qr' | |||
| const qrUrl = ref(DEFAULT_QR) | |||
| onLoad(() => loadQr()) | |||
| async function loadQr() { | |||
| try { | |||
| const res = await commonApi.getConfig({ keys: CONFIG_KEY }) | |||
| const rows = Array.isArray(res.list) ? res.list : [] | |||
| const item = rows.find((it) => it.key === CONFIG_KEY) | |||
| if (item && item.value) qrUrl.value = item.value | |||
| } catch (e) { | |||
| // 配置缺失就用默认图 | |||
| } | |||
| } | |||
| function previewQr() { | |||
| uni.previewImage({ urls: [qrUrl.value] }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .wrap { | |||
| min-height: 100vh; | |||
| padding: 24rpx 28rpx 60rpx; | |||
| background: #f3f7fb; | |||
| box-sizing: border-box; | |||
| } | |||
| .hero-card { | |||
| padding: 32rpx; | |||
| border-radius: 26rpx; | |||
| background: linear-gradient(135deg, #1f8cff, #41b9ff); | |||
| } | |||
| .eyebrow { display: block; color: rgba(255, 255, 255, 0.75); font-size: 21rpx; font-weight: 800; } | |||
| .hero-title { display: block; margin-top: 12rpx; color: #fff; font-size: 40rpx; font-weight: 900; } | |||
| .hero-desc { | |||
| display: block; | |||
| margin-top: 12rpx; | |||
| color: rgba(255, 255, 255, 0.85); | |||
| font-size: 23rpx; | |||
| line-height: 1.5; | |||
| } | |||
| .qr-card { | |||
| margin-top: 22rpx; | |||
| padding: 36rpx 28rpx 28rpx; | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| } | |||
| .qr-img { | |||
| width: 420rpx; | |||
| height: 420rpx; | |||
| border-radius: 20rpx; | |||
| background: #f7f9fd; | |||
| } | |||
| .qr-name { margin-top: 20rpx; color: #172033; font-size: 30rpx; font-weight: 800; } | |||
| .steps { margin-top: 24rpx; width: 100%; } | |||
| .step { display: flex; align-items: center; gap: 14rpx; margin-top: 14rpx; } | |||
| .step-index { | |||
| width: 40rpx; | |||
| height: 40rpx; | |||
| border-radius: 50%; | |||
| color: #1f8cff; | |||
| background: #eef7ff; | |||
| font-size: 22rpx; | |||
| font-weight: 800; | |||
| line-height: 40rpx; | |||
| text-align: center; | |||
| flex-shrink: 0; | |||
| } | |||
| .step-text { color: #5f6f83; font-size: 25rpx; } | |||
| .tip-card { | |||
| margin-top: 22rpx; | |||
| padding: 28rpx; | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| } | |||
| .tip-title { display: block; margin-bottom: 12rpx; color: #172033; font-size: 28rpx; font-weight: 800; } | |||
| .tip-line { | |||
| display: block; | |||
| margin-top: 8rpx; | |||
| color: #7f8896; | |||
| font-size: 23rpx; | |||
| line-height: 1.6; | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,523 @@ | |||
| <template> | |||
| <view class="wrap"> | |||
| <!-- 余额头卡 --> | |||
| <view class="head"> | |||
| <view class="head-left"> | |||
| <text class="kicker">获得点数</text> | |||
| <text class="title">当前 {{ balance }} 点</text> | |||
| <text class="desc">完成下面的任务即可获得点数,点数可用于全部 AI 工具</text> | |||
| </view> | |||
| <view class="head-actions"> | |||
| <view class="head-btn" @click="goBill">明细</view> | |||
| <view class="head-btn primary" @click="goRecharge">充值</view> | |||
| </view> | |||
| </view> | |||
| <!-- 加载骨架:等待超过 300ms 也有反馈 --> | |||
| <view v-if="loading" class="skeleton-list"> | |||
| <view v-for="i in 3" :key="i" class="skeleton-row"></view> | |||
| </view> | |||
| <template v-else> | |||
| <!-- 任务列表:只渲染已开启的任务 --> | |||
| <view v-if="checkinTask.enabled" class="task-card checkin-card"> | |||
| <view class="task-main"> | |||
| <view class="task-icon checkin"> | |||
| <up-icon name="calendar" color="#1f8cff" size="22" /> | |||
| </view> | |||
| <view class="task-copy"> | |||
| <text class="task-title">{{ pointCopy(checkinTask.title, '每日签到') }}</text> | |||
| <text class="task-desc">{{ pointCopy(checkinTask.desc, '每天签到一次,点数立即到账') }}</text> | |||
| <text class="task-progress" :class="{ success: checkinTask.checked_in }"> | |||
| {{ checkinTask.checked_in ? '今日已完成,明天再来' : '今日尚未签到' }} | |||
| </text> | |||
| </view> | |||
| <view class="task-action"> | |||
| <view class="reward-chip">+{{ checkinTask.points_per_checkin }}</view> | |||
| <view | |||
| class="task-btn" | |||
| :class="{ disabled: checkinBtnDisabled }" | |||
| @click="claimCheckin" | |||
| > | |||
| {{ checkinBtnText }} | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view v-if="adTask.enabled" class="task-card"> | |||
| <view class="task-main"> | |||
| <view class="task-icon ad"> | |||
| <text class="icon-text">▶</text> | |||
| </view> | |||
| <view class="task-copy"> | |||
| <text class="task-title">{{ pointCopy(adTask.title, '看视频获得点数') }}</text> | |||
| <text class="task-desc">{{ pointCopy(adTask.desc, '完整观看一段视频即可获得点数') }}</text> | |||
| <text class="task-progress">{{ adProgressText }}</text> | |||
| </view> | |||
| <view class="task-action"> | |||
| <view class="reward-chip">+{{ adTask.points_per_ad }}</view> | |||
| <view | |||
| class="task-btn" | |||
| :class="{ disabled: adBtnDisabled }" | |||
| @click="watchAd" | |||
| > | |||
| {{ adBtnText }} | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view v-if="inviteTask.enabled" class="task-card"> | |||
| <view class="task-main"> | |||
| <view class="task-icon invite"> | |||
| <text class="icon-text">+</text> | |||
| </view> | |||
| <view class="task-copy"> | |||
| <text class="task-title">邀请好友注册</text> | |||
| <text class="task-desc"> | |||
| 好友注册成功,你获得 {{ inviteTask.inviter_points }} 点,好友也获得 {{ inviteTask.invitee_points }} 点 | |||
| </text> | |||
| <text class="task-progress"> | |||
| 已邀请 {{ inviteTask.stats.success || 0 }} 人 · 累计获得 {{ inviteTask.stats.inviter_points || 0 }} 点 | |||
| </text> | |||
| </view> | |||
| <view class="task-action"> | |||
| <view class="reward-chip">+{{ inviteTask.inviter_points }}</view> | |||
| <view class="task-btn" @click="goInvite">去邀请</view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <!-- 所有任务都未开启:不给空白页,直接引导充值 --> | |||
| <view v-if="!checkinTask.enabled && !adTask.enabled && !inviteTask.enabled" class="empty-state"> | |||
| <text class="empty-title">暂无可做的任务</text> | |||
| <text class="empty-desc">活动暂未开启,可以先通过充值获得点数。</text> | |||
| <view class="empty-action" @click="goRecharge">去充值</view> | |||
| </view> | |||
| <!-- 规则 --> | |||
| <view v-if="ruleLines.length" class="rule-card"> | |||
| <text class="rule-title">规则说明</text> | |||
| <text v-for="line in ruleLines" :key="line" class="rule-line">· {{ line }}</text> | |||
| </view> | |||
| </template> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { onLoad, onShow, onUnload } from '@dcloudio/uni-app' | |||
| import { pointApi } from '@/api/index.js' | |||
| import { useUserStore } from '@/store/user.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const userStore = useUserStore() | |||
| const { toast } = useApp() | |||
| const loading = ref(true) | |||
| const balance = ref(0) | |||
| const rewarding = ref(false) | |||
| const checkingIn = ref(false) | |||
| const waitSeconds = ref(0) | |||
| const checkinTask = ref({ | |||
| enabled: false, | |||
| points_per_checkin: 0, | |||
| checked_in: false, | |||
| can_checkin: false, | |||
| title: '', | |||
| desc: '', | |||
| rule_text: '' | |||
| }) | |||
| const inviteTask = ref({ enabled: false, inviter_points: 0, invitee_points: 0, rule_text: '', stats: {} }) | |||
| const adTask = ref({ | |||
| enabled: false, | |||
| points_per_ad: 0, | |||
| daily_limit: 0, | |||
| today_used: 0, | |||
| remain: 0, | |||
| ad_unit_id: '', | |||
| title: '', | |||
| desc: '', | |||
| rule_text: '' | |||
| }) | |||
| // 激励视频实例:官方约定是页面内单例、不可跨页复用,所以只在本页创建 | |||
| let videoAd = null | |||
| let currentTicket = '' | |||
| let waitTimer = 0 | |||
| const adRemainText = computed(() => { | |||
| if (adTask.value.daily_limit <= 0) return '不限次数' | |||
| return `今日 ${adTask.value.today_used}/${adTask.value.daily_limit} 次` | |||
| }) | |||
| const adProgressText = computed(() => { | |||
| if (!adTask.value.enabled) return '' | |||
| if (adTask.value.remain === 0) return '今日次数已用完,明天再来' | |||
| return adRemainText.value | |||
| }) | |||
| const adBtnDisabled = computed( | |||
| () => rewarding.value || adTask.value.remain === 0 || waitSeconds.value > 0 | |||
| ) | |||
| const adBtnText = computed(() => { | |||
| if (rewarding.value) return '发放中' | |||
| if (adTask.value.remain === 0) return '已领完' | |||
| if (waitSeconds.value > 0) return `${waitSeconds.value}s` | |||
| return `看视频 +${adTask.value.points_per_ad}` | |||
| }) | |||
| const checkinBtnDisabled = computed(() => checkingIn.value || checkinTask.value.checked_in) | |||
| const checkinBtnText = computed(() => { | |||
| if (checkingIn.value) return '签到中' | |||
| if (checkinTask.value.checked_in) return '已签到' | |||
| return '立即签到' | |||
| }) | |||
| const ruleLines = computed(() => { | |||
| const lines = [] | |||
| if (checkinTask.value.enabled && checkinTask.value.rule_text) lines.push(...splitRule(checkinTask.value.rule_text)) | |||
| if (adTask.value.enabled && adTask.value.rule_text) lines.push(...splitRule(adTask.value.rule_text)) | |||
| if (inviteTask.value.enabled && inviteTask.value.rule_text) lines.push(...splitRule(inviteTask.value.rule_text)) | |||
| return lines | |||
| }) | |||
| function splitRule(text) { | |||
| return pointCopy(text) | |||
| .split(/[\n;;]/) | |||
| .map((item) => item.trim()) | |||
| .filter(Boolean) | |||
| } | |||
| onLoad(() => { | |||
| loadConfig() | |||
| }) | |||
| onShow(() => { | |||
| // 从充值/邀请页返回时刷新余额与进度 | |||
| if (!loading.value) loadConfig(true) | |||
| }) | |||
| onUnload(() => { | |||
| clearInterval(waitTimer) | |||
| destroyAd() | |||
| }) | |||
| async function loadConfig(silent = false) { | |||
| try { | |||
| const res = await pointApi.getEarnConfig() | |||
| const data = res.data || {} | |||
| balance.value = (data.balance && data.balance.points) || 0 | |||
| checkinTask.value = Object.assign({}, checkinTask.value, data.checkin || {}) | |||
| inviteTask.value = Object.assign({ stats: {} }, data.invite || {}) | |||
| adTask.value = Object.assign({}, adTask.value, data.ad || {}) | |||
| startWait(adTask.value.wait_seconds || 0) | |||
| if (adTask.value.enabled) initAd() | |||
| } catch (e) { | |||
| if (!silent) toast((e && e.msg) || '加载失败') | |||
| } finally { | |||
| loading.value = false | |||
| } | |||
| } | |||
| /** 冷却倒计时:后端下发剩余秒数,前端只做展示 */ | |||
| function startWait(seconds) { | |||
| clearInterval(waitTimer) | |||
| waitSeconds.value = Number(seconds || 0) | |||
| if (waitSeconds.value <= 0) return | |||
| waitTimer = setInterval(() => { | |||
| waitSeconds.value -= 1 | |||
| if (waitSeconds.value <= 0) clearInterval(waitTimer) | |||
| }, 1000) | |||
| } | |||
| function initAd() { | |||
| // #ifdef MP-WEIXIN | |||
| if (videoAd || !adTask.value.ad_unit_id) return | |||
| if (!wx.createRewardedVideoAd) return | |||
| videoAd = wx.createRewardedVideoAd({ adUnitId: adTask.value.ad_unit_id }) | |||
| videoAd.onError((err) => { | |||
| console.log('激励视频加载失败', err) | |||
| cancelTicket('广告拉取失败') | |||
| rewarding.value = false | |||
| toast('广告暂时拉取失败,请稍后再试') | |||
| }) | |||
| videoAd.onClose((res) => { | |||
| // 只有完整看完才发奖,中途退出作废凭证且不占次数 | |||
| if (res && res.isEnded) { | |||
| claimReward() | |||
| } else { | |||
| cancelTicket('未看完') | |||
| rewarding.value = false | |||
| toast('未看完视频,本次不发放点数') | |||
| } | |||
| }) | |||
| // #endif | |||
| } | |||
| function destroyAd() { | |||
| // #ifdef MP-WEIXIN | |||
| videoAd = null | |||
| // #endif | |||
| } | |||
| async function watchAd() { | |||
| if (!ensureLogin()) return | |||
| if (adBtnDisabled.value) return | |||
| // #ifndef MP-WEIXIN | |||
| toast('请在微信小程序中观看视频获得点数') | |||
| return | |||
| // #endif | |||
| rewarding.value = true | |||
| try { | |||
| const res = await pointApi.getAdTicket() | |||
| const data = res.data || {} | |||
| currentTicket = data.ticket || '' | |||
| if (data.ad) adTask.value = Object.assign({}, adTask.value, data.ad) | |||
| if (!currentTicket) throw new Error('凭证获取失败') | |||
| initAd() | |||
| if (!videoAd) { | |||
| rewarding.value = false | |||
| cancelTicket('广告组件不可用') | |||
| toast('当前环境不支持激励视频') | |||
| return | |||
| } | |||
| await showAd() | |||
| } catch (e) { | |||
| rewarding.value = false | |||
| cancelTicket('提交失败') | |||
| toast((e && e.msg) || e.message || '暂不可领取') | |||
| } | |||
| } | |||
| function showAd() { | |||
| return new Promise((resolve, reject) => { | |||
| videoAd | |||
| .show() | |||
| .then(resolve) | |||
| .catch(() => { | |||
| // 首次 show 失败按官方建议先 load 再重试一次 | |||
| videoAd | |||
| .load() | |||
| .then(() => videoAd.show()) | |||
| .then(resolve) | |||
| .catch(reject) | |||
| }) | |||
| }) | |||
| } | |||
| async function claimReward() { | |||
| if (!currentTicket) return | |||
| const ticket = currentTicket | |||
| currentTicket = '' | |||
| try { | |||
| const res = await pointApi.getAdReward({ ticket }) | |||
| const data = res.data || {} | |||
| balance.value = (data.balance && data.balance.points) || balance.value | |||
| if (data.ad) { | |||
| adTask.value = Object.assign({}, adTask.value, data.ad) | |||
| startWait(data.ad.wait_seconds || 0) | |||
| } | |||
| // 同步 store,个人中心与工具页展示的余额跟着变 | |||
| userStore.fetchBalance().catch(() => {}) | |||
| toast(`点数 +${data.points || adTask.value.points_per_ad}`) | |||
| } catch (e) { | |||
| toast((e && e.msg) || '点数发放失败') | |||
| } finally { | |||
| rewarding.value = false | |||
| } | |||
| } | |||
| function cancelTicket(reason) { | |||
| if (!currentTicket) return | |||
| const ticket = currentTicket | |||
| currentTicket = '' | |||
| pointApi.cancelAdTicket({ ticket, reason }).catch(() => {}) | |||
| } | |||
| function ensureLogin() { | |||
| if (userStore.isLogin) return true | |||
| uni.navigateTo({ url: '/pages/login/login' }) | |||
| return false | |||
| } | |||
| // 兼容后台历史配置中仍使用“积分”的文案,前台统一展示为“点数”。 | |||
| function pointCopy(text, fallback = '') { | |||
| return String(text || fallback).replace(/积分/g, '点数') | |||
| } | |||
| async function claimCheckin() { | |||
| if (!ensureLogin()) return | |||
| if (checkinBtnDisabled.value) return | |||
| checkingIn.value = true | |||
| try { | |||
| const res = await pointApi.checkin() | |||
| const data = res.data || {} | |||
| balance.value = (data.balance && data.balance.points) || balance.value | |||
| if (data.checkin) checkinTask.value = Object.assign({}, checkinTask.value, data.checkin) | |||
| userStore.fetchBalance().catch(() => {}) | |||
| toast(data.duplicated ? '今天已经签到过了' : `签到成功,点数 +${data.points || checkinTask.value.points_per_checkin}`) | |||
| } catch (e) { | |||
| toast((e && e.msg) || '签到失败,请稍后再试') | |||
| } finally { | |||
| checkingIn.value = false | |||
| } | |||
| } | |||
| function goInvite() { | |||
| if (!ensureLogin()) return | |||
| uni.navigateTo({ url: '/pages/invite/invite' }) | |||
| } | |||
| function goRecharge() { | |||
| uni.navigateTo({ url: '/pages/recharge/recharge' }) | |||
| } | |||
| function goBill() { | |||
| if (!ensureLogin()) return | |||
| uni.navigateTo({ url: '/pages/bill/bill' }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .wrap { | |||
| min-height: 100vh; | |||
| padding: 24rpx 28rpx 60rpx; | |||
| background: #f3f7fb; | |||
| box-sizing: border-box; | |||
| } | |||
| .head { | |||
| padding: 30rpx; | |||
| border-radius: 26rpx; | |||
| background: linear-gradient(135deg, #1f8cff, #41b9ff); | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 20rpx; | |||
| } | |||
| .head-left { flex: 1; min-width: 0; } | |||
| .kicker { display: block; color: rgba(255, 255, 255, 0.75); font-size: 21rpx; font-weight: 800; } | |||
| .title { display: block; margin-top: 12rpx; color: #fff; font-size: 42rpx; font-weight: 900; } | |||
| .desc { | |||
| display: block; | |||
| margin-top: 12rpx; | |||
| color: rgba(255, 255, 255, 0.85); | |||
| font-size: 22rpx; | |||
| line-height: 1.5; | |||
| } | |||
| .head-actions { display: flex; flex-direction: column; gap: 12rpx; flex-shrink: 0; } | |||
| .head-btn { | |||
| width: 130rpx; | |||
| height: 60rpx; | |||
| border-radius: 999rpx; | |||
| color: #fff; | |||
| font-size: 24rpx; | |||
| font-weight: 800; | |||
| line-height: 60rpx; | |||
| text-align: center; | |||
| background: rgba(255, 255, 255, 0.2); | |||
| } | |||
| .head-btn.primary { color: #1f8cff; background: #fff; } | |||
| .skeleton-list { margin-top: 22rpx; } | |||
| .skeleton-row { | |||
| height: 180rpx; | |||
| margin-bottom: 18rpx; | |||
| border-radius: 24rpx; | |||
| background: #e9eef6; | |||
| } | |||
| .task-card { | |||
| margin-top: 22rpx; | |||
| padding: 28rpx; | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| } | |||
| .task-main { display: flex; align-items: center; gap: 20rpx; } | |||
| .task-icon { | |||
| width: 88rpx; | |||
| height: 88rpx; | |||
| border-radius: 24rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| flex-shrink: 0; | |||
| } | |||
| .task-icon.ad { background: #eef7ff; } | |||
| .task-icon.checkin { background: #eaf5ff; } | |||
| .task-icon.invite { background: #eefaf3; } | |||
| .icon-text { color: #1f8cff; font-size: 36rpx; font-weight: 900; } | |||
| .task-icon.invite .icon-text { color: #17a65a; } | |||
| .task-copy { flex: 1; min-width: 0; } | |||
| .task-title { display: block; color: #172033; font-size: 30rpx; font-weight: 800; } | |||
| .task-desc { | |||
| display: block; | |||
| margin-top: 8rpx; | |||
| color: #7f8896; | |||
| font-size: 23rpx; | |||
| line-height: 1.45; | |||
| } | |||
| .task-progress { display: block; margin-top: 10rpx; color: #1f8cff; font-size: 22rpx; font-weight: 700; } | |||
| .task-progress.success { color: #17a65a; } | |||
| .task-action { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; } | |||
| .reward-chip { | |||
| padding: 4rpx 16rpx; | |||
| border-radius: 999rpx; | |||
| color: #ff8a00; | |||
| background: #fff5e8; | |||
| font-size: 22rpx; | |||
| font-weight: 800; | |||
| } | |||
| .task-btn { | |||
| min-width: 156rpx; | |||
| height: 68rpx; | |||
| padding: 0 20rpx; | |||
| border-radius: 999rpx; | |||
| color: #fff; | |||
| background: #1f8cff; | |||
| font-size: 25rpx; | |||
| font-weight: 800; | |||
| line-height: 68rpx; | |||
| text-align: center; | |||
| } | |||
| .task-btn.disabled { opacity: 0.5; } | |||
| .empty-state { | |||
| margin-top: 60rpx; | |||
| padding: 60rpx 34rpx; | |||
| border-radius: 26rpx; | |||
| background: #fff; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| text-align: center; | |||
| } | |||
| .empty-title { color: #172033; font-size: 32rpx; font-weight: 900; } | |||
| .empty-desc { margin-top: 12rpx; color: #7f8896; font-size: 25rpx; line-height: 1.5; } | |||
| .empty-action { | |||
| margin-top: 28rpx; | |||
| width: 220rpx; | |||
| height: 72rpx; | |||
| border-radius: 999rpx; | |||
| color: #fff; | |||
| background: #1f8cff; | |||
| font-size: 28rpx; | |||
| font-weight: 800; | |||
| line-height: 72rpx; | |||
| } | |||
| .rule-card { | |||
| margin-top: 22rpx; | |||
| padding: 28rpx; | |||
| border-radius: 24rpx; | |||
| background: #fff; | |||
| } | |||
| .rule-title { display: block; margin-bottom: 12rpx; color: #172033; font-size: 28rpx; font-weight: 800; } | |||
| .rule-line { | |||
| display: block; | |||
| margin-top: 8rpx; | |||
| color: #7f8896; | |||
| font-size: 23rpx; | |||
| line-height: 1.6; | |||
| } | |||
| </style> | |||
| @@ -1,73 +1,123 @@ | |||
| <template> | |||
| <view class="profile"> | |||
| <ai-nav-title title="AI在线-解放你的生产力" /> | |||
| <ai-nav-title title="个人中心" /> | |||
| <scroll-view class="page-scroll" scroll-y> | |||
| <view class="profile-content"> | |||
| <view class="balance-card"> | |||
| <view class="balance-card" :class="{ 'guest-card': !userStore.isLogin }"> | |||
| <image class="balance-texture" src="/static/profile/balance_texture.png" mode="aspectFit" /> | |||
| <view class="balance-copy"> | |||
| <text class="hello">{{ userStore.isLogin ? `Hi,${userStore.nickname}!` : 'Hi,手机微信昵称!' }}</text> | |||
| <view v-if="userStore.isLogin" class="balance-copy"> | |||
| <text class="hello">{{ `Hi,${userStore.nickname || 'AI在线用户'}!` }}</text> | |||
| <text class="balance-label">点数余额</text> | |||
| <view class="balance-bottom"> | |||
| <text class="balance-value">{{ userStore.isLogin ? userStore.pointsText : '123456' }}</text> | |||
| <text class="balance-value">{{ userStore.pointsText }}</text> | |||
| <view class="balance-actions"> | |||
| <view class="outline-btn" @click="go('bill')">账单</view> | |||
| <view class="solid-btn" @click="go('recharge')">充值</view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view v-else class="guest-copy"> | |||
| <view class="guest-main"> | |||
| <view class="guest-avatar"> | |||
| <up-icon name="account-fill" color="#2384f8" size="28" /> | |||
| </view> | |||
| <view class="guest-text"> | |||
| <text class="guest-title">登录 AI在线</text> | |||
| <text class="guest-description">同步点数、作品与创作记录</text> | |||
| </view> | |||
| </view> | |||
| <view class="guest-bottom"> | |||
| <text class="guest-tip">登录后可跨设备查看创作数据</text> | |||
| <view class="login-btn" hover-class="login-btn--pressed" @click="requireLogin"> | |||
| <text>立即登录</text> | |||
| <up-icon name="arrow-right" color="#2384f8" size="14" /> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view class="menu-panel"> | |||
| <view v-for="item in menus" :key="item.key" class="menu-row" @click="onMenu(item)"> | |||
| <image class="menu-icon" :src="item.icon" mode="aspectFit" /> | |||
| <text class="menu-name">{{ item.name }}</text> | |||
| <text v-if="item.hint" class="menu-hint">{{ item.hint }}</text> | |||
| <image class="arrow" src="/static/profile/icon_arrow.png" mode="aspectFit" /> | |||
| <!-- 按用途分组,组间留白,避免一长条列表 --> | |||
| <view v-for="group in menuGroups" :key="group.key" class="menu-group"> | |||
| <text class="group-title">{{ group.title }}</text> | |||
| <view class="menu-panel"> | |||
| <view v-for="item in group.items" :key="item.key" class="menu-row" @click="onMenu(item)"> | |||
| <view class="menu-icon" :style="{ background: item.iconBg }"> | |||
| <up-icon :name="item.icon" :color="item.iconColor" size="20" /> | |||
| </view> | |||
| <text class="menu-name">{{ item.name }}</text> | |||
| <text v-if="item.hint" class="menu-hint">{{ item.hint }}</text> | |||
| <up-icon name="arrow-right" color="#c3cddb" size="14" /> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view class="logout" @click="logout">退出登录</view> | |||
| </view> | |||
| </scroll-view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { computed, ref } from 'vue' | |||
| import { onShow } from '@dcloudio/uni-app' | |||
| import { inviteApi } from '@/api/index.js' | |||
| import { inviteApi, pointApi } from '@/api/index.js' | |||
| import { useUserStore } from '@/store/user.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const userStore = useUserStore() | |||
| const { toast } = useApp() | |||
| // 图标统一用 uview-plus 的图标字体,不再各自维护 png | |||
| const earnMenuConfig = { | |||
| key: 'earn', | |||
| name: '获得点数', | |||
| hint: '完成任务获得点数', | |||
| icon: 'gift', | |||
| iconColor: '#ff8a00', | |||
| iconBg: '#fff5e8', | |||
| auth: false | |||
| } | |||
| const inviteMenuConfig = { | |||
| key: 'invite', | |||
| name: '邀请好友', | |||
| hint: '得100点分享奖励', | |||
| icon: '/static/profile/icon_invite.png', | |||
| icon: 'man-add', | |||
| iconColor: '#17a65a', | |||
| iconBg: '#eefaf3', | |||
| auth: true | |||
| } | |||
| const menus = ref([ | |||
| { key: 'account', name: '我的账号', hint: '提示文字#999', icon: '/static/profile/icon_account.png', auth: true }, | |||
| { ...inviteMenuConfig }, | |||
| { key: 'news', name: '前沿资讯', hint: '提示文字#999', icon: '/static/profile/icon_news.png', soon: true }, | |||
| { key: 'tutorial', name: '使用教程', icon: '/static/profile/icon_tutorial.png', soon: true }, | |||
| { key: 'official', name: '关注公众号', icon: '/static/profile/icon_official.png', soon: true }, | |||
| { key: 'contact', name: '联系我们', icon: '/static/profile/icon_contact.png' }, | |||
| { key: 'feedback', name: '意见反馈', hint: '得10点奖励', icon: '/static/profile/icon_feedback.png', soon: true }, | |||
| { key: 'terms', name: '服务条款', icon: '/static/profile/icon_terms.png' }, | |||
| { key: 'version', name: '当前版本', icon: '/static/profile/icon_version.png', soon: true } | |||
| ]) | |||
| // 福利组:签到、邀请与看广告都受后台开关控制,关掉就整项移除 | |||
| const benefitMenus = ref([{ ...inviteMenuConfig }]) | |||
| const serviceMenus = [ | |||
| { key: 'official', name: '关注公众号', icon: 'weixin-fill', iconColor: '#07c160', iconBg: '#eafaf0' }, | |||
| { key: 'contact', name: '联系我们', icon: 'kefu-ermai', iconColor: '#1f8cff', iconBg: '#eef7ff' }, | |||
| { key: 'feedback', name: '意见反馈', icon: 'edit-pen', iconColor: '#7a5af8', iconBg: '#f3f0ff', auth: true } | |||
| ] | |||
| // 服务条款、隐私政策、当前版本、退出登录都收进设置二级页 | |||
| const otherMenus = [ | |||
| { key: 'setting', name: '设置', icon: 'setting', iconColor: '#64748b', iconBg: '#f1f5fa' } | |||
| ] | |||
| const menuGroups = computed(() => { | |||
| const groups = [] | |||
| if (benefitMenus.value.length) { | |||
| groups.push({ key: 'benefit', title: '福利', items: benefitMenus.value }) | |||
| } | |||
| groups.push({ key: 'service', title: '服务与帮助', items: serviceMenus }) | |||
| groups.push({ key: 'other', title: '其他', items: otherMenus }) | |||
| return groups | |||
| }) | |||
| const MENU_ROUTE = { | |||
| earn: '/pages/point/earn', | |||
| invite: '/pages/invite/invite', | |||
| contact: '/pages/agreement/agreement?key=contact', | |||
| terms: '/pages/agreement/agreement?key=terms' | |||
| official: '/pages/my/wechat', | |||
| feedback: '/pages/my/feedback', | |||
| contact: '/pages/my/contact', | |||
| setting: '/pages/my/setting' | |||
| } | |||
| function requireLogin() { | |||
| @@ -92,9 +142,9 @@ function go(page) { | |||
| } | |||
| function syncInviteMenu(config = {}) { | |||
| const currentIndex = menus.value.findIndex((item) => item.key === 'invite') | |||
| const currentIndex = benefitMenus.value.findIndex((item) => item.key === 'invite') | |||
| if (Number(config.status) !== 1) { | |||
| if (currentIndex > -1) menus.value.splice(currentIndex, 1) | |||
| if (currentIndex > -1) benefitMenus.value.splice(currentIndex, 1) | |||
| return | |||
| } | |||
| @@ -104,12 +154,13 @@ function syncInviteMenu(config = {}) { | |||
| hint: points > 0 ? `得${points}点分享奖励` : '分享给好友' | |||
| } | |||
| if (currentIndex > -1) { | |||
| menus.value.splice(currentIndex, 1, item) | |||
| benefitMenus.value.splice(currentIndex, 1, item) | |||
| return | |||
| } | |||
| const accountIndex = menus.value.findIndex((menu) => menu.key === 'account') | |||
| menus.value.splice(accountIndex > -1 ? accountIndex + 1 : 0, 0, item) | |||
| // 排在「获得点数」之后,没有该入口时排在最前 | |||
| const earnIndex = benefitMenus.value.findIndex((menu) => menu.key === 'earn') | |||
| benefitMenus.value.splice(earnIndex > -1 ? earnIndex + 1 : 0, 0, item) | |||
| } | |||
| async function loadProfile() { | |||
| @@ -129,22 +180,38 @@ async function loadInviteConfig() { | |||
| } catch (e) {} | |||
| } | |||
| function logout() { | |||
| if (!userStore.isLogin) return requireLogin() | |||
| uni.showModal({ | |||
| title: '提示', | |||
| content: '确定退出登录?', | |||
| success: (r) => { | |||
| if (r.confirm) { | |||
| userStore.clear() | |||
| toast('已退出登录') | |||
| } | |||
| } | |||
| }) | |||
| /** 所有免费任务都关闭时不显示入口,而不是显示后再提示不可用 */ | |||
| function syncEarnMenu(enabled, hint) { | |||
| const currentIndex = benefitMenus.value.findIndex((item) => item.key === 'earn') | |||
| if (!enabled) { | |||
| if (currentIndex > -1) benefitMenus.value.splice(currentIndex, 1) | |||
| return | |||
| } | |||
| const item = { ...earnMenuConfig, hint: hint || earnMenuConfig.hint } | |||
| if (currentIndex > -1) { | |||
| benefitMenus.value.splice(currentIndex, 1, item) | |||
| return | |||
| } | |||
| // 获得点数放在福利组最前面 | |||
| benefitMenus.value.splice(0, 0, item) | |||
| } | |||
| async function loadEarnConfig() { | |||
| try { | |||
| const res = await pointApi.getEarnConfig() | |||
| const data = res.data || {} | |||
| const checkinPoints = Number((data.checkin && data.checkin.points_per_checkin) || 0) | |||
| const adPoints = Number((data.ad && data.ad.points_per_ad) || 0) | |||
| let hint = '完成任务获得点数' | |||
| if (data.checkin && data.checkin.enabled && checkinPoints > 0) hint = `每日签到获得${checkinPoints}点` | |||
| else if (data.ad && data.ad.enabled && adPoints > 0) hint = `看视频获得${adPoints}点` | |||
| syncEarnMenu(Boolean(data.entry_enabled), hint) | |||
| } catch (e) {} | |||
| } | |||
| onShow(() => { | |||
| loadInviteConfig() | |||
| loadEarnConfig() | |||
| loadProfile() | |||
| }) | |||
| </script> | |||
| @@ -177,7 +244,10 @@ onShow(() => { | |||
| overflow: hidden; | |||
| background: linear-gradient(135deg, #3f94ff 0%, #2384f8 100%); | |||
| box-sizing: border-box; | |||
| } | |||
| .guest-card { | |||
| background: linear-gradient(135deg, #4298ff 0%, #1677ef 100%); | |||
| } | |||
| .balance-texture { | |||
| @@ -198,6 +268,92 @@ onShow(() => { | |||
| box-sizing: border-box; | |||
| } | |||
| .guest-copy { | |||
| position: relative; | |||
| z-index: 1; | |||
| height: 100%; | |||
| padding: 36rpx 42rpx 34rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| box-sizing: border-box; | |||
| } | |||
| .guest-main { | |||
| display: flex; | |||
| align-items: center; | |||
| min-width: 0; | |||
| } | |||
| .guest-avatar { | |||
| width: 84rpx; | |||
| height: 84rpx; | |||
| border-radius: 50%; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| flex-shrink: 0; | |||
| background: rgba(255, 255, 255, 0.96); | |||
| box-shadow: 0 10rpx 24rpx rgba(16, 87, 174, 0.2); | |||
| } | |||
| .guest-text { | |||
| margin-left: 24rpx; | |||
| min-width: 0; | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| .guest-title { | |||
| color: #ffffff; | |||
| font-size: 34rpx; | |||
| font-weight: 700; | |||
| line-height: 1.25; | |||
| } | |||
| .guest-description { | |||
| margin-top: 8rpx; | |||
| color: rgba(255, 255, 255, 0.78); | |||
| font-size: 24rpx; | |||
| line-height: 1.4; | |||
| } | |||
| .guest-bottom { | |||
| margin-top: auto; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: 20rpx; | |||
| } | |||
| .guest-tip { | |||
| color: rgba(255, 255, 255, 0.68); | |||
| font-size: 22rpx; | |||
| line-height: 1.35; | |||
| } | |||
| .login-btn { | |||
| min-width: 180rpx; | |||
| height: 64rpx; | |||
| padding: 0 24rpx 0 28rpx; | |||
| border-radius: 32rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| gap: 6rpx; | |||
| flex-shrink: 0; | |||
| color: #2384f8; | |||
| font-size: 27rpx; | |||
| font-weight: 700; | |||
| background: #ffffff; | |||
| box-shadow: 0 10rpx 24rpx rgba(16, 87, 174, 0.18); | |||
| box-sizing: border-box; | |||
| } | |||
| .login-btn--pressed { | |||
| opacity: 0.84; | |||
| transform: scale(0.98); | |||
| } | |||
| .hello { | |||
| max-width: 430rpx; | |||
| color: #ffffff; | |||
| @@ -260,10 +416,19 @@ onShow(() => { | |||
| background: rgba(255, 255, 255, 0.08); | |||
| } | |||
| .menu-group { | |||
| margin-top: 26rpx; | |||
| } | |||
| .group-title { | |||
| display: block; | |||
| margin: 0 8rpx 12rpx; | |||
| color: #8a95a6; | |||
| font-size: 23rpx; | |||
| font-weight: 800; | |||
| } | |||
| .menu-panel { | |||
| margin-top: 28rpx; | |||
| padding-top: 18rpx; | |||
| padding-bottom: 14rpx; | |||
| border-radius: 21rpx; | |||
| background: #ffffff; | |||
| overflow: hidden; | |||
| @@ -272,20 +437,21 @@ onShow(() => { | |||
| .menu-row { | |||
| position: relative; | |||
| height: 100rpx; | |||
| padding: 0 27rpx 0 27rpx; | |||
| min-height: 108rpx; | |||
| padding: 20rpx 27rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| box-sizing: border-box; | |||
| /* 分隔线只在图标右侧起始,视觉上更整齐 */ | |||
| &::after { | |||
| content: ''; | |||
| position: absolute; | |||
| left: 25rpx; | |||
| left: 105rpx; | |||
| right: 24rpx; | |||
| bottom: 0; | |||
| height: 1rpx; | |||
| background: #eeeeee; | |||
| background: #f1f5fa; | |||
| } | |||
| &:last-child::after { | |||
| @@ -294,44 +460,33 @@ onShow(() => { | |||
| } | |||
| .menu-icon { | |||
| width: 34rpx; | |||
| height: 34rpx; | |||
| width: 68rpx; | |||
| height: 68rpx; | |||
| border-radius: 18rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| flex-shrink: 0; | |||
| } | |||
| .menu-name { | |||
| margin-left: 25rpx; | |||
| color: #333333; | |||
| font-size: 28rpx; | |||
| line-height: 1; | |||
| margin-left: 22rpx; | |||
| color: #172033; | |||
| font-size: 29rpx; | |||
| line-height: 1.3; | |||
| flex: 1; | |||
| min-width: 0; | |||
| } | |||
| .menu-hint { | |||
| margin-left: 20rpx; | |||
| max-width: 300rpx; | |||
| color: #999999; | |||
| font-size: 26rpx; | |||
| line-height: 1; | |||
| max-width: 280rpx; | |||
| color: #8a95a6; | |||
| font-size: 24rpx; | |||
| line-height: 1.3; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| white-space: nowrap; | |||
| } | |||
| .arrow { | |||
| width: 14rpx; | |||
| height: 22rpx; | |||
| margin-left: 20rpx; | |||
| flex-shrink: 0; | |||
| } | |||
| .logout { | |||
| height: 104rpx; | |||
| color: #333333; | |||
| font-size: 27rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| </style> | |||
| @@ -26,6 +26,10 @@ | |||
| <text v-if="item.badge" class="badge">{{ item.badge }}</text> | |||
| <text class="points">{{ item.points }}点</text> | |||
| <text class="price">¥{{ item.price_yuan }}</text> | |||
| <view v-if="item.old_price_yuan" class="offer-row"> | |||
| <text class="old-price">¥{{ item.old_price_yuan }}</text> | |||
| <text v-if="item.bonus_percent" class="bonus">多送{{ item.bonus_percent }}%</text> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| @@ -339,6 +343,27 @@ $muted: #8b95a3; | |||
| font-weight: 800; | |||
| color: $accent; | |||
| } | |||
| .offer-row { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| gap: 8rpx; | |||
| min-height: 30rpx; | |||
| white-space: nowrap; | |||
| } | |||
| .old-price { | |||
| color: #9aa6b2; | |||
| font-size: 21rpx; | |||
| text-decoration: line-through; | |||
| } | |||
| .bonus { | |||
| padding: 3rpx 8rpx; | |||
| border-radius: 999rpx; | |||
| color: #159a54; | |||
| background: #edfdf4; | |||
| font-size: 19rpx; | |||
| font-weight: 900; | |||
| } | |||
| } | |||
| /* 确认充值 */ | |||
| @@ -0,0 +1,451 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <scroll-view class="scroll" scroll-y> | |||
| <view class="wrap"> | |||
| <view v-if="!loaded || !isSuccess" class="state-card"> | |||
| <view v-if="!loaded || isPending" class="spinner"></view> | |||
| <text class="state-title">{{ stateTitle }}</text> | |||
| <text v-if="isPending && countDown > 0" class="count">{{ countDown }} S 后刷新</text> | |||
| <text v-if="loaded && !isPending" class="error">{{ detail.error_msg || '暂时无法读取证件照成品,请稍后重试' }}</text> | |||
| <view v-if="loaded && !isPending" class="retry-btn" hover-class="button-pressed" @click="createAgain">重新制作</view> | |||
| </view> | |||
| <template v-else> | |||
| <view class="result-card"> | |||
| <view class="result-head"> | |||
| <view> | |||
| <text class="eyebrow">FINISHED PHOTO</text> | |||
| <text class="result-title">{{ detail.spec_name || '证件照' }}</text> | |||
| </view> | |||
| <view class="dpi-chip">{{ detail.dpi || 300 }} DPI</view> | |||
| </view> | |||
| <view class="result-stage" @click="preview"> | |||
| <image class="result-img" :src="detail.result_url" mode="widthFix" /> | |||
| </view> | |||
| <text class="preview-tip">点击照片可查看大图</text> | |||
| </view> | |||
| <view class="info-card"> | |||
| <text class="info-title">成品信息</text> | |||
| <view class="cell"> | |||
| <text class="cell-label">证件照规格</text> | |||
| <text class="cell-content">{{ detail.spec_name || '-' }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">像素尺寸</text> | |||
| <text class="cell-content">{{ sizeText }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">背景颜色</text> | |||
| <view class="color-value"> | |||
| <view class="color-dot" :style="{ backgroundColor: detail.background || '#438edb' }"></view> | |||
| <text>{{ String(detail.background || '#438EDB').toUpperCase() }}</text> | |||
| </view> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">画面优化</text> | |||
| <text class="cell-content">{{ beautyText }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">创建时间</text> | |||
| <text class="cell-content">{{ formatTime(detail.add_time) }}</text> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| </view> | |||
| </scroll-view> | |||
| <view v-if="isSuccess" class="bottom-bar"> | |||
| <view class="save-btn" hover-class="button-pressed" @click="download"> | |||
| <up-icon name="download" color="#1f8cff" size="18" /> | |||
| <text>保存照片</text> | |||
| </view> | |||
| <view class="main-btn" hover-class="button-pressed" @click="createAgain">再做一张</view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { onLoad, onShow, onUnload } from '@dcloudio/uni-app' | |||
| import dayjs from 'dayjs' | |||
| import { idPhotoApi } from '@/api/index.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const { toast } = useApp() | |||
| const id = ref('') | |||
| const detail = ref({ status: 0 }) | |||
| const loaded = ref(false) | |||
| const countDown = ref(0) | |||
| let timer = 0 | |||
| let countTimer = 0 | |||
| const isPending = computed(() => loaded.value && Number(detail.value.status) === 0) | |||
| const isSuccess = computed(() => loaded.value && Number(detail.value.status) === 1 && Boolean(detail.value.result_url)) | |||
| const stateTitle = computed(() => { | |||
| if (!loaded.value) return '正在读取证件照' | |||
| if (isPending.value) return '证件照正在制作中' | |||
| if (Number(detail.value.status) < 0) return '本次制作失败' | |||
| return '证件照成品暂不可用' | |||
| }) | |||
| const sizeText = computed(() => { | |||
| const width = Number(detail.value.width || 0) | |||
| const height = Number(detail.value.height || 0) | |||
| return width && height ? `${width} × ${height}px` : '-' | |||
| }) | |||
| const beautyText = computed(() => detail.value.beauty === 'natural' ? '自然优化' : '关闭美化') | |||
| onLoad((options = {}) => { | |||
| id.value = String(options.id || options.work || '') | |||
| }) | |||
| onShow(() => loadDetail()) | |||
| onUnload(() => clearPolling()) | |||
| async function loadDetail() { | |||
| if (!id.value) { | |||
| detail.value = { status: -1, error_msg: '缺少证件照记录编号' } | |||
| loaded.value = true | |||
| return | |||
| } | |||
| clearPolling() | |||
| try { | |||
| const res = await idPhotoApi.getDetail({ photo_open_id: id.value }) | |||
| detail.value = res.data || {} | |||
| loaded.value = true | |||
| if (Number(detail.value.status) === 0) waitNext() | |||
| } catch (e) { | |||
| detail.value = { | |||
| status: -1, | |||
| error_msg: (e && e.msg) || '记录加载失败' | |||
| } | |||
| loaded.value = true | |||
| toast((e && e.msg) || '记录加载失败') | |||
| } | |||
| } | |||
| function waitNext() { | |||
| clearPolling() | |||
| countDown.value = 3 | |||
| countTimer = setInterval(() => { | |||
| countDown.value -= 1 | |||
| if (countDown.value <= 0) clearInterval(countTimer) | |||
| }, 1000) | |||
| timer = setTimeout(loadDetail, 3000) | |||
| } | |||
| function clearPolling() { | |||
| clearTimeout(timer) | |||
| clearInterval(countTimer) | |||
| timer = 0 | |||
| countTimer = 0 | |||
| } | |||
| function formatTime(value) { | |||
| if (!value) return '-' | |||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||
| } | |||
| function preview() { | |||
| if (!detail.value.result_url) return | |||
| uni.previewImage({ urls: [detail.value.result_url], current: detail.value.result_url }) | |||
| } | |||
| function download() { | |||
| if (!detail.value.result_url) return | |||
| uni.showLoading({ title: '保存中...', mask: true }) | |||
| uni.downloadFile({ | |||
| url: detail.value.result_url, | |||
| success: (res) => { | |||
| if (res.statusCode !== 200) { | |||
| toast('图片下载失败') | |||
| return | |||
| } | |||
| uni.saveImageToPhotosAlbum({ | |||
| filePath: res.tempFilePath, | |||
| success: () => toast('已保存到相册'), | |||
| fail: () => toast('保存失败,请检查相册权限') | |||
| }) | |||
| }, | |||
| fail: () => toast('图片下载失败'), | |||
| complete: () => uni.hideLoading() | |||
| }) | |||
| } | |||
| function createAgain() { | |||
| const delta = getPageStackDelta('pages/tool/id-photo') | |||
| if (delta > 0) { | |||
| uni.navigateBack({ delta }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/tool/id-photo' }) | |||
| } | |||
| function getPageStackDelta(routeName) { | |||
| const pages = getCurrentPages() | |||
| const index = pages.findIndex((item) => item.route === routeName) | |||
| return index >= 0 ? pages.length - 1 - index : -1 | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| height: 100vh; | |||
| display: flex; | |||
| flex-direction: column; | |||
| overflow: hidden; | |||
| background: #f4f7fb; | |||
| } | |||
| .scroll { | |||
| flex: 1; | |||
| height: 0; | |||
| } | |||
| .wrap { | |||
| padding: 28rpx 28rpx calc(150rpx + env(safe-area-inset-bottom)); | |||
| } | |||
| .state-card, | |||
| .result-card, | |||
| .info-card { | |||
| border-radius: 24rpx; | |||
| background: #ffffff; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05); | |||
| overflow: hidden; | |||
| } | |||
| .state-card { | |||
| min-height: 660rpx; | |||
| padding: 50rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| box-sizing: border-box; | |||
| text-align: center; | |||
| } | |||
| .spinner { | |||
| width: 68rpx; | |||
| height: 68rpx; | |||
| margin-bottom: 28rpx; | |||
| border: 8rpx solid #e9edf5; | |||
| border-top-color: #1f8cff; | |||
| border-radius: 50%; | |||
| animation: spin 0.8s linear infinite; | |||
| } | |||
| .state-title { | |||
| color: #172033; | |||
| font-size: 32rpx; | |||
| font-weight: 800; | |||
| } | |||
| .count, | |||
| .error { | |||
| margin-top: 18rpx; | |||
| color: #7f8896; | |||
| font-size: 25rpx; | |||
| line-height: 1.5; | |||
| } | |||
| .error { | |||
| color: #e74b43; | |||
| } | |||
| .retry-btn { | |||
| min-width: 210rpx; | |||
| height: 72rpx; | |||
| margin-top: 30rpx; | |||
| padding: 0 28rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| font-size: 27rpx; | |||
| font-weight: 800; | |||
| line-height: 72rpx; | |||
| box-sizing: border-box; | |||
| } | |||
| .result-head { | |||
| padding: 26rpx 28rpx 22rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| gap: 20rpx; | |||
| } | |||
| .eyebrow { | |||
| display: block; | |||
| color: #1f8cff; | |||
| font-size: 19rpx; | |||
| font-weight: 800; | |||
| } | |||
| .result-title { | |||
| display: block; | |||
| margin-top: 8rpx; | |||
| color: #172033; | |||
| font-size: 34rpx; | |||
| font-weight: 900; | |||
| } | |||
| .dpi-chip { | |||
| height: 48rpx; | |||
| padding: 0 18rpx; | |||
| border-radius: 999rpx; | |||
| color: #1f8cff; | |||
| background: #edf7ff; | |||
| font-size: 21rpx; | |||
| font-weight: 800; | |||
| line-height: 48rpx; | |||
| flex-shrink: 0; | |||
| } | |||
| .result-stage { | |||
| min-height: 520rpx; | |||
| padding: 30rpx; | |||
| background: repeating-linear-gradient(135deg, #edf3f9 0, #edf3f9 20rpx, #f8fbfd 20rpx, #f8fbfd 40rpx); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| box-sizing: border-box; | |||
| } | |||
| .result-img { | |||
| width: 520rpx; | |||
| max-width: 100%; | |||
| border-radius: 4rpx; | |||
| display: block; | |||
| box-shadow: 0 16rpx 34rpx rgba(23, 32, 51, 0.16); | |||
| } | |||
| .preview-tip { | |||
| display: block; | |||
| padding: 18rpx 28rpx 22rpx; | |||
| color: #8a95a6; | |||
| font-size: 22rpx; | |||
| text-align: center; | |||
| } | |||
| .info-card { | |||
| margin-top: 24rpx; | |||
| padding: 28rpx; | |||
| } | |||
| .info-title { | |||
| display: block; | |||
| margin-bottom: 14rpx; | |||
| color: #172033; | |||
| font-size: 31rpx; | |||
| font-weight: 800; | |||
| } | |||
| .cell { | |||
| min-height: 70rpx; | |||
| padding: 8rpx 0; | |||
| border-bottom: 1rpx solid #edf1f6; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 24rpx; | |||
| box-sizing: border-box; | |||
| font-size: 26rpx; | |||
| } | |||
| .cell:last-child { | |||
| border-bottom: 0; | |||
| } | |||
| .cell-label { | |||
| width: 160rpx; | |||
| color: #7f8896; | |||
| flex-shrink: 0; | |||
| } | |||
| .cell-content, | |||
| .color-value { | |||
| flex: 1; | |||
| min-width: 0; | |||
| color: #172033; | |||
| text-align: right; | |||
| } | |||
| .color-value { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: flex-end; | |||
| gap: 12rpx; | |||
| } | |||
| .color-dot { | |||
| width: 30rpx; | |||
| height: 30rpx; | |||
| border-radius: 50%; | |||
| border: 2rpx solid rgba(23, 32, 51, 0.12); | |||
| box-sizing: border-box; | |||
| } | |||
| .bottom-bar { | |||
| position: fixed; | |||
| z-index: 80; | |||
| left: 0; | |||
| right: 0; | |||
| bottom: 0; | |||
| padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom)); | |||
| display: flex; | |||
| gap: 18rpx; | |||
| background: rgba(255, 255, 255, 0.97); | |||
| box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08); | |||
| box-sizing: border-box; | |||
| } | |||
| .save-btn, | |||
| .main-btn { | |||
| height: 82rpx; | |||
| border-radius: 42rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| font-size: 29rpx; | |||
| font-weight: 800; | |||
| } | |||
| .save-btn { | |||
| width: 240rpx; | |||
| gap: 10rpx; | |||
| color: #1f8cff; | |||
| background: #eef6ff; | |||
| } | |||
| .main-btn { | |||
| flex: 1; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| box-shadow: 0 12rpx 26rpx rgba(31, 140, 255, 0.2); | |||
| } | |||
| .button-pressed { | |||
| opacity: 0.82; | |||
| } | |||
| @keyframes spin { | |||
| to { transform: rotate(360deg); } | |||
| } | |||
| @media (min-width: 768px) { | |||
| .wrap { | |||
| width: 720px; | |||
| margin: 0 auto; | |||
| box-sizing: border-box; | |||
| } | |||
| .bottom-bar { | |||
| left: 50%; | |||
| width: 720px; | |||
| transform: translateX(-50%); | |||
| border-left: 1px solid #e6edf5; | |||
| border-right: 1px solid #e6edf5; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,554 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <scroll-view | |||
| class="scroll" | |||
| scroll-y | |||
| refresher-enabled | |||
| :refresher-triggered="refreshing" | |||
| @refresherrefresh="refresh" | |||
| @scrolltolower="loadMore" | |||
| > | |||
| <view class="wrap"> | |||
| <view class="studio-head"> | |||
| <view class="head-copy"> | |||
| <text class="eyebrow">ID PHOTO RECORDS</text> | |||
| <text class="page-title">我的证件照</text> | |||
| <text class="page-desc">制作记录会自动保存,可随时查看成品、下载照片或继续制作。</text> | |||
| </view> | |||
| <view class="create-btn" hover-class="button-pressed" @click="goCreate">制作</view> | |||
| </view> | |||
| <view class="stats-row"> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ total || list.length }}</text> | |||
| <text class="stat-label">全部记录</text> | |||
| </view> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ pendingCount }}</text> | |||
| <text class="stat-label">制作中</text> | |||
| </view> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ successCount }}</text> | |||
| <text class="stat-label">已完成</text> | |||
| </view> | |||
| </view> | |||
| <view v-if="list.length" class="gallery"> | |||
| <view v-for="item in list" :key="item.photo_open_id || item.id" class="work-card"> | |||
| <view class="poster" hover-class="card-pressed" @click="openItem(item)"> | |||
| <image v-if="item.cover" class="poster-img" :src="item.cover" mode="aspectFit" /> | |||
| <view v-else class="poster-empty"> | |||
| <up-icon name="photo" color="#8abff2" size="30" /> | |||
| </view> | |||
| <view class="status-chip" :class="statusClass(item)">{{ item.status_text || statusText(item) }}</view> | |||
| <view v-if="isPending(item)" class="progress-mask"> | |||
| <view class="spinner"></view> | |||
| <text>正在制作</text> | |||
| </view> | |||
| <view v-else-if="Number(item.status) < 0" class="failed-mask"> | |||
| <text>{{ item.summary || '制作失败' }}</text> | |||
| </view> | |||
| </view> | |||
| <view class="card-info"> | |||
| <text class="spec-name">{{ item.title || '证件照' }}</text> | |||
| <text class="spec-size">{{ item.subtitle || '标准证件照' }}</text> | |||
| <view class="time-line"> | |||
| <text>{{ formatTime(item.add_time) }}</text> | |||
| <view class="delete-btn" hover-class="button-pressed" @click.stop="deleteItem(item)">删除</view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view v-if="!list.length && !loading" class="empty-state"> | |||
| <view class="empty-preview"> | |||
| <view class="empty-photo"> | |||
| <view class="empty-head"></view> | |||
| <view class="empty-body"></view> | |||
| </view> | |||
| </view> | |||
| <text class="empty-title">还没有证件照</text> | |||
| <text class="empty-desc">上传一张清晰正面照,制作符合规格的证件照。</text> | |||
| <view class="empty-action" hover-class="button-pressed" @click="goCreate">去制作</view> | |||
| </view> | |||
| <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view> | |||
| <view v-if="list.length && list.length >= total && !loading" class="end-text">已经到底了</view> | |||
| </view> | |||
| </scroll-view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { onHide, onShow, onUnload } from '@dcloudio/uni-app' | |||
| import dayjs from 'dayjs' | |||
| import { idPhotoApi } from '@/api/index.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const { toast } = useApp() | |||
| const list = ref([]) | |||
| const page = ref(1) | |||
| const total = ref(0) | |||
| const loading = ref(false) | |||
| const refreshing = ref(false) | |||
| let timer = 0 | |||
| const pendingCount = computed(() => list.value.filter((item) => isPending(item)).length) | |||
| const successCount = computed(() => list.value.filter((item) => Number(item.status) === 1).length) | |||
| onShow(() => refreshSilent()) | |||
| onHide(() => clearTimer()) | |||
| onUnload(() => clearTimer()) | |||
| function isPending(item) { | |||
| return Number(item.status) === 0 | |||
| } | |||
| function formatTime(value) { | |||
| if (!value) return '' | |||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||
| } | |||
| async function fetchList(reset = false) { | |||
| if (loading.value) { | |||
| finishRefresh() | |||
| return | |||
| } | |||
| loading.value = true | |||
| if (reset) page.value = 1 | |||
| try { | |||
| const res = await idPhotoApi.getList({ page_no: page.value, page_size: 10 }) | |||
| const rows = res.list || [] | |||
| total.value = Number((res.data && res.data.total) || rows.length) | |||
| list.value = reset ? rows : list.value.concat(rows) | |||
| scheduleRefresh() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '记录加载失败') | |||
| } finally { | |||
| loading.value = false | |||
| finishRefresh() | |||
| } | |||
| } | |||
| function scheduleRefresh() { | |||
| clearTimer() | |||
| if (list.value.some((item) => isPending(item))) { | |||
| timer = setTimeout(() => refreshSilent(), 3000) | |||
| } | |||
| } | |||
| function clearTimer() { | |||
| clearTimeout(timer) | |||
| timer = 0 | |||
| } | |||
| function finishRefresh() { | |||
| refreshing.value = false | |||
| uni.stopPullDownRefresh() | |||
| } | |||
| function refresh() { | |||
| refreshing.value = true | |||
| refreshSilent() | |||
| } | |||
| function refreshSilent() { | |||
| page.value = 1 | |||
| fetchList(true) | |||
| } | |||
| function loadMore() { | |||
| if (loading.value || list.value.length >= total.value) return | |||
| page.value += 1 | |||
| fetchList() | |||
| } | |||
| function openItem(item) { | |||
| const id = item.photo_open_id || item.id | |||
| if (!id) return | |||
| uni.navigateTo({ url: `/pages/tool/id-photo-detail?id=${id}` }) | |||
| } | |||
| function statusClass(item) { | |||
| if (isPending(item)) return 'pending' | |||
| if (Number(item.status) < 0) return 'failed' | |||
| return 'done' | |||
| } | |||
| function statusText(item) { | |||
| if (isPending(item)) return '制作中' | |||
| if (Number(item.status) < 0) return '失败' | |||
| return '完成' | |||
| } | |||
| function goCreate() { | |||
| const delta = getPageStackDelta('pages/tool/id-photo') | |||
| if (delta > 0) { | |||
| uni.navigateBack({ delta }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/tool/id-photo' }) | |||
| } | |||
| function getPageStackDelta(routeName) { | |||
| const pages = getCurrentPages() | |||
| const index = pages.findIndex((item) => item.route === routeName) | |||
| return index >= 0 ? pages.length - 1 - index : -1 | |||
| } | |||
| function deleteItem(item) { | |||
| const id = item.photo_open_id || item.id | |||
| if (!id) return | |||
| uni.showModal({ | |||
| title: '温馨提示', | |||
| content: '是否确认删除该证件照记录?', | |||
| confirmText: '确认删除', | |||
| success: async (res) => { | |||
| if (!res.confirm) return | |||
| try { | |||
| await idPhotoApi.delete({ photo_open_id: id }) | |||
| toast('删除成功') | |||
| refreshSilent() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '删除失败') | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page, | |||
| .scroll { | |||
| height: 100vh; | |||
| background: #f6f8fc; | |||
| } | |||
| .wrap { | |||
| padding: 24rpx 28rpx calc(70rpx + env(safe-area-inset-bottom)); | |||
| } | |||
| .studio-head { | |||
| padding: 30rpx; | |||
| border-radius: 26rpx; | |||
| background: #ffffff; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 24rpx; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05); | |||
| } | |||
| .head-copy { | |||
| flex: 1; | |||
| min-width: 0; | |||
| } | |||
| .eyebrow { | |||
| display: block; | |||
| color: #1f8cff; | |||
| font-size: 20rpx; | |||
| font-weight: 800; | |||
| } | |||
| .page-title { | |||
| display: block; | |||
| margin-top: 10rpx; | |||
| color: #172033; | |||
| font-size: 40rpx; | |||
| line-height: 1.2; | |||
| font-weight: 900; | |||
| } | |||
| .page-desc { | |||
| display: block; | |||
| margin-top: 12rpx; | |||
| color: #64748b; | |||
| font-size: 24rpx; | |||
| line-height: 1.5; | |||
| } | |||
| .create-btn { | |||
| width: 112rpx; | |||
| height: 68rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| font-size: 26rpx; | |||
| line-height: 68rpx; | |||
| text-align: center; | |||
| font-weight: 800; | |||
| box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.22); | |||
| flex-shrink: 0; | |||
| } | |||
| .stats-row { | |||
| margin-top: 18rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||
| gap: 14rpx; | |||
| } | |||
| .stat-card { | |||
| padding: 20rpx 8rpx; | |||
| border-radius: 20rpx; | |||
| background: #ffffff; | |||
| text-align: center; | |||
| box-shadow: 0 8rpx 20rpx rgba(31, 52, 86, 0.04); | |||
| } | |||
| .stat-num, | |||
| .stat-label, | |||
| .spec-name, | |||
| .spec-size, | |||
| .empty-title, | |||
| .empty-desc { | |||
| display: block; | |||
| } | |||
| .stat-num { | |||
| color: #172033; | |||
| font-size: 34rpx; | |||
| line-height: 1; | |||
| font-weight: 900; | |||
| } | |||
| .stat-label { | |||
| margin-top: 10rpx; | |||
| color: #7c8a9b; | |||
| font-size: 22rpx; | |||
| } | |||
| .gallery { | |||
| margin-top: 24rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |||
| gap: 22rpx; | |||
| } | |||
| .work-card { | |||
| min-width: 0; | |||
| border-radius: 24rpx; | |||
| background: #ffffff; | |||
| overflow: hidden; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.06); | |||
| } | |||
| .poster { | |||
| position: relative; | |||
| width: 100%; | |||
| height: 440rpx; | |||
| background: repeating-linear-gradient(135deg, #edf3f9 0, #edf3f9 18rpx, #f8fbfd 18rpx, #f8fbfd 36rpx); | |||
| overflow: hidden; | |||
| } | |||
| .poster-img, | |||
| .poster-empty { | |||
| width: 100%; | |||
| height: 100%; | |||
| } | |||
| .poster-empty { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .status-chip { | |||
| position: absolute; | |||
| left: 14rpx; | |||
| top: 14rpx; | |||
| height: 42rpx; | |||
| padding: 0 16rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| font-size: 22rpx; | |||
| line-height: 42rpx; | |||
| font-weight: 800; | |||
| background: rgba(15, 23, 42, 0.48); | |||
| } | |||
| .status-chip.done { background: rgba(31, 140, 255, 0.92); } | |||
| .status-chip.pending { background: rgba(249, 115, 22, 0.92); } | |||
| .status-chip.failed { background: rgba(239, 68, 68, 0.92); } | |||
| .progress-mask, | |||
| .failed-mask { | |||
| position: absolute; | |||
| inset: 0; | |||
| padding: 24rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| color: #ffffff; | |||
| font-size: 26rpx; | |||
| text-align: center; | |||
| background: rgba(15, 23, 42, 0.52); | |||
| } | |||
| .failed-mask { | |||
| background: rgba(127, 29, 29, 0.72); | |||
| font-size: 24rpx; | |||
| } | |||
| .spinner { | |||
| width: 48rpx; | |||
| height: 48rpx; | |||
| margin-bottom: 18rpx; | |||
| border: 6rpx solid rgba(255, 255, 255, 0.45); | |||
| border-top-color: #ffffff; | |||
| border-radius: 50%; | |||
| animation: spin 0.8s linear infinite; | |||
| } | |||
| .card-info { | |||
| padding: 18rpx; | |||
| } | |||
| .spec-name { | |||
| color: #172033; | |||
| font-size: 26rpx; | |||
| font-weight: 800; | |||
| line-height: 1.3; | |||
| } | |||
| .spec-size { | |||
| margin-top: 6rpx; | |||
| color: #8994a5; | |||
| font-size: 21rpx; | |||
| line-height: 1.3; | |||
| } | |||
| .time-line { | |||
| margin-top: 12rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 10rpx; | |||
| color: #64748b; | |||
| font-size: 21rpx; | |||
| } | |||
| .time-line text { | |||
| flex: 1; | |||
| min-width: 0; | |||
| } | |||
| .delete-btn { | |||
| width: 74rpx; | |||
| height: 44rpx; | |||
| border-radius: 999rpx; | |||
| color: #ef4444; | |||
| background: #fff1f2; | |||
| font-size: 22rpx; | |||
| line-height: 44rpx; | |||
| text-align: center; | |||
| flex-shrink: 0; | |||
| } | |||
| .empty-state { | |||
| margin-top: 86rpx; | |||
| padding: 56rpx 34rpx; | |||
| border-radius: 28rpx; | |||
| background: #ffffff; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| text-align: center; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05); | |||
| } | |||
| .empty-preview { | |||
| width: 150rpx; | |||
| height: 200rpx; | |||
| border-radius: 30rpx; | |||
| background: repeating-linear-gradient(135deg, #edf5fd 0, #edf5fd 14rpx, #f8fbff 14rpx, #f8fbff 28rpx); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .empty-photo { | |||
| position: relative; | |||
| width: 88rpx; | |||
| height: 122rpx; | |||
| border-radius: 8rpx; | |||
| background: #438edb; | |||
| overflow: hidden; | |||
| } | |||
| .empty-head { | |||
| position: absolute; | |||
| left: 50%; | |||
| top: 24rpx; | |||
| width: 34rpx; | |||
| height: 34rpx; | |||
| margin-left: -17rpx; | |||
| border-radius: 50%; | |||
| background: #ffffff; | |||
| } | |||
| .empty-body { | |||
| position: absolute; | |||
| left: 50%; | |||
| bottom: -18rpx; | |||
| width: 78rpx; | |||
| height: 72rpx; | |||
| margin-left: -39rpx; | |||
| border-radius: 50% 50% 0 0; | |||
| background: #ffffff; | |||
| } | |||
| .empty-title { | |||
| margin-top: 28rpx; | |||
| color: #172033; | |||
| font-size: 32rpx; | |||
| font-weight: 900; | |||
| } | |||
| .empty-desc { | |||
| margin-top: 12rpx; | |||
| color: #64748b; | |||
| font-size: 25rpx; | |||
| line-height: 1.5; | |||
| } | |||
| .empty-action { | |||
| margin-top: 28rpx; | |||
| width: 220rpx; | |||
| height: 72rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| font-size: 28rpx; | |||
| line-height: 72rpx; | |||
| font-weight: 800; | |||
| } | |||
| .loading, | |||
| .end-text { | |||
| padding: 46rpx 0 20rpx; | |||
| color: #7c8a9b; | |||
| font-size: 24rpx; | |||
| text-align: center; | |||
| } | |||
| .button-pressed, | |||
| .card-pressed { | |||
| opacity: 0.82; | |||
| } | |||
| @keyframes spin { | |||
| to { transform: rotate(360deg); } | |||
| } | |||
| @media (min-width: 768px) { | |||
| .wrap { | |||
| width: 720px; | |||
| margin: 0 auto; | |||
| box-sizing: border-box; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,434 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <scroll-view class="scroll" scroll-y> | |||
| <view class="wrap"> | |||
| <!-- 处理中 / 失败 --> | |||
| <view v-if="isLoadingState" class="state-card"> | |||
| <view v-if="isPending" class="spinner"></view> | |||
| <text class="state-title">{{ isPending ? (Number(detail.status) === 10 ? '排队中,请耐心等待' : 'AI 正在修复上色') : '修复失败' }}</text> | |||
| <text v-if="isPending && countDown > 0" class="count">{{ countDown }} S</text> | |||
| <text v-if="!isPending" class="error">{{ detail.error_msg || '本次未扣点,可以换一张更清晰的照片重试' }}</text> | |||
| </view> | |||
| <!-- 成品:默认直接看修复后的图 --> | |||
| <view v-else class="image-card" @click="preview"> | |||
| <image class="work-img" :src="detail.result_url" mode="widthFix" /> | |||
| <view class="compare-entry" @click.stop="openCompare">查看对比</view> | |||
| </view> | |||
| <view class="info-card"> | |||
| <view class="title">修复信息</view> | |||
| <view class="cell"> | |||
| <text class="cell-label">修复档位:</text> | |||
| <text class="cell-content">{{ detail.mode_name || '-' }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">当前状态:</text> | |||
| <text class="cell-content">{{ detail.status_text || '-' }}</text> | |||
| </view> | |||
| <view v-if="detail.source_width && detail.source_height" class="cell"> | |||
| <text class="cell-label">原图尺寸:</text> | |||
| <text class="cell-content">{{ detail.source_width }} × {{ detail.source_height }}px</text> | |||
| </view> | |||
| <view v-if="Number(detail.refine) === 1" class="cell"> | |||
| <text class="cell-label">清晰度:</text> | |||
| <text class="cell-content">已做高清增强</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">创建时间:</text> | |||
| <text class="cell-content">{{ formatTime(detail.add_time) }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">功能来源:</text> | |||
| <text class="link" @click="createAgain">老照片修复 / 上色</text> | |||
| </view> | |||
| <text class="note">AI 会重绘上色,五官可能出现轻微变化,建议对照原图确认。</text> | |||
| </view> | |||
| </view> | |||
| </scroll-view> | |||
| <view v-if="!isLoadingState" class="bottom-bar"> | |||
| <view class="bar-btn" @click="download">保存</view> | |||
| <view class="bar-btn" @click="openCompare">对比</view> | |||
| <view class="main-btn" @click="createAgain">再修一张</view> | |||
| </view> | |||
| <!-- 前后对比:样式与动漫头像一致 --> | |||
| <view v-if="compareVisible" class="compare-modal"> | |||
| <view class="compare-backdrop" @click="closeCompare"></view> | |||
| <view class="compare-panel"> | |||
| <view class="compare-panel-head"> | |||
| <text>效果对比</text> | |||
| <view class="close-btn" @click="closeCompare">×</view> | |||
| </view> | |||
| <view | |||
| class="large-compare" | |||
| @touchstart.stop="onCompareTouch" | |||
| @touchmove.stop.prevent="onCompareTouch" | |||
| @click.stop="onCompareTouch" | |||
| > | |||
| <image class="compare-img" :src="detail.result_url" mode="aspectFit" /> | |||
| <view class="compare-before" :style="{ width: comparePercent + '%' }"> | |||
| <image class="compare-img" :src="detail.source_url" mode="aspectFit" /> | |||
| </view> | |||
| <view class="compare-line large" :style="{ left: comparePercent + '%' }"> | |||
| <view class="compare-handle large"> | |||
| <text>‹</text> | |||
| <text>›</text> | |||
| </view> | |||
| </view> | |||
| <view class="compare-tag left">原图</view> | |||
| <view class="compare-tag right">修复后</view> | |||
| </view> | |||
| <text class="compare-guide">左右滑动中间按钮查看变化</text> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { onLoad, onUnload } from '@dcloudio/uni-app' | |||
| import dayjs from 'dayjs' | |||
| import { oldPhotoApi } from '@/api/index.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const { toast } = useApp() | |||
| const id = ref('') | |||
| const detail = ref({ status: 10 }) | |||
| const countDown = ref(0) | |||
| const compareVisible = ref(false) | |||
| const comparePercent = ref(50) | |||
| let timer = 0 | |||
| let countTimer = 0 | |||
| const isPending = computed(() => [10, 20].includes(Number(detail.value.status))) | |||
| const isLoadingState = computed(() => Number(detail.value.status) !== 30) | |||
| onLoad((options) => { | |||
| id.value = (options && options.id) || '' | |||
| loadDetail() | |||
| }) | |||
| onUnload(() => { | |||
| clearTimeout(timer) | |||
| clearInterval(countTimer) | |||
| }) | |||
| async function loadDetail() { | |||
| if (!id.value) return | |||
| try { | |||
| const res = await oldPhotoApi.getDetail({ photo_open_id: id.value }) | |||
| detail.value = res.data || {} | |||
| if (isPending.value) waitNext() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '记录加载失败') | |||
| } | |||
| } | |||
| // 处理中就 3 秒拉一次,后端会在这一步推进任务并转存结果 | |||
| function waitNext() { | |||
| clearTimeout(timer) | |||
| clearInterval(countTimer) | |||
| countDown.value = 3 | |||
| countTimer = setInterval(() => { | |||
| countDown.value -= 1 | |||
| if (countDown.value <= 0) clearInterval(countTimer) | |||
| }, 1000) | |||
| timer = setTimeout(loadDetail, 3000) | |||
| } | |||
| function formatTime(value) { | |||
| if (!value) return '-' | |||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||
| } | |||
| function preview() { | |||
| if (!detail.value.result_url) return | |||
| uni.previewImage({ urls: [detail.value.result_url], current: detail.value.result_url }) | |||
| } | |||
| function download() { | |||
| if (!detail.value.result_url) return | |||
| uni.showLoading({ title: '保存中...', mask: true }) | |||
| uni.downloadFile({ | |||
| url: detail.value.result_url, | |||
| success: (res) => { | |||
| uni.saveImageToPhotosAlbum({ | |||
| filePath: res.tempFilePath, | |||
| success: () => toast('已保存到相册'), | |||
| fail: () => toast('保存失败,请检查相册权限') | |||
| }) | |||
| }, | |||
| fail: () => toast('图片下载失败'), | |||
| complete: () => uni.hideLoading() | |||
| }) | |||
| } | |||
| function openCompare() { | |||
| if (!detail.value.result_url || !detail.value.source_url) return | |||
| comparePercent.value = 50 | |||
| compareVisible.value = true | |||
| } | |||
| function closeCompare() { | |||
| compareVisible.value = false | |||
| } | |||
| function onCompareTouch(e) { | |||
| const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || e | |||
| const x = touch.clientX || 0 | |||
| uni.createSelectorQuery() | |||
| .select('.large-compare') | |||
| .boundingClientRect((rect) => { | |||
| if (!rect || !rect.width) return | |||
| const percent = ((x - rect.left) / rect.width) * 100 | |||
| comparePercent.value = Math.max(6, Math.min(94, Number(percent.toFixed(1)))) | |||
| }) | |||
| .exec() | |||
| } | |||
| function createAgain() { | |||
| const delta = getPageStackDelta('pages/tool/old-photo') | |||
| if (delta > 0) { | |||
| uni.navigateBack({ delta }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/tool/old-photo' }) | |||
| } | |||
| function getPageStackDelta(routeName) { | |||
| const pages = getCurrentPages() | |||
| const index = pages.findIndex((item) => item.route === routeName) | |||
| return index >= 0 ? pages.length - 1 - index : -1 | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| height: 100vh; | |||
| background: #f4f7fb; | |||
| display: flex; | |||
| flex-direction: column; | |||
| overflow: hidden; | |||
| } | |||
| .scroll { flex: 1; height: 0; } | |||
| /* 底栏是 fixed 的,留够 300rpx */ | |||
| .wrap { padding: 28rpx 28rpx 300rpx; } | |||
| .state-card, | |||
| .image-card, | |||
| .info-card { | |||
| background: #fff; | |||
| border-radius: 18rpx; | |||
| overflow: hidden; | |||
| } | |||
| .state-card { | |||
| min-height: 540rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| color: #172033; | |||
| font-size: 30rpx; | |||
| } | |||
| .spinner { | |||
| width: 72rpx; | |||
| height: 72rpx; | |||
| margin-bottom: 28rpx; | |||
| border: 8rpx solid #e9edf5; | |||
| border-top-color: #0b8cff; | |||
| border-radius: 50%; | |||
| animation: spin 0.8s linear infinite; | |||
| } | |||
| .count, | |||
| .error { margin-top: 18rpx; color: #7f8896; font-size: 26rpx; } | |||
| .error { color: #f04438; padding: 0 40rpx; text-align: center; } | |||
| .image-card { position: relative; } | |||
| .work-img { width: 100%; display: block; } | |||
| .compare-entry { | |||
| position: absolute; | |||
| right: 18rpx; | |||
| bottom: 18rpx; | |||
| height: 56rpx; | |||
| padding: 0 22rpx; | |||
| border-radius: 999rpx; | |||
| color: #fff; | |||
| font-size: 24rpx; | |||
| font-weight: 800; | |||
| line-height: 56rpx; | |||
| background: rgba(15, 23, 42, 0.55); | |||
| } | |||
| .info-card { margin-top: 24rpx; padding: 28rpx; } | |||
| .title { margin-bottom: 16rpx; color: #172033; font-size: 32rpx; font-weight: 700; } | |||
| .cell { | |||
| padding: 10rpx 0; | |||
| display: flex; | |||
| color: #7f8896; | |||
| font-size: 27rpx; | |||
| line-height: 1.6; | |||
| } | |||
| .cell-label { flex-shrink: 0; } | |||
| .cell-content { flex: 1; min-width: 0; color: #172033; } | |||
| .link { color: #0b8cff; } | |||
| .note { | |||
| display: block; | |||
| margin-top: 16rpx; | |||
| padding-top: 16rpx; | |||
| border-top: 2rpx solid #f1f5fa; | |||
| color: #8a95a6; | |||
| font-size: 23rpx; | |||
| line-height: 1.5; | |||
| } | |||
| .bottom-bar { | |||
| position: fixed; | |||
| left: 0; | |||
| right: 0; | |||
| bottom: 0; | |||
| padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom)); | |||
| background: #fff; | |||
| display: flex; | |||
| gap: 18rpx; | |||
| box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08); | |||
| } | |||
| .bar-btn, | |||
| .main-btn { | |||
| height: 82rpx; | |||
| border-radius: 42rpx; | |||
| font-size: 29rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .bar-btn { width: 150rpx; color: #0b8cff; background: #eef6ff; } | |||
| .main-btn { flex: 1; color: #fff; background: #0b8cff; } | |||
| /* 对比弹层:与动漫头像保持一致 */ | |||
| .compare-modal { | |||
| position: fixed; | |||
| left: 0; | |||
| right: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| z-index: 120; | |||
| } | |||
| .compare-backdrop { | |||
| position: absolute; | |||
| left: 0; | |||
| right: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| background: rgba(15, 23, 42, 0.58); | |||
| } | |||
| .compare-panel { | |||
| position: absolute; | |||
| left: 36rpx; | |||
| right: 36rpx; | |||
| top: 50%; | |||
| transform: translateY(-50%); | |||
| padding: 24rpx; | |||
| border-radius: 28rpx; | |||
| background: #ffffff; | |||
| } | |||
| .compare-panel-head { | |||
| margin-bottom: 20rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| color: #172033; | |||
| font-size: 32rpx; | |||
| font-weight: 800; | |||
| } | |||
| .close-btn { | |||
| width: 56rpx; | |||
| height: 56rpx; | |||
| border-radius: 50%; | |||
| color: #64748b; | |||
| background: #f1f5f9; | |||
| font-size: 42rpx; | |||
| line-height: 50rpx; | |||
| text-align: center; | |||
| font-weight: 300; | |||
| } | |||
| .large-compare { | |||
| position: relative; | |||
| width: 100%; | |||
| height: 900rpx; | |||
| max-height: 70vh; | |||
| border-radius: 22rpx; | |||
| background: #dfe8f4; | |||
| overflow: hidden; | |||
| } | |||
| .compare-img { width: 100%; height: 100%; } | |||
| .compare-before { | |||
| position: absolute; | |||
| left: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| overflow: hidden; | |||
| } | |||
| .large-compare .compare-before .compare-img { width: calc(100vw - 120rpx); } | |||
| .compare-line { | |||
| position: absolute; | |||
| top: 0; | |||
| bottom: 0; | |||
| width: 4rpx; | |||
| margin-left: -2rpx; | |||
| background: rgba(255, 255, 255, 0.94); | |||
| box-shadow: 0 0 12rpx rgba(20, 37, 66, 0.22); | |||
| } | |||
| .compare-line.large { width: 6rpx; margin-left: -3rpx; } | |||
| .compare-handle { | |||
| position: absolute; | |||
| left: 50%; | |||
| top: 50%; | |||
| width: 38rpx; | |||
| height: 58rpx; | |||
| margin-left: -19rpx; | |||
| margin-top: -29rpx; | |||
| border-radius: 999rpx; | |||
| background: #ffffff; | |||
| box-shadow: 0 8rpx 18rpx rgba(20, 37, 66, 0.22); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| gap: 1rpx; | |||
| color: #1f8cff; | |||
| font-size: 24rpx; | |||
| font-weight: 900; | |||
| } | |||
| .compare-handle.large { | |||
| width: 54rpx; | |||
| height: 78rpx; | |||
| margin-left: -27rpx; | |||
| margin-top: -39rpx; | |||
| font-size: 32rpx; | |||
| } | |||
| .compare-tag { | |||
| position: absolute; | |||
| top: 12rpx; | |||
| padding: 6rpx 12rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: rgba(15, 23, 42, 0.46); | |||
| font-size: 20rpx; | |||
| font-weight: 700; | |||
| } | |||
| .compare-tag.left { left: 12rpx; } | |||
| .compare-tag.right { right: 12rpx; } | |||
| .compare-guide { | |||
| display: block; | |||
| margin-top: 18rpx; | |||
| color: #64748b; | |||
| font-size: 24rpx; | |||
| text-align: center; | |||
| } | |||
| @keyframes spin { | |||
| to { transform: rotate(360deg); } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,393 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <scroll-view | |||
| class="scroll" | |||
| scroll-y | |||
| refresher-enabled | |||
| :refresher-triggered="refreshing" | |||
| @refresherrefresh="refresh" | |||
| @scrolltolower="loadMore" | |||
| > | |||
| <view class="wrap"> | |||
| <view class="studio-head"> | |||
| <view> | |||
| <text class="eyebrow">OLD PHOTO GALLERY</text> | |||
| <text class="page-title">我的老照片</text> | |||
| <text class="page-desc">每次修复结果都会保存,完成后可查看对比、保存原图或继续修复。</text> | |||
| </view> | |||
| <view class="create-btn" @click="goCreate">修复</view> | |||
| </view> | |||
| <view class="stats-row"> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ total || list.length }}</text> | |||
| <text class="stat-label">全部记录</text> | |||
| </view> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ pendingCount }}</text> | |||
| <text class="stat-label">生成中</text> | |||
| </view> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ successCount }}</text> | |||
| <text class="stat-label">已完成</text> | |||
| </view> | |||
| </view> | |||
| <view v-if="list.length" class="gallery"> | |||
| <view v-for="item in list" :key="item.photo_open_id" class="work-card"> | |||
| <view class="poster" @click="openItem(item)"> | |||
| <image v-if="item.cover" class="poster-img" :src="item.cover" mode="aspectFill" /> | |||
| <view v-else class="poster-empty"></view> | |||
| <view class="status-chip" :class="statusClass(item)">{{ item.status_text }}</view> | |||
| <view v-if="isPending(item)" class="progress-mask"> | |||
| <view class="spinner"></view> | |||
| <text>{{ Number(item.status) === 10 ? '排队中' : '正在修复' }}</text> | |||
| </view> | |||
| <view v-else-if="Number(item.status) < 0" class="failed-mask"> | |||
| <text>{{ item.error_msg || '修复失败' }}</text> | |||
| </view> | |||
| </view> | |||
| <view class="card-info"> | |||
| <text class="mode-name">{{ item.mode_name || '老照片修复' }}</text> | |||
| <view class="time-line"> | |||
| <text>{{ formatTime(item.add_time) }}</text> | |||
| <view class="delete-btn" @click.stop="deleteItem(item)">删除</view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view v-if="!list.length && !loading" class="empty-state"> | |||
| <view class="empty-preview"> | |||
| <view class="empty-photo"></view> | |||
| </view> | |||
| <text class="empty-title">还没有修复记录</text> | |||
| <text class="empty-desc">上传一张老照片,试试修复和上色的效果。</text> | |||
| <view class="empty-action" @click="goCreate">去修复</view> | |||
| </view> | |||
| <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view> | |||
| <view v-if="list.length && list.length >= total && !loading" class="end-text">已经到底了</view> | |||
| </view> | |||
| </scroll-view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { onLoad, onUnload } from '@dcloudio/uni-app' | |||
| import dayjs from 'dayjs' | |||
| import { oldPhotoApi } from '@/api/index.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const { toast } = useApp() | |||
| const list = ref([]) | |||
| const page = ref(1) | |||
| const total = ref(0) | |||
| const loading = ref(false) | |||
| const refreshing = ref(false) | |||
| let timer = 0 | |||
| const pendingCount = computed(() => list.value.filter((item) => isPending(item)).length) | |||
| const successCount = computed(() => list.value.filter((item) => Number(item.status) === 30).length) | |||
| onLoad(() => refresh()) | |||
| onUnload(() => clearTimeout(timer)) | |||
| function isPending(item) { | |||
| return [10, 20].includes(Number(item.status)) | |||
| } | |||
| function formatTime(value) { | |||
| if (!value) return '' | |||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||
| } | |||
| async function fetchList(reset = false) { | |||
| if (loading.value) return | |||
| loading.value = true | |||
| try { | |||
| const res = await oldPhotoApi.getList({ page_no: page.value, page_size: 10 }) | |||
| const rows = res.list || [] | |||
| total.value = Number((res.data && res.data.total) || rows.length) | |||
| list.value = reset ? rows : list.value.concat(rows) | |||
| scheduleRefresh() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '历史记录加载失败') | |||
| } finally { | |||
| loading.value = false | |||
| refreshing.value = false | |||
| } | |||
| } | |||
| // 有进行中的任务就轮询:后端 getlist 会顺带推进任务状态 | |||
| function scheduleRefresh() { | |||
| clearTimeout(timer) | |||
| if (list.value.some((item) => isPending(item))) { | |||
| timer = setTimeout(() => refreshSilent(), 3000) | |||
| } | |||
| } | |||
| function refresh() { | |||
| refreshing.value = true | |||
| refreshSilent() | |||
| } | |||
| function refreshSilent() { | |||
| page.value = 1 | |||
| fetchList(true) | |||
| } | |||
| function loadMore() { | |||
| if (loading.value) return | |||
| if (list.value.length >= total.value) return | |||
| page.value += 1 | |||
| fetchList() | |||
| } | |||
| // 进行中的也能进详情页看进度 | |||
| function openItem(item) { | |||
| if (Number(item.status) < 0) return toast(item.error_msg || '该记录修复失败') | |||
| uni.navigateTo({ url: `/pages/tool/old-photo-detail?id=${item.photo_open_id}` }) | |||
| } | |||
| function statusClass(item) { | |||
| if (isPending(item)) return 'pending' | |||
| if (Number(item.status) < 0) return 'failed' | |||
| return 'done' | |||
| } | |||
| function goCreate() { | |||
| const delta = getPageStackDelta('pages/tool/old-photo') | |||
| if (delta > 0) { | |||
| uni.navigateBack({ delta }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/tool/old-photo' }) | |||
| } | |||
| function getPageStackDelta(routeName) { | |||
| const pages = getCurrentPages() | |||
| const index = pages.findIndex((item) => item.route === routeName) | |||
| return index >= 0 ? pages.length - 1 - index : -1 | |||
| } | |||
| function deleteItem(item) { | |||
| uni.showModal({ | |||
| title: '温馨提示', | |||
| content: '是否确认删除该记录?', | |||
| confirmText: '确认删除', | |||
| success: async (res) => { | |||
| if (!res.confirm) return | |||
| try { | |||
| await oldPhotoApi.delete({ photo_open_id: item.photo_open_id }) | |||
| toast('删除成功') | |||
| refresh() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '删除失败') | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page, | |||
| .scroll { | |||
| height: 100vh; | |||
| background: #f6f8fc; | |||
| } | |||
| .wrap { padding: 24rpx 28rpx 70rpx; } | |||
| .studio-head { | |||
| padding: 30rpx; | |||
| border-radius: 26rpx; | |||
| background: #ffffff; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 24rpx; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05); | |||
| } | |||
| .studio-head > view:first-child { flex: 1; min-width: 0; } | |||
| .eyebrow { display: block; color: #1f8cff; font-size: 20rpx; font-weight: 800; } | |||
| .page-title { | |||
| display: block; | |||
| margin-top: 10rpx; | |||
| color: #172033; | |||
| font-size: 40rpx; | |||
| line-height: 1.2; | |||
| font-weight: 900; | |||
| } | |||
| .page-desc { display: block; margin-top: 12rpx; color: #64748b; font-size: 24rpx; line-height: 1.5; } | |||
| .create-btn { | |||
| width: 112rpx; | |||
| height: 68rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| font-size: 26rpx; | |||
| line-height: 68rpx; | |||
| text-align: center; | |||
| font-weight: 800; | |||
| box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.22); | |||
| flex-shrink: 0; | |||
| } | |||
| .stats-row { | |||
| margin-top: 18rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(3, 1fr); | |||
| gap: 14rpx; | |||
| } | |||
| .stat-card { | |||
| padding: 20rpx 10rpx; | |||
| border-radius: 20rpx; | |||
| background: #ffffff; | |||
| text-align: center; | |||
| box-shadow: 0 8rpx 20rpx rgba(31, 52, 86, 0.04); | |||
| } | |||
| .stat-num { display: block; color: #172033; font-size: 34rpx; line-height: 1; font-weight: 900; } | |||
| .stat-label { display: block; margin-top: 10rpx; color: #7c8a9b; font-size: 22rpx; } | |||
| .gallery { | |||
| margin-top: 24rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |||
| gap: 22rpx; | |||
| } | |||
| .work-card { | |||
| border-radius: 24rpx; | |||
| background: #ffffff; | |||
| overflow: hidden; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.06); | |||
| } | |||
| .poster { | |||
| position: relative; | |||
| width: 100%; | |||
| height: 440rpx; | |||
| background: #dfe7f2; | |||
| overflow: hidden; | |||
| } | |||
| .poster-img, | |||
| .poster-empty { width: 100%; height: 100%; } | |||
| .poster-empty { background: linear-gradient(135deg, #eef4fb, #dbe6f4); } | |||
| .status-chip { | |||
| position: absolute; | |||
| left: 14rpx; | |||
| top: 14rpx; | |||
| height: 42rpx; | |||
| padding: 0 16rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| font-size: 22rpx; | |||
| line-height: 42rpx; | |||
| font-weight: 800; | |||
| background: rgba(15, 23, 42, 0.48); | |||
| } | |||
| .status-chip.done { background: rgba(31, 140, 255, 0.9); } | |||
| .status-chip.pending { background: rgba(249, 115, 22, 0.92); } | |||
| .status-chip.failed { background: rgba(239, 68, 68, 0.9); } | |||
| .progress-mask, | |||
| .failed-mask { | |||
| position: absolute; | |||
| left: 0; | |||
| right: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| padding: 24rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| color: #ffffff; | |||
| font-size: 26rpx; | |||
| text-align: center; | |||
| background: rgba(15, 23, 42, 0.52); | |||
| } | |||
| .failed-mask { background: rgba(127, 29, 29, 0.72); font-size: 24rpx; } | |||
| .spinner { | |||
| width: 48rpx; | |||
| height: 48rpx; | |||
| margin-bottom: 18rpx; | |||
| border: 6rpx solid rgba(255, 255, 255, 0.45); | |||
| border-top-color: #ffffff; | |||
| border-radius: 50%; | |||
| animation: spin 0.8s linear infinite; | |||
| } | |||
| .card-info { padding: 18rpx; } | |||
| .mode-name { | |||
| display: block; | |||
| margin-bottom: 10rpx; | |||
| color: #172033; | |||
| font-size: 25rpx; | |||
| font-weight: 800; | |||
| } | |||
| .time-line { display: flex; align-items: center; gap: 12rpx; color: #64748b; font-size: 22rpx; } | |||
| .time-line text { flex: 1; min-width: 0; } | |||
| .delete-btn { | |||
| width: 74rpx; | |||
| height: 44rpx; | |||
| border-radius: 999rpx; | |||
| color: #ef4444; | |||
| background: #fff1f2; | |||
| font-size: 22rpx; | |||
| line-height: 44rpx; | |||
| text-align: center; | |||
| flex-shrink: 0; | |||
| } | |||
| .empty-state { | |||
| margin-top: 86rpx; | |||
| padding: 56rpx 34rpx; | |||
| border-radius: 28rpx; | |||
| background: #ffffff; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| text-align: center; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05); | |||
| } | |||
| .empty-preview { | |||
| width: 150rpx; | |||
| height: 200rpx; | |||
| border-radius: 32rpx; | |||
| background: linear-gradient(135deg, #edf6ff, #fff4f0); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .empty-photo { | |||
| width: 88rpx; | |||
| height: 108rpx; | |||
| border-radius: 14rpx; | |||
| border: 6rpx solid #1f8cff; | |||
| background: linear-gradient(135deg, #d7d0c6 50%, #5db2d9 50%); | |||
| } | |||
| .empty-title { margin-top: 28rpx; color: #172033; font-size: 32rpx; font-weight: 900; } | |||
| .empty-desc { margin-top: 12rpx; color: #64748b; font-size: 25rpx; line-height: 1.5; } | |||
| .empty-action { | |||
| margin-top: 28rpx; | |||
| width: 220rpx; | |||
| height: 72rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| font-size: 28rpx; | |||
| line-height: 72rpx; | |||
| font-weight: 800; | |||
| } | |||
| .loading, | |||
| .end-text { padding: 46rpx 0 20rpx; color: #7c8a9b; font-size: 24rpx; text-align: center; } | |||
| @keyframes spin { | |||
| to { transform: rotate(360deg); } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,503 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <scroll-view class="scroll" scroll-y> | |||
| <view class="wrap"> | |||
| <!-- 处理中 / 失败 --> | |||
| <view v-if="!isSuccess" class="state-card"> | |||
| <view v-if="isPending" class="spinner"></view> | |||
| <text class="state-title">{{ stateTitle }}</text> | |||
| <text v-if="isPending" class="count">{{ detail.success_count || 0 }} / {{ detail.count || 0 }} 张 · {{ countDown }}S</text> | |||
| <text v-if="!isPending" class="error">{{ detail.error_msg || '失败的照片不扣点,可以重新生成' }}</text> | |||
| </view> | |||
| <template v-else> | |||
| <!-- 主图 + 缩略图切换 --> | |||
| <view class="image-card" @click="preview"> | |||
| <image class="work-img" :src="activeUrl" mode="widthFix" /> | |||
| <view class="index-chip">{{ activeIndex + 1 }} / {{ resultUrls.length }}</view> | |||
| </view> | |||
| <view v-if="resultUrls.length > 1" class="thumb-row"> | |||
| <image | |||
| v-for="(url, index) in resultUrls" | |||
| :key="url" | |||
| class="thumb" | |||
| :class="{ active: index === activeIndex }" | |||
| :src="url" | |||
| mode="aspectFill" | |||
| @click="activeIndex = index" | |||
| /> | |||
| </view> | |||
| </template> | |||
| <view class="info-card"> | |||
| <view class="title">生成信息</view> | |||
| <view class="cell"> | |||
| <text class="cell-label">照片风格:</text> | |||
| <text class="cell-content">{{ detail.style_name || '-' }}</text> | |||
| </view> | |||
| <view v-for="item in optionTexts" :key="item.label" class="cell"> | |||
| <text class="cell-label">{{ item.label }}:</text> | |||
| <text class="cell-content">{{ item.value }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">生成模型:</text> | |||
| <text class="cell-content">{{ detail.model_name || '-' }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">图片比例:</text> | |||
| <text class="cell-content">{{ detail.ratio || '-' }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">生成张数:</text> | |||
| <text class="cell-content"> | |||
| 成功 {{ detail.success_count || 0 }} 张<text v-if="detail.fail_count">,失败 {{ detail.fail_count }} 张</text> | |||
| </text> | |||
| </view> | |||
| <view v-if="detail.extra_prompt" class="cell"> | |||
| <text class="cell-label">补充描述:</text> | |||
| <text class="cell-content">{{ detail.extra_prompt }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">创建时间:</text> | |||
| <text class="cell-content">{{ formatTime(detail.add_time) }}</text> | |||
| </view> | |||
| <view class="cell"> | |||
| <text class="cell-label">功能来源:</text> | |||
| <text class="link" @click="createAgain">形象照 / 职业照 / 写真</text> | |||
| </view> | |||
| <text class="note">AI 会重绘服装与背景,五官可能出现轻微变化,建议对照原图确认。</text> | |||
| </view> | |||
| </view> | |||
| </scroll-view> | |||
| <view v-if="isSuccess" class="bottom-bar"> | |||
| <view class="bar-btn" @click="save">保存</view> | |||
| <view class="bar-btn" @click="openCompare">对比</view> | |||
| <view class="main-btn" @click="createAgain">再拍一组</view> | |||
| </view> | |||
| <!-- 前后对比:样式与动漫头像一致 --> | |||
| <view v-if="compareVisible" class="compare-modal"> | |||
| <view class="compare-backdrop" @click="closeCompare"></view> | |||
| <view class="compare-panel"> | |||
| <view class="compare-panel-head"> | |||
| <text>效果对比</text> | |||
| <view class="close-btn" @click="closeCompare">×</view> | |||
| </view> | |||
| <view | |||
| class="large-compare" | |||
| @touchstart.stop="onCompareTouch" | |||
| @touchmove.stop.prevent="onCompareTouch" | |||
| @click.stop="onCompareTouch" | |||
| > | |||
| <image class="compare-img" :src="activeUrl" mode="aspectFit" /> | |||
| <view class="compare-before" :style="{ width: comparePercent + '%' }"> | |||
| <image class="compare-img" :src="detail.source_url" mode="aspectFit" /> | |||
| </view> | |||
| <view class="compare-line large" :style="{ left: comparePercent + '%' }"> | |||
| <view class="compare-handle large"> | |||
| <text>‹</text> | |||
| <text>›</text> | |||
| </view> | |||
| </view> | |||
| <view class="compare-tag left">原图</view> | |||
| <view class="compare-tag right">生成图</view> | |||
| </view> | |||
| <text class="compare-guide">左右滑动中间按钮查看变化</text> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { onLoad, onUnload } from '@dcloudio/uni-app' | |||
| import dayjs from 'dayjs' | |||
| import { portraitApi } from '@/api/index.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const { toast } = useApp() | |||
| const id = ref('') | |||
| const detail = ref({ status: 10 }) | |||
| const activeIndex = ref(0) | |||
| const countDown = ref(0) | |||
| const compareVisible = ref(false) | |||
| const comparePercent = ref(50) | |||
| let timer = 0 | |||
| let countTimer = 0 | |||
| // 后端 options 是 key,这里映射成中文;名称与 utils/portrait.js 的预设保持一致 | |||
| const OPTION_LABELS = { | |||
| background: '背景', | |||
| outfit: '服装', | |||
| framing: '取景', | |||
| art_style: '写真风格' | |||
| } | |||
| const OPTION_NAMES = { | |||
| white: '白色', gray: '浅灰', blue: '商务蓝', 'deep-blue': '深蓝', beige: '米色', | |||
| office: '办公室', bookshelf: '书架', cafe: '咖啡角', window: '落地窗', | |||
| formal: '正装', shirt: '衬衫', knit: '针织衫', 'tshirt-jeans': 'T恤配牛仔裤', | |||
| 'blazer-tshirt': '西装外套配T恤', bust: '胸像', half: '半身', | |||
| guofeng: '古风', zangzu: '藏族', miaozu: '苗族', urban: '都市青年', trendy: '潮人', | |||
| dunhuang: '敦煌飞仙', hongkong: '港风复古', newchinese: '新中式', french: '法式油画' | |||
| } | |||
| const resultUrls = computed(() => detail.value.result_urls || []) | |||
| const activeUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '') | |||
| const isPending = computed(() => [10, 20].includes(Number(detail.value.status))) | |||
| const isSuccess = computed(() => Number(detail.value.status) === 30 && resultUrls.value.length > 0) | |||
| const stateTitle = computed(() => { | |||
| if (Number(detail.value.status) === 10) return '排队中,请耐心等待' | |||
| if (Number(detail.value.status) === 20) return 'AI 正在生成形象照' | |||
| return '生成失败' | |||
| }) | |||
| const optionTexts = computed(() => { | |||
| const options = detail.value.options || {} | |||
| return Object.keys(options).map((key) => ({ | |||
| label: OPTION_LABELS[key] || key, | |||
| value: OPTION_NAMES[options[key]] || options[key] | |||
| })) | |||
| }) | |||
| onLoad((options) => { | |||
| id.value = (options && options.id) || '' | |||
| loadDetail() | |||
| }) | |||
| onUnload(() => { | |||
| clearTimeout(timer) | |||
| clearInterval(countTimer) | |||
| }) | |||
| async function loadDetail() { | |||
| if (!id.value) return | |||
| try { | |||
| const res = await portraitApi.getDetail({ photo_open_id: id.value }) | |||
| detail.value = res.data || {} | |||
| if (activeIndex.value >= resultUrls.value.length) activeIndex.value = 0 | |||
| if (isPending.value) waitNext() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '记录加载失败') | |||
| } | |||
| } | |||
| // 处理中就 3 秒拉一次,后端会在这一步推进任务并转存结果 | |||
| function waitNext() { | |||
| clearTimeout(timer) | |||
| clearInterval(countTimer) | |||
| countDown.value = 3 | |||
| countTimer = setInterval(() => { | |||
| countDown.value -= 1 | |||
| if (countDown.value <= 0) clearInterval(countTimer) | |||
| }, 1000) | |||
| timer = setTimeout(loadDetail, 3000) | |||
| } | |||
| function formatTime(value) { | |||
| if (!value) return '-' | |||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||
| } | |||
| function preview() { | |||
| if (!resultUrls.value.length) return | |||
| uni.previewImage({ urls: resultUrls.value, current: activeUrl.value }) | |||
| } | |||
| function save() { | |||
| if (!activeUrl.value) return | |||
| uni.showLoading({ title: '保存中...', mask: true }) | |||
| uni.downloadFile({ | |||
| url: activeUrl.value, | |||
| success: (res) => { | |||
| uni.saveImageToPhotosAlbum({ | |||
| filePath: res.tempFilePath, | |||
| success: () => toast('已保存到相册'), | |||
| fail: () => toast('保存失败,请检查相册权限') | |||
| }) | |||
| }, | |||
| fail: () => toast('图片下载失败'), | |||
| complete: () => uni.hideLoading() | |||
| }) | |||
| } | |||
| function openCompare() { | |||
| if (!activeUrl.value || !detail.value.source_url) return | |||
| comparePercent.value = 50 | |||
| compareVisible.value = true | |||
| } | |||
| function closeCompare() { | |||
| compareVisible.value = false | |||
| } | |||
| function onCompareTouch(e) { | |||
| const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || e | |||
| const x = touch.clientX || 0 | |||
| uni.createSelectorQuery() | |||
| .select('.large-compare') | |||
| .boundingClientRect((rect) => { | |||
| if (!rect || !rect.width) return | |||
| const percent = ((x - rect.left) / rect.width) * 100 | |||
| comparePercent.value = Math.max(6, Math.min(94, Number(percent.toFixed(1)))) | |||
| }) | |||
| .exec() | |||
| } | |||
| function createAgain() { | |||
| const delta = getPageStackDelta('pages/tool/portrait-photo') | |||
| if (delta > 0) { | |||
| uni.navigateBack({ delta }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/tool/portrait-photo' }) | |||
| } | |||
| function getPageStackDelta(routeName) { | |||
| const pages = getCurrentPages() | |||
| const index = pages.findIndex((item) => item.route === routeName) | |||
| return index >= 0 ? pages.length - 1 - index : -1 | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| height: 100vh; | |||
| background: #f4f7fb; | |||
| display: flex; | |||
| flex-direction: column; | |||
| overflow: hidden; | |||
| } | |||
| .scroll { flex: 1; height: 0; } | |||
| /* 底栏是 fixed 的,留够 300rpx */ | |||
| .wrap { padding: 28rpx 28rpx 300rpx; } | |||
| .state-card, | |||
| .image-card, | |||
| .info-card { | |||
| background: #fff; | |||
| border-radius: 18rpx; | |||
| overflow: hidden; | |||
| } | |||
| .state-card { | |||
| min-height: 540rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| color: #172033; | |||
| font-size: 30rpx; | |||
| } | |||
| .spinner { | |||
| width: 72rpx; | |||
| height: 72rpx; | |||
| margin-bottom: 28rpx; | |||
| border: 8rpx solid #e9edf5; | |||
| border-top-color: #0b8cff; | |||
| border-radius: 50%; | |||
| animation: spin 0.8s linear infinite; | |||
| } | |||
| .count, | |||
| .error { margin-top: 18rpx; color: #7f8896; font-size: 26rpx; } | |||
| .error { color: #f04438; padding: 0 40rpx; text-align: center; } | |||
| .image-card { position: relative; } | |||
| .work-img { width: 100%; display: block; } | |||
| .index-chip { | |||
| position: absolute; | |||
| right: 18rpx; | |||
| bottom: 18rpx; | |||
| height: 48rpx; | |||
| padding: 0 20rpx; | |||
| border-radius: 999rpx; | |||
| color: #fff; | |||
| font-size: 23rpx; | |||
| font-weight: 800; | |||
| line-height: 48rpx; | |||
| background: rgba(15, 23, 42, 0.55); | |||
| } | |||
| .thumb-row { | |||
| margin-top: 18rpx; | |||
| display: flex; | |||
| gap: 14rpx; | |||
| } | |||
| .thumb { | |||
| width: 150rpx; | |||
| height: 150rpx; | |||
| border-radius: 16rpx; | |||
| background: #eef2f7; | |||
| border: 4rpx solid transparent; | |||
| } | |||
| .thumb.active { border-color: #0b8cff; } | |||
| .info-card { margin-top: 24rpx; padding: 28rpx; } | |||
| .title { margin-bottom: 16rpx; color: #172033; font-size: 32rpx; font-weight: 700; } | |||
| .cell { | |||
| padding: 10rpx 0; | |||
| display: flex; | |||
| color: #7f8896; | |||
| font-size: 27rpx; | |||
| line-height: 1.6; | |||
| } | |||
| .cell-label { flex-shrink: 0; } | |||
| .cell-content { flex: 1; min-width: 0; color: #172033; } | |||
| .link { color: #0b8cff; } | |||
| .note { | |||
| display: block; | |||
| margin-top: 16rpx; | |||
| padding-top: 16rpx; | |||
| border-top: 2rpx solid #f1f5fa; | |||
| color: #8a95a6; | |||
| font-size: 23rpx; | |||
| line-height: 1.5; | |||
| } | |||
| .bottom-bar { | |||
| position: fixed; | |||
| left: 0; | |||
| right: 0; | |||
| bottom: 0; | |||
| padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom)); | |||
| background: #fff; | |||
| display: flex; | |||
| gap: 18rpx; | |||
| box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08); | |||
| } | |||
| .bar-btn, | |||
| .main-btn { | |||
| height: 82rpx; | |||
| border-radius: 42rpx; | |||
| font-size: 29rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .bar-btn { width: 150rpx; color: #0b8cff; background: #eef6ff; } | |||
| .main-btn { flex: 1; color: #fff; background: #0b8cff; } | |||
| /* 对比弹层:与动漫头像保持一致 */ | |||
| .compare-modal { | |||
| position: fixed; | |||
| left: 0; | |||
| right: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| z-index: 120; | |||
| } | |||
| .compare-backdrop { | |||
| position: absolute; | |||
| left: 0; | |||
| right: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| background: rgba(15, 23, 42, 0.58); | |||
| } | |||
| .compare-panel { | |||
| position: absolute; | |||
| left: 36rpx; | |||
| right: 36rpx; | |||
| top: 50%; | |||
| transform: translateY(-50%); | |||
| padding: 24rpx; | |||
| border-radius: 28rpx; | |||
| background: #ffffff; | |||
| } | |||
| .compare-panel-head { | |||
| margin-bottom: 20rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| color: #172033; | |||
| font-size: 32rpx; | |||
| font-weight: 800; | |||
| } | |||
| .close-btn { | |||
| width: 56rpx; | |||
| height: 56rpx; | |||
| border-radius: 50%; | |||
| color: #64748b; | |||
| background: #f1f5f9; | |||
| font-size: 42rpx; | |||
| line-height: 50rpx; | |||
| text-align: center; | |||
| font-weight: 300; | |||
| } | |||
| .large-compare { | |||
| position: relative; | |||
| width: 100%; | |||
| height: 900rpx; | |||
| max-height: 70vh; | |||
| border-radius: 22rpx; | |||
| background: #dfe8f4; | |||
| overflow: hidden; | |||
| } | |||
| .compare-img { width: 100%; height: 100%; } | |||
| .compare-before { | |||
| position: absolute; | |||
| left: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| overflow: hidden; | |||
| } | |||
| .large-compare .compare-before .compare-img { width: calc(100vw - 120rpx); } | |||
| .compare-line { | |||
| position: absolute; | |||
| top: 0; | |||
| bottom: 0; | |||
| width: 4rpx; | |||
| margin-left: -2rpx; | |||
| background: rgba(255, 255, 255, 0.94); | |||
| box-shadow: 0 0 12rpx rgba(20, 37, 66, 0.22); | |||
| } | |||
| .compare-line.large { width: 6rpx; margin-left: -3rpx; } | |||
| .compare-handle { | |||
| position: absolute; | |||
| left: 50%; | |||
| top: 50%; | |||
| width: 38rpx; | |||
| height: 58rpx; | |||
| margin-left: -19rpx; | |||
| margin-top: -29rpx; | |||
| border-radius: 999rpx; | |||
| background: #ffffff; | |||
| box-shadow: 0 8rpx 18rpx rgba(20, 37, 66, 0.22); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| gap: 1rpx; | |||
| color: #1f8cff; | |||
| font-size: 24rpx; | |||
| font-weight: 900; | |||
| } | |||
| .compare-handle.large { | |||
| width: 54rpx; | |||
| height: 78rpx; | |||
| margin-left: -27rpx; | |||
| margin-top: -39rpx; | |||
| font-size: 32rpx; | |||
| } | |||
| .compare-tag { | |||
| position: absolute; | |||
| top: 12rpx; | |||
| padding: 6rpx 12rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: rgba(15, 23, 42, 0.46); | |||
| font-size: 20rpx; | |||
| font-weight: 700; | |||
| } | |||
| .compare-tag.left { left: 12rpx; } | |||
| .compare-tag.right { right: 12rpx; } | |||
| .compare-guide { | |||
| display: block; | |||
| margin-top: 18rpx; | |||
| color: #64748b; | |||
| font-size: 24rpx; | |||
| text-align: center; | |||
| } | |||
| @keyframes spin { | |||
| to { transform: rotate(360deg); } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,408 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <scroll-view | |||
| class="scroll" | |||
| scroll-y | |||
| refresher-enabled | |||
| :refresher-triggered="refreshing" | |||
| @refresherrefresh="refresh" | |||
| @scrolltolower="loadMore" | |||
| > | |||
| <view class="wrap"> | |||
| <view class="studio-head"> | |||
| <view> | |||
| <text class="eyebrow">PORTRAIT GALLERY</text> | |||
| <text class="page-title">我的形象照</text> | |||
| <text class="page-desc">每次生成的照片都会保存,完成后可查看大图、保存原图或继续创作。</text> | |||
| </view> | |||
| <view class="create-btn" @click="goCreate">生成</view> | |||
| </view> | |||
| <view class="stats-row"> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ total || list.length }}</text> | |||
| <text class="stat-label">全部记录</text> | |||
| </view> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ pendingCount }}</text> | |||
| <text class="stat-label">生成中</text> | |||
| </view> | |||
| <view class="stat-card"> | |||
| <text class="stat-num">{{ photoCount }}</text> | |||
| <text class="stat-label">已出图</text> | |||
| </view> | |||
| </view> | |||
| <view v-if="list.length" class="gallery"> | |||
| <view v-for="item in list" :key="item.photo_open_id" class="work-card"> | |||
| <view class="poster" @click="openItem(item)"> | |||
| <image v-if="item.cover" class="poster-img" :src="item.cover" mode="aspectFill" /> | |||
| <view v-else class="poster-empty"></view> | |||
| <view class="status-chip" :class="statusClass(item)">{{ item.status_text }}</view> | |||
| <view v-if="item.success_count > 1" class="count-chip">{{ item.success_count }}张</view> | |||
| <view v-if="isPending(item)" class="progress-mask"> | |||
| <view class="spinner"></view> | |||
| <text>{{ item.success_count || 0 }}/{{ item.count || 0 }} 张</text> | |||
| </view> | |||
| <view v-else-if="Number(item.status) < 0" class="failed-mask"> | |||
| <text>{{ item.error_msg || '生成失败' }}</text> | |||
| </view> | |||
| </view> | |||
| <view class="card-info"> | |||
| <text class="style-name">{{ item.style_name || '形象照' }}</text> | |||
| <view class="time-line"> | |||
| <text>{{ formatTime(item.add_time) }}</text> | |||
| <view class="delete-btn" @click.stop="deleteItem(item)">删除</view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view v-if="!list.length && !loading" class="empty-state"> | |||
| <view class="empty-preview"> | |||
| <view class="empty-photo"></view> | |||
| </view> | |||
| <text class="empty-title">还没有形象照</text> | |||
| <text class="empty-desc">上传一张人像照片,试试商务形象照或艺术写真。</text> | |||
| <view class="empty-action" @click="goCreate">去生成</view> | |||
| </view> | |||
| <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view> | |||
| <view v-if="list.length && list.length >= total && !loading" class="end-text">已经到底了</view> | |||
| </view> | |||
| </scroll-view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { onLoad, onUnload } from '@dcloudio/uni-app' | |||
| import dayjs from 'dayjs' | |||
| import { portraitApi } from '@/api/index.js' | |||
| import { useApp } from '@/utils/useApp.js' | |||
| const { toast } = useApp() | |||
| const list = ref([]) | |||
| const page = ref(1) | |||
| const total = ref(0) | |||
| const loading = ref(false) | |||
| const refreshing = ref(false) | |||
| let timer = 0 | |||
| const pendingCount = computed(() => list.value.filter((item) => isPending(item)).length) | |||
| const photoCount = computed(() => | |||
| list.value.reduce((sum, item) => sum + Number(item.success_count || 0), 0) | |||
| ) | |||
| onLoad(() => refresh()) | |||
| onUnload(() => clearTimeout(timer)) | |||
| function isPending(item) { | |||
| return [10, 20].includes(Number(item.status)) | |||
| } | |||
| function formatTime(value) { | |||
| if (!value) return '' | |||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||
| } | |||
| async function fetchList(reset = false) { | |||
| if (loading.value) return | |||
| loading.value = true | |||
| try { | |||
| const res = await portraitApi.getList({ page_no: page.value, page_size: 10 }) | |||
| const rows = res.list || [] | |||
| total.value = Number((res.data && res.data.total) || rows.length) | |||
| list.value = reset ? rows : list.value.concat(rows) | |||
| scheduleRefresh() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '记录加载失败') | |||
| } finally { | |||
| loading.value = false | |||
| refreshing.value = false | |||
| } | |||
| } | |||
| // 有进行中的任务就轮询:后端 getlist 会顺带推进任务状态 | |||
| function scheduleRefresh() { | |||
| clearTimeout(timer) | |||
| if (list.value.some((item) => isPending(item))) { | |||
| timer = setTimeout(() => refreshSilent(), 3000) | |||
| } | |||
| } | |||
| function refresh() { | |||
| refreshing.value = true | |||
| refreshSilent() | |||
| } | |||
| function refreshSilent() { | |||
| page.value = 1 | |||
| fetchList(true) | |||
| } | |||
| function loadMore() { | |||
| if (loading.value) return | |||
| if (list.value.length >= total.value) return | |||
| page.value += 1 | |||
| fetchList() | |||
| } | |||
| function openItem(item) { | |||
| if (Number(item.status) < 0) return toast(item.error_msg || '该记录生成失败') | |||
| uni.navigateTo({ url: `/pages/tool/portrait-detail?id=${item.photo_open_id}` }) | |||
| } | |||
| function statusClass(item) { | |||
| if (isPending(item)) return 'pending' | |||
| if (Number(item.status) < 0) return 'failed' | |||
| return 'done' | |||
| } | |||
| function goCreate() { | |||
| const delta = getPageStackDelta('pages/tool/portrait-photo') | |||
| if (delta > 0) { | |||
| uni.navigateBack({ delta }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/tool/portrait-photo' }) | |||
| } | |||
| function getPageStackDelta(routeName) { | |||
| const pages = getCurrentPages() | |||
| const index = pages.findIndex((item) => item.route === routeName) | |||
| return index >= 0 ? pages.length - 1 - index : -1 | |||
| } | |||
| function deleteItem(item) { | |||
| uni.showModal({ | |||
| title: '温馨提示', | |||
| content: '是否确认删除该记录?', | |||
| confirmText: '确认删除', | |||
| success: async (res) => { | |||
| if (!res.confirm) return | |||
| try { | |||
| await portraitApi.delete({ photo_open_id: item.photo_open_id }) | |||
| toast('删除成功') | |||
| refresh() | |||
| } catch (e) { | |||
| toast((e && e.msg) || '删除失败') | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page, | |||
| .scroll { | |||
| height: 100vh; | |||
| background: #f6f8fc; | |||
| } | |||
| .wrap { padding: 24rpx 28rpx 70rpx; } | |||
| .studio-head { | |||
| padding: 30rpx; | |||
| border-radius: 26rpx; | |||
| background: #ffffff; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 24rpx; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05); | |||
| } | |||
| .studio-head > view:first-child { flex: 1; min-width: 0; } | |||
| .eyebrow { display: block; color: #1f8cff; font-size: 20rpx; font-weight: 800; } | |||
| .page-title { | |||
| display: block; | |||
| margin-top: 10rpx; | |||
| color: #172033; | |||
| font-size: 40rpx; | |||
| line-height: 1.2; | |||
| font-weight: 900; | |||
| } | |||
| .page-desc { display: block; margin-top: 12rpx; color: #64748b; font-size: 24rpx; line-height: 1.5; } | |||
| .create-btn { | |||
| width: 112rpx; | |||
| height: 68rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| font-size: 26rpx; | |||
| line-height: 68rpx; | |||
| text-align: center; | |||
| font-weight: 800; | |||
| box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.22); | |||
| flex-shrink: 0; | |||
| } | |||
| .stats-row { | |||
| margin-top: 18rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(3, 1fr); | |||
| gap: 14rpx; | |||
| } | |||
| .stat-card { | |||
| padding: 20rpx 10rpx; | |||
| border-radius: 20rpx; | |||
| background: #ffffff; | |||
| text-align: center; | |||
| box-shadow: 0 8rpx 20rpx rgba(31, 52, 86, 0.04); | |||
| } | |||
| .stat-num { display: block; color: #172033; font-size: 34rpx; line-height: 1; font-weight: 900; } | |||
| .stat-label { display: block; margin-top: 10rpx; color: #7c8a9b; font-size: 22rpx; } | |||
| .gallery { | |||
| margin-top: 24rpx; | |||
| display: grid; | |||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |||
| gap: 22rpx; | |||
| } | |||
| .work-card { | |||
| border-radius: 24rpx; | |||
| background: #ffffff; | |||
| overflow: hidden; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.06); | |||
| } | |||
| .poster { | |||
| position: relative; | |||
| width: 100%; | |||
| height: 440rpx; | |||
| background: #dfe7f2; | |||
| overflow: hidden; | |||
| } | |||
| .poster-img, | |||
| .poster-empty { width: 100%; height: 100%; } | |||
| .poster-empty { background: linear-gradient(135deg, #eef4fb, #dbe6f4); } | |||
| .status-chip { | |||
| position: absolute; | |||
| left: 14rpx; | |||
| top: 14rpx; | |||
| height: 42rpx; | |||
| padding: 0 16rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| font-size: 22rpx; | |||
| line-height: 42rpx; | |||
| font-weight: 800; | |||
| background: rgba(15, 23, 42, 0.48); | |||
| } | |||
| .status-chip.done { background: rgba(31, 140, 255, 0.9); } | |||
| .status-chip.pending { background: rgba(249, 115, 22, 0.92); } | |||
| .status-chip.failed { background: rgba(239, 68, 68, 0.9); } | |||
| .count-chip { | |||
| position: absolute; | |||
| right: 14rpx; | |||
| top: 14rpx; | |||
| height: 42rpx; | |||
| padding: 0 14rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| font-size: 21rpx; | |||
| line-height: 42rpx; | |||
| font-weight: 800; | |||
| background: rgba(15, 23, 42, 0.48); | |||
| } | |||
| .progress-mask, | |||
| .failed-mask { | |||
| position: absolute; | |||
| left: 0; | |||
| right: 0; | |||
| top: 0; | |||
| bottom: 0; | |||
| padding: 24rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| color: #ffffff; | |||
| font-size: 26rpx; | |||
| text-align: center; | |||
| background: rgba(15, 23, 42, 0.52); | |||
| } | |||
| .failed-mask { background: rgba(127, 29, 29, 0.72); font-size: 24rpx; } | |||
| .spinner { | |||
| width: 48rpx; | |||
| height: 48rpx; | |||
| margin-bottom: 18rpx; | |||
| border: 6rpx solid rgba(255, 255, 255, 0.45); | |||
| border-top-color: #ffffff; | |||
| border-radius: 50%; | |||
| animation: spin 0.8s linear infinite; | |||
| } | |||
| .card-info { padding: 18rpx; } | |||
| .style-name { | |||
| display: block; | |||
| margin-bottom: 10rpx; | |||
| color: #172033; | |||
| font-size: 25rpx; | |||
| font-weight: 800; | |||
| } | |||
| .time-line { display: flex; align-items: center; gap: 12rpx; color: #64748b; font-size: 22rpx; } | |||
| .time-line text { flex: 1; min-width: 0; } | |||
| .delete-btn { | |||
| width: 74rpx; | |||
| height: 44rpx; | |||
| border-radius: 999rpx; | |||
| color: #ef4444; | |||
| background: #fff1f2; | |||
| font-size: 22rpx; | |||
| line-height: 44rpx; | |||
| text-align: center; | |||
| flex-shrink: 0; | |||
| } | |||
| .empty-state { | |||
| margin-top: 86rpx; | |||
| padding: 56rpx 34rpx; | |||
| border-radius: 28rpx; | |||
| background: #ffffff; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| text-align: center; | |||
| box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05); | |||
| } | |||
| .empty-preview { | |||
| width: 150rpx; | |||
| height: 200rpx; | |||
| border-radius: 32rpx; | |||
| background: linear-gradient(135deg, #edf6ff, #eef2ff); | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .empty-photo { | |||
| width: 88rpx; | |||
| height: 108rpx; | |||
| border-radius: 14rpx; | |||
| border: 6rpx solid #1f8cff; | |||
| background: linear-gradient(180deg, #c9d8ef 40%, #2f4468 40%); | |||
| } | |||
| .empty-title { margin-top: 28rpx; color: #172033; font-size: 32rpx; font-weight: 900; } | |||
| .empty-desc { margin-top: 12rpx; color: #64748b; font-size: 25rpx; line-height: 1.5; } | |||
| .empty-action { | |||
| margin-top: 28rpx; | |||
| width: 220rpx; | |||
| height: 72rpx; | |||
| border-radius: 999rpx; | |||
| color: #ffffff; | |||
| background: #1f8cff; | |||
| font-size: 28rpx; | |||
| line-height: 72rpx; | |||
| font-weight: 800; | |||
| } | |||
| .loading, | |||
| .end-text { padding: 46rpx 0 20rpx; color: #7c8a9b; font-size: 24rpx; text-align: center; } | |||
| @keyframes spin { | |||
| to { transform: rotate(360deg); } | |||
| } | |||
| </style> | |||
| @@ -160,8 +160,9 @@ function getPageStackDelta(routeName) { | |||
| height: 0; | |||
| } | |||
| /* 底栏是 fixed 的,留够 300rpx 才不会盖住内容 */ | |||
| .wrap { | |||
| padding: 28rpx 28rpx 150rpx; | |||
| padding: 28rpx 28rpx 300rpx; | |||
| } | |||
| .state-card, | |||
| @@ -32,7 +32,9 @@ | |||
| <view class="tool-list"> | |||
| <view v-for="item in apps" :key="item.id || item.type || item.title" class="tool-row" @click="onTool(item)"> | |||
| <image class="tool-icon" :src="item.icon" mode="aspectFit" /> | |||
| <view class="tool-icon-wrap"> | |||
| <image class="tool-icon" :src="item.icon" mode="aspectFill" /> | |||
| </view> | |||
| <view class="tool-main"> | |||
| <text class="tool-name">{{ item.name }}</text> | |||
| <text class="tool-desc">{{ item.desc }}</text> | |||
| @@ -301,10 +303,19 @@ function onRefresh() { | |||
| } | |||
| } | |||
| .tool-icon { | |||
| .tool-icon-wrap { | |||
| width: 91rpx; | |||
| height: 91rpx; | |||
| flex-shrink: 0; | |||
| border-radius: 12rpx; | |||
| overflow: hidden; | |||
| background: #edf7ff; | |||
| } | |||
| .tool-icon { | |||
| width: 100%; | |||
| height: 100%; | |||
| display: block; | |||
| } | |||
| .tool-main { | |||
| @@ -5,24 +5,36 @@ | |||
| <scroll-view | |||
| class="page-scroll" | |||
| scroll-y | |||
| refresher-enabled | |||
| :refresher-enabled="userStore.isLogin" | |||
| :refresher-triggered="refreshing" | |||
| @refresherrefresh="onRefresh" | |||
| @scrolltolower="loadMore" | |||
| > | |||
| <view class="works-content"> | |||
| <view class="tabs"> | |||
| <view | |||
| v-for="item in tabs" | |||
| :key="item.key" | |||
| class="tab" | |||
| :class="{ active: activeTab === item.key }" | |||
| @click="changeTab(item.key)" | |||
| > | |||
| <text class="tab-name">{{ item.name }}</text> | |||
| <text class="tab-count">{{ counts[item.key] || 0 }}</text> | |||
| <!-- 类型标签会随功能增加变多,横向滚动,选中项自动滚到居中 --> | |||
| <scroll-view | |||
| id="tabs-scroll" | |||
| class="tabs-scroll" | |||
| scroll-x | |||
| scroll-with-animation | |||
| :scroll-left="tabsScrollLeft" | |||
| :show-scrollbar="false" | |||
| :enhanced="true" | |||
| > | |||
| <view class="tabs"> | |||
| <view | |||
| v-for="item in tabs" | |||
| :key="item.key" | |||
| :id="`tab-${item.key}`" | |||
| class="tab" | |||
| :class="{ active: activeTab === item.key }" | |||
| @click="changeTab(item.key)" | |||
| > | |||
| <text class="tab-name">{{ item.name }}</text> | |||
| <text v-if="userStore.isLogin" class="tab-count">{{ counts[item.key] || 0 }}</text> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </scroll-view> | |||
| <view v-if="list.length" class="works-grid"> | |||
| <view | |||
| @@ -91,7 +103,7 @@ | |||
| </template> | |||
| <script setup> | |||
| import { computed, ref } from 'vue' | |||
| import { computed, nextTick, ref } from 'vue' | |||
| import { onHide, onShow, onUnload } from '@dcloudio/uni-app' | |||
| import dayjs from 'dayjs' | |||
| import { toolApi } from '@/api/index.js' | |||
| @@ -107,18 +119,29 @@ const page = ref(1) | |||
| const pageSize = 10 | |||
| const loading = ref(false) | |||
| const refreshing = ref(false) | |||
| const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0 }) | |||
| const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0, oldphoto: 0, portrait: 0 }) | |||
| // 类型标签栏的横向滚动位置 | |||
| const tabsScrollLeft = ref(0) | |||
| let timer = 0 | |||
| const tabs = computed(() => [ | |||
| { key: 'all', name: '全部' }, | |||
| { key: 'ainame', name: 'AI取名' }, | |||
| { key: 'anime', name: '动漫头像' }, | |||
| { key: 'photo', name: '证件照' } | |||
| { key: 'photo', name: '证件照' }, | |||
| { key: 'oldphoto', name: '老照片' }, | |||
| { key: 'portrait', name: '形象照' } | |||
| ]) | |||
| onShow(() => { | |||
| const preferredTab = uni.getStorageSync('works_active_tab') | |||
| if (tabs.value.some((item) => item.key === preferredTab)) { | |||
| activeTab.value = preferredTab | |||
| uni.removeStorageSync('works_active_tab') | |||
| } | |||
| refreshSilent() | |||
| // 从其它页面带着类型进来时,把该类型滚到可视区中间(等渲染完成再量位置) | |||
| nextTick(() => centerTab(activeTab.value)) | |||
| }) | |||
| onHide(() => clearTimer()) | |||
| @@ -170,7 +193,9 @@ function normalizeCounts(data = {}) { | |||
| ainame: Number(data.ainame || 0), | |||
| anime: Number(data.anime || 0), | |||
| image: Number(data.image || 0), | |||
| photo: Number(data.photo || 0) | |||
| photo: Number(data.photo || 0), | |||
| oldphoto: Number(data.oldphoto || 0), | |||
| portrait: Number(data.portrait || 0) | |||
| } | |||
| } | |||
| @@ -180,6 +205,14 @@ function refreshSilent(silent = false) { | |||
| function onRefresh() { | |||
| refreshing.value = true | |||
| if (!userStore.isLogin) { | |||
| // 先让原生 scroll-view 接收到 triggered=true,再在下一次渲染后关闭,避免刷新指示器滞留 | |||
| nextTick(() => { | |||
| refreshing.value = false | |||
| uni.stopPullDownRefresh() | |||
| }) | |||
| return | |||
| } | |||
| refreshSilent() | |||
| } | |||
| @@ -190,11 +223,35 @@ function loadMore() { | |||
| } | |||
| function changeTab(key) { | |||
| centerTab(key) | |||
| if (activeTab.value === key) return | |||
| activeTab.value = key | |||
| refreshSilent() | |||
| } | |||
| /** | |||
| * 把指定标签滚到可视区中间。 | |||
| * scroll-into-view 只能把元素对齐到左边缘,所以这里量一次位置自己算 scroll-left: | |||
| * 目标 = 标签中心 - 容器中心(含容器当前已滚动的距离),左右到头时由 scroll-view 自行收敛。 | |||
| */ | |||
| function centerTab(key) { | |||
| uni.createSelectorQuery() | |||
| .select('#tabs-scroll') | |||
| .boundingClientRect() | |||
| .select('#tabs-scroll') | |||
| .scrollOffset() | |||
| .select(`#tab-${key}`) | |||
| .boundingClientRect() | |||
| .exec((res) => { | |||
| const wrap = res && res[0] | |||
| const offset = res && res[1] | |||
| const tab = res && res[2] | |||
| if (!wrap || !offset || !tab || !wrap.width) return | |||
| const target = tab.left - wrap.left + offset.scrollLeft - (wrap.width - tab.width) / 2 | |||
| tabsScrollLeft.value = Math.max(0, Math.round(target)) | |||
| }) | |||
| } | |||
| function scheduleRefresh() { | |||
| clearTimer() | |||
| if (list.value.some((item) => Number(item.status) === 0)) { | |||
| @@ -208,6 +265,21 @@ function clearTimer() { | |||
| } | |||
| function openWork(item) { | |||
| if (item.work_type === 'photo') { | |||
| const workId = item.photo_open_id || item.id | |||
| if (workId) uni.navigateTo({ url: `/pages/tool/id-photo-detail?id=${workId}` }) | |||
| return | |||
| } | |||
| if (item.work_type === 'oldphoto') { | |||
| const workId = item.photo_open_id || item.id | |||
| if (workId) uni.navigateTo({ url: `/pages/tool/old-photo-detail?id=${workId}` }) | |||
| return | |||
| } | |||
| if (item.work_type === 'portrait') { | |||
| const workId = item.photo_open_id || item.id | |||
| if (workId) uni.navigateTo({ url: `/pages/tool/portrait-detail?id=${workId}` }) | |||
| return | |||
| } | |||
| if (!item.detail_url) return | |||
| uni.navigateTo({ url: item.detail_url }) | |||
| } | |||
| @@ -224,7 +296,9 @@ function typeText(type) { | |||
| ainame: 'AI取名', | |||
| anime: '动漫头像', | |||
| image: 'AI作品', | |||
| photo: '证件照' | |||
| photo: '证件照', | |||
| oldphoto: '老照片', | |||
| portrait: '形象照' | |||
| } | |||
| return map[type] || 'AI作品' | |||
| } | |||
| @@ -257,6 +331,8 @@ function handleEmptyAction() { | |||
| .page-scroll { | |||
| flex: 1; | |||
| width: 100%; | |||
| min-width: 0; | |||
| height: 0; | |||
| } | |||
| @@ -267,16 +343,31 @@ function handleEmptyAction() { | |||
| box-sizing: border-box; | |||
| } | |||
| .tabs { | |||
| /* 吸顶放在滚动容器上,内部只负责横向排列 */ | |||
| .tabs-scroll { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 3; | |||
| width: calc(100% + 48rpx); | |||
| height: 88rpx; | |||
| margin: 0 -24rpx; | |||
| padding: 0 24rpx; | |||
| border-top: 1rpx solid #eef1f6; | |||
| background: rgba(255, 255, 255, 0.96); | |||
| display: flex; | |||
| white-space: nowrap; | |||
| box-sizing: border-box; | |||
| /* 隐藏横向滚动条,H5 下默认会占高度 */ | |||
| ::-webkit-scrollbar { | |||
| display: none; | |||
| width: 0; | |||
| height: 0; | |||
| } | |||
| } | |||
| .tabs { | |||
| height: 88rpx; | |||
| padding: 0 24rpx; | |||
| display: inline-flex; | |||
| align-items: center; | |||
| gap: 14rpx; | |||
| box-sizing: border-box; | |||
| @@ -5,7 +5,7 @@ | |||
| */ | |||
| // App / 小程序默认环境:'dev' | 'test' | 'pro' | |||
| const DEFAULT_ENV = 'dev' | |||
| const DEFAULT_ENV = 'pro' | |||
| const ENV_MAP = { | |||
| dev: { | |||
| @@ -42,7 +42,11 @@ base = ENV_MAP.pro | |||
| // H5 公众号网页授权 appId(沿用旧项目) | |||
| const WX_MP_APPID = 'wxd2aa05b2249b16ce' | |||
| // 应用版本号,用于「设置 - 当前版本」展示;发版时与 manifest.json 的 versionName 一起改 | |||
| const APP_VERSION = '3.0.0' | |||
| export default { | |||
| ...base, | |||
| WX_MP_APPID | |||
| WX_MP_APPID, | |||
| APP_VERSION | |||
| } | |||
| @@ -0,0 +1,86 @@ | |||
| const LOGIN_RETURN_KEY = 'login_redirect' | |||
| const DEFAULT_RETURN_URL = '/pages/toolbox/toolbox' | |||
| const LOGIN_ROUTES = ['pages/login/login', 'pages/login/phone'] | |||
| function routePath(url = '') { | |||
| return String(url).replace(/^\/+/, '').split(/[?#]/)[0] | |||
| } | |||
| function isLoginRoute(url = '') { | |||
| return LOGIN_ROUTES.includes(routePath(url)) | |||
| } | |||
| function normalizeReturnUrl(value = '') { | |||
| if (!value) return '' | |||
| let url = String(value) | |||
| try { | |||
| if (/%[0-9a-f]{2}/i.test(url)) url = decodeURIComponent(url) | |||
| } catch (e) {} | |||
| if (!url.startsWith('/')) url = `/${url}` | |||
| if (!url.startsWith('/pages/') || isLoginRoute(url)) return '' | |||
| return url | |||
| } | |||
| function getPageUrl(page) { | |||
| if (!page) return '' | |||
| const fullPath = page.$page && page.$page.fullPath | |||
| const normalizedFullPath = normalizeReturnUrl(fullPath) | |||
| if (normalizedFullPath) return normalizedFullPath | |||
| const route = routePath(page.route || '') | |||
| if (!route || isLoginRoute(route)) return '' | |||
| const options = page.options || (page.$page && page.$page.options) || {} | |||
| const query = Object.keys(options) | |||
| .filter((key) => options[key] !== undefined && options[key] !== null && options[key] !== '') | |||
| .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(String(options[key]))}`) | |||
| .join('&') | |||
| return normalizeReturnUrl(`/${route}${query ? `?${query}` : ''}`) | |||
| } | |||
| /** | |||
| * 记录发起登录的页面。普通端返回时优先使用原页面栈;该记录主要用于 H5 微信授权后页面栈丢失的场景。 | |||
| */ | |||
| export function rememberLoginSource(explicitUrl = '') { | |||
| const directUrl = normalizeReturnUrl(explicitUrl) | |||
| if (directUrl) { | |||
| uni.setStorageSync(LOGIN_RETURN_KEY, directUrl) | |||
| return directUrl | |||
| } | |||
| const pages = getCurrentPages() | |||
| for (let index = pages.length - 1; index >= 0; index -= 1) { | |||
| const url = getPageUrl(pages[index]) | |||
| if (!url) continue | |||
| uni.setStorageSync(LOGIN_RETURN_KEY, url) | |||
| return url | |||
| } | |||
| return normalizeReturnUrl(uni.getStorageSync(LOGIN_RETURN_KEY)) | |||
| } | |||
| /** | |||
| * 登录成功后回到发起页: | |||
| * - 页面栈仍存在时 navigateBack,保留表单/滚动位置,并触发来源页 onShow; | |||
| * - H5 微信授权导致页面栈丢失时 reLaunch 到已记录路径; | |||
| * - 没有来源页时才回到工具箱首页。 | |||
| */ | |||
| export function returnAfterLogin() { | |||
| const pages = getCurrentPages() | |||
| const firstLoginIndex = pages.findIndex((page) => isLoginRoute(page.route || '')) | |||
| if (firstLoginIndex > 0) { | |||
| uni.removeStorageSync(LOGIN_RETURN_KEY) | |||
| uni.navigateBack({ delta: pages.length - firstLoginIndex }) | |||
| return | |||
| } | |||
| const returnUrl = normalizeReturnUrl(uni.getStorageSync(LOGIN_RETURN_KEY)) | |||
| uni.removeStorageSync(LOGIN_RETURN_KEY) | |||
| if (returnUrl) { | |||
| uni.reLaunch({ url: returnUrl }) | |||
| return | |||
| } | |||
| uni.switchTab({ url: DEFAULT_RETURN_URL }) | |||
| } | |||
| @@ -0,0 +1,95 @@ | |||
| /** | |||
| * 后台富文本清洗(协议、公告等静态内容) | |||
| * | |||
| * 背景:html 表里的正文是 Word / 旧编辑器导出的,存在两类问题会破坏移动端排版: | |||
| * 1. 大量 `<p><span> </span></p>` 空段落,叠加段间距后出现大片空白; | |||
| * 2. 几乎每个 span 都带内联 `font-family:宋体;font-size:14px;color:#000`, | |||
| * 内联样式优先级最高,会盖掉端上给的 tagStyle,导致字号偏小、暗色模式不可读。 | |||
| * | |||
| * 处理方式:删掉空段落,剥掉会影响排版与主题的内联属性,保留加粗、下划线等语义样式。 | |||
| */ | |||
| // 会破坏排版 / 主题的内联属性,统一剥掉,交给端上的 tagStyle 决定 | |||
| const DROP_STYLE_PROPS = [ | |||
| 'font-family', | |||
| 'font-size', | |||
| 'color', | |||
| 'background', | |||
| 'background-color', | |||
| 'line-height', | |||
| 'margin', | |||
| 'margin-top', | |||
| 'margin-bottom', | |||
| 'margin-left', | |||
| 'margin-right', | |||
| 'padding', | |||
| 'padding-top', | |||
| 'padding-bottom', | |||
| 'width', | |||
| 'height' | |||
| ] | |||
| /** 判断一段 html 是否只有空白内容(空格、 、<br>、空标签) */ | |||
| function isBlankFragment(html) { | |||
| const text = String(html || '') | |||
| .replace(/<br\s*\/?>/gi, '') | |||
| .replace(/<[^>]+>/g, '') | |||
| .replace(/ | |\u00a0/gi, '') | |||
| .replace(/\s/g, '') | |||
| return text.length === 0 | |||
| } | |||
| /** 过滤单个 style 属性里的危险声明 */ | |||
| function cleanStyleValue(style) { | |||
| return String(style || '') | |||
| .split(';') | |||
| .map((item) => item.trim()) | |||
| .filter(Boolean) | |||
| .filter((item) => { | |||
| const prop = item.split(':')[0].trim().toLowerCase() | |||
| return prop && !DROP_STYLE_PROPS.includes(prop) | |||
| }) | |||
| .join('; ') | |||
| } | |||
| /** | |||
| * @param {string} html 后台返回的富文本 | |||
| * @return {string} 清洗后的富文本 | |||
| */ | |||
| export function cleanRichText(html) { | |||
| if (!html) return '' | |||
| let text = String(html) | |||
| // 1. Word 导出常见的无用标签 | |||
| text = text | |||
| .replace(/<\/?o:p[^>]*>/gi, '') | |||
| .replace(/<!--[\s\S]*?-->/g, '') | |||
| .replace(/<meta[^>]*>/gi, '') | |||
| .replace(/<style[\s\S]*?<\/style>/gi, '') | |||
| // 2. 剥掉内联的字体/颜色/间距声明,style 清空后连属性一起去掉 | |||
| text = text.replace(/\sstyle\s*=\s*"([^"]*)"/gi, (match, style) => { | |||
| const kept = cleanStyleValue(style) | |||
| return kept ? ` style="${kept}"` : '' | |||
| }) | |||
| text = text.replace(/\sstyle\s*=\s*'([^']*)'/gi, (match, style) => { | |||
| const kept = cleanStyleValue(style) | |||
| return kept ? ` style="${kept}"` : '' | |||
| }) | |||
| // 3. Word 会把字体信息塞进 font 标签和 class,一并清掉 | |||
| text = text.replace(/<\/?font[^>]*>/gi, '') | |||
| text = text.replace(/\sclass\s*=\s*"(?:Mso|xl)[^"]*"/gi, '') | |||
| // 4. 删掉只剩空白的段落 / 标题(连续空段落是大片空白的主因) | |||
| text = text.replace(/<(p|div|h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi, (match, tag, inner) => { | |||
| return isBlankFragment(inner) ? '' : match | |||
| }) | |||
| // 5. 连续 <br> 压缩为一个,避免用 br 堆出的空行 | |||
| text = text.replace(/(?:<br\s*\/?>\s*){2,}/gi, '<br>') | |||
| return text.trim() | |||
| } | |||
| export default cleanRichText | |||
| @@ -56,3 +56,94 @@ export function uploadImage(filePath, fileInfo = {}) { | |||
| }) | |||
| }) | |||
| } | |||
| /** | |||
| * 把原图作为 multipart 文件直接提交给某个业务接口(不先传 CDN 再由后端回源下载一次)。 | |||
| * 公共参数与鉴权头和 utils/request.js 保持一致。 | |||
| * @param {string} url 接口路径,取自 api/config.js | |||
| * @param {string} filePath 本地临时文件路径 | |||
| * @param {object} params 业务参数 | |||
| * @param {string} failMsg 解析失败时的兜底文案 | |||
| */ | |||
| function uploadWithForm(url, filePath, params = {}, failMsg = '上传结果解析失败') { | |||
| const token = uni.getStorageSync('token') | |||
| const uid = uni.getStorageSync('open_id') | |||
| let mac = uni.getStorageSync('mac') | |||
| if (!mac) { | |||
| mac = uni.getSystemInfoSync().deviceId || guid() | |||
| uni.setStorageSync('mac', mac) | |||
| } | |||
| const { timeStr, nonce } = getNonce() | |||
| const { version, version_code } = getVersion() | |||
| const formData = { | |||
| token, | |||
| uid, | |||
| mac, | |||
| base_timestamp: parseInt(Date.now() / 1000), | |||
| client: getClient(), | |||
| source: uni.getStorageSync('source') || '', | |||
| client_ios: uni.getSystemInfoSync().platform === 'ios' ? 1 : 0, | |||
| version, | |||
| version_code, | |||
| ...params | |||
| } | |||
| Object.keys(formData).forEach((key) => { | |||
| if (formData[key] === '' || formData[key] === undefined || formData[key] === null) { | |||
| delete formData[key] | |||
| } else if (typeof formData[key] !== 'string') { | |||
| formData[key] = String(formData[key]) | |||
| } | |||
| }) | |||
| return new Promise((resolve, reject) => { | |||
| uni.uploadFile({ | |||
| url: `${BASE_URL}${url}`, | |||
| filePath, | |||
| name: 'file', | |||
| header: { | |||
| token, | |||
| timestr: timeStr, | |||
| nonce | |||
| }, | |||
| formData, | |||
| success: (res) => { | |||
| let data = {} | |||
| try { | |||
| data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data | |||
| } catch (e) { | |||
| reject({ code: 1, msg: failMsg }) | |||
| return | |||
| } | |||
| if (data.code === 0) { | |||
| resolve(data) | |||
| return | |||
| } | |||
| reject(data) | |||
| }, | |||
| fail: (e) => reject({ code: 1, msg: e.errMsg || '照片上传失败,请重试' }) | |||
| }) | |||
| }) | |||
| } | |||
| /** | |||
| * 证件照制作 | |||
| */ | |||
| export function createIdPhoto(filePath, params = {}) { | |||
| return uploadWithForm(API.INTERFACE_IDPHOTO_POST_CREATE, filePath, params, '制作结果解析失败') | |||
| } | |||
| /** | |||
| * 老照片修复/上色:提交后立即返回任务,结果由 /oldphoto/getdetail 轮询 | |||
| */ | |||
| export function createOldPhoto(filePath, params = {}) { | |||
| return uploadWithForm(API.INTERFACE_OLDPHOTO_POST_CREATE, filePath, params, '提交结果解析失败') | |||
| } | |||
| /** | |||
| * 形象照/职业照/写真:提交后立即返回任务(含 1~4 个子任务),结果由 /portrait/getdetail 轮询 | |||
| */ | |||
| export function createPortrait(filePath, params = {}) { | |||
| return uploadWithForm(API.INTERFACE_PORTRAIT_POST_CREATE, filePath, params, '提交结果解析失败') | |||
| } | |||