소스 검색

feat: add point and AI tool flows

main
leiyun 1 개월 전
부모
커밋
a514904169
34개의 변경된 파일8032개의 추가작업 그리고 1272개의 파일을 삭제
  1. BIN
     
  2. BIN
     
  3. +24
    -0
      api/config.js
  4. +40
    -1
      api/index.js
  5. +66
    -0
      pages.json
  6. +104
    -19
      pages/agreement/agreement.vue
  7. +47
    -0
      pages/bill/bill.vue
  8. +14
    -21
      pages/login/login.vue
  9. +7
    -11
      pages/login/phone.vue
  10. +166
    -0
      pages/my/contact.vue
  11. +321
    -0
      pages/my/feedback.vue
  12. +127
    -0
      pages/my/setting.vue
  13. +140
    -0
      pages/my/wechat.vue
  14. +523
    -0
      pages/point/earn.vue
  15. +233
    -78
      pages/profile/profile.vue
  16. +25
    -0
      pages/recharge/recharge.vue
  17. +451
    -0
      pages/tool/id-photo-detail.vue
  18. +554
    -0
      pages/tool/id-photo-history.vue
  19. +1466
    -530
      pages/tool/id-photo.vue
  20. +434
    -0
      pages/tool/old-photo-detail.vue
  21. +393
    -0
      pages/tool/old-photo-history.vue
  22. +687
    -218
      pages/tool/old-photo.vue
  23. +503
    -0
      pages/tool/portrait-detail.vue
  24. +408
    -0
      pages/tool/portrait-history.vue
  25. +895
    -369
      pages/tool/portrait-photo.vue
  26. +2
    -1
      pages/tool/work-detail.vue
  27. +13
    -2
      pages/toolbox/toolbox.vue
  28. +111
    -20
      pages/works/works.vue
  29. BIN
     
  30. BIN
     
  31. +6
    -2
      utils/config.js
  32. +86
    -0
      utils/login.js
  33. +95
    -0
      utils/richtext.js
  34. +91
    -0
      utils/upload.js


+ 24
- 0
api/config.js 파일 보기

@@ -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', // 创建/编辑机器人


+ 40
- 1
api/index.js 파일 보기

@@ -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
}

+ 66
- 0
pages.json 파일 보기

@@ -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": {


+ 104
- 19
pages/agreement/agreement.vue 파일 보기

@@ -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>

+ 47
- 0
pages/bill/bill.vue 파일 보기

@@ -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;


+ 14
- 21
pages/login/login.vue 파일 보기

@@ -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;
}
}



+ 7
- 11
pages/login/phone.vue 파일 보기

@@ -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 {


+ 166
- 0
pages/my/contact.vue 파일 보기

@@ -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>

+ 321
- 0
pages/my/feedback.vue 파일 보기

@@ -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>

+ 127
- 0
pages/my/setting.vue 파일 보기

@@ -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>

+ 140
- 0
pages/my/wechat.vue 파일 보기

@@ -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>

+ 523
- 0
pages/point/earn.vue 파일 보기

@@ -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>

+ 233
- 78
pages/profile/profile.vue 파일 보기

@@ -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>

+ 25
- 0
pages/recharge/recharge.vue 파일 보기

@@ -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;
}
}

/* 确认充值 */


+ 451
- 0
pages/tool/id-photo-detail.vue 파일 보기

@@ -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>

+ 554
- 0
pages/tool/id-photo-history.vue 파일 보기

@@ -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>

+ 1466
- 530
pages/tool/id-photo.vue
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 434
- 0
pages/tool/old-photo-detail.vue 파일 보기

@@ -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>

+ 393
- 0
pages/tool/old-photo-history.vue 파일 보기

@@ -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>

+ 687
- 218
pages/tool/old-photo.vue
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 503
- 0
pages/tool/portrait-detail.vue 파일 보기

@@ -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>

+ 408
- 0
pages/tool/portrait-history.vue 파일 보기

@@ -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>

+ 895
- 369
pages/tool/portrait-photo.vue
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 2
- 1
pages/tool/work-detail.vue 파일 보기

@@ -160,8 +160,9 @@ function getPageStackDelta(routeName) {
height: 0;
}

/* 底栏是 fixed 的,留够 300rpx 才不会盖住内容 */
.wrap {
padding: 28rpx 28rpx 150rpx;
padding: 28rpx 28rpx 300rpx;
}

.state-card,


+ 13
- 2
pages/toolbox/toolbox.vue 파일 보기

@@ -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 {


+ 111
- 20
pages/works/works.vue 파일 보기

@@ -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;




+ 6
- 2
utils/config.js 파일 보기

@@ -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
}

+ 86
- 0
utils/login.js 파일 보기

@@ -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 })
}


+ 95
- 0
utils/richtext.js 파일 보기

@@ -0,0 +1,95 @@
/**
* 后台富文本清洗(协议、公告等静态内容)
*
* 背景:html 表里的正文是 Word / 旧编辑器导出的,存在两类问题会破坏移动端排版:
* 1. 大量 `<p><span>&nbsp;</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 是否只有空白内容(空格、&nbsp;、<br>、空标签) */
function isBlankFragment(html) {
const text = String(html || '')
.replace(/<br\s*\/?>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;|&#160;|\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

+ 91
- 0
utils/upload.js 파일 보기

@@ -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, '提交结果解析失败')
}

불러오는 중...
취소
저장