Compare commits
11 Commits
3560457e0b
...
2b6eab4121
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b6eab4121 | |||
| 2d77142995 | |||
| f82dab2fc8 | |||
| 1321aa4391 | |||
| 77cb04964a | |||
| 190db7446d | |||
| b6b1cc37ce | |||
| 48793491c3 | |||
| 2135ca78e2 | |||
| 315a8365b6 | |||
| 0831faa3a3 |
@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run dev",
|
"start": "npm run dev",
|
||||||
"dev": "./node_modules/.bin/wepy build --watch",
|
"dev": "./node_modules/.bin/wepy build --watch",
|
||||||
"build": "cross-env NODE_ENV=production ./node_modules/.bin/wepy build --no-cache ",
|
"build": "node tools/build/build.cjs",
|
||||||
"clean": "rm -rf weapp",
|
"clean": "rm -rf weapp",
|
||||||
"lint": "eslint --ext .js,.wpy src/",
|
"lint": "eslint --ext .js,.wpy src/",
|
||||||
"lint:fix": "eslint --ext .js,.wpy src/ --fix",
|
"lint:fix": "eslint --ext .js,.wpy src/ --fix",
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"enhance": true
|
"enhance": true
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"appid": "wxe486777f4379507e",
|
"appid": "wxc41491431733671e",
|
||||||
"projectname": "myproj",
|
"projectname": "myproj",
|
||||||
"miniprogramRoot": "weapp/",
|
"miniprogramRoot": "weapp/",
|
||||||
"srcMiniprogramRoot": "weapp/",
|
"srcMiniprogramRoot": "weapp/",
|
||||||
|
|||||||
17
src/app.wpy
17
src/app.wpy
@ -20,6 +20,7 @@ import promisify from '@wepy/use-promisify'
|
|||||||
import {wx_login} from './utils/util'
|
import {wx_login} from './utils/util'
|
||||||
import {IM} from './utils/im'
|
import {IM} from './utils/im'
|
||||||
import {initEid} from './mp_ecard_sdk/main'
|
import {initEid} from './mp_ecard_sdk/main'
|
||||||
|
import {invitationFromOptions} from './utils/invitation'
|
||||||
// import {service} from './config'
|
// import {service} from './config'
|
||||||
|
|
||||||
wepy.use(promisify)
|
wepy.use(promisify)
|
||||||
@ -50,10 +51,11 @@ wepy.app({
|
|||||||
|
|
||||||
onLaunch(e) {
|
onLaunch(e) {
|
||||||
let vm = this
|
let vm = this
|
||||||
|
const invitationID = invitationFromOptions(e)
|
||||||
|
if (invitationID) wx.setStorageSync('from_user_id', invitationID)
|
||||||
console.log(e, 'uuu=')
|
console.log(e, 'uuu=')
|
||||||
vm.appLogin()
|
vm.appLogin()
|
||||||
console.log(e, 'e=')
|
console.log(e, 'e=')
|
||||||
console.log(decodeURIComponent(e.query.scene), 'e=')
|
|
||||||
// if (e.path === 'pages/home/informationV2') {
|
// if (e.path === 'pages/home/informationV2') {
|
||||||
// wx.redirectTo({ url: `/pages/home/information?${decodeURIComponent(e.query.scene)}` })
|
// wx.redirectTo({ url: `/pages/home/information?${decodeURIComponent(e.query.scene)}` })
|
||||||
// }
|
// }
|
||||||
@ -95,15 +97,19 @@ wepy.app({
|
|||||||
if (options.path === 'pages/tabBar/home1') {
|
if (options.path === 'pages/tabBar/home1') {
|
||||||
wx.switchTab({url: `/pages/tabBar/home?${decodeURIComponent(options.query.scene)}`})
|
wx.switchTab({url: `/pages/tabBar/home?${decodeURIComponent(options.query.scene)}`})
|
||||||
}
|
}
|
||||||
if (options.query.scene) {
|
if (options.query && options.query.scene) {
|
||||||
let sceneList = decodeURIComponent(options.query.scene).split(/[= &]/)
|
let sceneList = []
|
||||||
|
try { sceneList = decodeURIComponent(options.query.scene).split(/[= &]/) } catch (_) {}
|
||||||
sceneList.forEach((item, index) => {
|
sceneList.forEach((item, index) => {
|
||||||
if (item == 'userId') {
|
if (item == 'userId') {
|
||||||
wx.setStorageSync('agent_user_id', sceneList[index + 1])
|
wx.setStorageSync('agent_user_id', sceneList[index + 1])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (options.query && options.query.from_user_id) {
|
const invitationScene = invitationFromOptions(options)
|
||||||
|
if (invitationScene) {
|
||||||
|
wx.setStorageSync('from_user_id', invitationScene)
|
||||||
|
} else if (options.query && options.query.from_user_id) {
|
||||||
wx.setStorageSync('from_user_id', options.query.from_user_id)
|
wx.setStorageSync('from_user_id', options.query.from_user_id)
|
||||||
} else {
|
} else {
|
||||||
if (options.path && options.path === 'pages/sub_mall/confirmOrder') {
|
if (options.path && options.path === 'pages/sub_mall/confirmOrder') {
|
||||||
@ -235,6 +241,9 @@ subPackages: [
|
|||||||
'orderDetails',
|
'orderDetails',
|
||||||
'exclusiveService',
|
'exclusiveService',
|
||||||
'myDynamic',
|
'myDynamic',
|
||||||
|
'more',
|
||||||
|
'myShare',
|
||||||
|
'shareCard',
|
||||||
'logoutDefault',
|
'logoutDefault',
|
||||||
'ImageCropper',
|
'ImageCropper',
|
||||||
'myDynamicDetail',
|
'myDynamicDetail',
|
||||||
|
|||||||
@ -97,7 +97,6 @@ export default {
|
|||||||
wx.removeStorageSync('message')
|
wx.removeStorageSync('message')
|
||||||
wx.stopPullDownRefresh()
|
wx.stopPullDownRefresh()
|
||||||
// 控制台调试日志
|
// 控制台调试日志
|
||||||
console.log('[SUCCESS]', statusCode, typeof data === 'object' ? data : data.toString().substring(0, 100))
|
|
||||||
// 状态码正常 & 确认有数据
|
// 状态码正常 & 确认有数据
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
// 成功回调
|
// 成功回调
|
||||||
@ -122,14 +121,13 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
let errorMessage = {message: '错误,请寻找开发人员协助'}
|
let errorMessage = {message: '错误,请寻找开发人员协助'}
|
||||||
throw errorMessage
|
wx.hideLoading()
|
||||||
|
return
|
||||||
wx.hideLoading()
|
wx.hideLoading()
|
||||||
} else if (data.code === 2) {
|
} else if (data.code === 2) {
|
||||||
console.error(data.message)
|
|
||||||
vm.errorTwo += 1
|
vm.errorTwo += 1
|
||||||
if (vm.errorTwo <= 2) {
|
if (vm.errorTwo <= 2) {
|
||||||
utils.wx_login().then((data) => {
|
utils.wx_login().then((data) => {
|
||||||
console.log(data, '444')
|
|
||||||
let route = `/${currentPage.route}?${url}`
|
let route = `/${currentPage.route}?${url}`
|
||||||
if (!data.data.token) {
|
if (!data.data.token) {
|
||||||
wx.removeStorageSync('information')
|
wx.removeStorageSync('information')
|
||||||
@ -142,7 +140,6 @@ export default {
|
|||||||
route = route.split('?')[0]
|
route = route.split('?')[0]
|
||||||
wx.switchTab({url: route})
|
wx.switchTab({url: route})
|
||||||
} else {
|
} else {
|
||||||
console.log('222')
|
|
||||||
wx.redirectTo({url: route})
|
wx.redirectTo({url: route})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,7 +152,6 @@ export default {
|
|||||||
throw errorMessage
|
throw errorMessage
|
||||||
} else if (code === 3) {
|
} else if (code === 3) {
|
||||||
utils.wx_login().then((data) => {
|
utils.wx_login().then((data) => {
|
||||||
console.log(data, '444')
|
|
||||||
let route = `/${currentPage.route}?${url}`
|
let route = `/${currentPage.route}?${url}`
|
||||||
if (!data.data.token) {
|
if (!data.data.token) {
|
||||||
wx.removeStorageSync('information')
|
wx.removeStorageSync('information')
|
||||||
@ -168,7 +164,6 @@ export default {
|
|||||||
route = route.split('?')[0]
|
route = route.split('?')[0]
|
||||||
wx.switchTab({url: route})
|
wx.switchTab({url: route})
|
||||||
} else {
|
} else {
|
||||||
console.log('222')
|
|
||||||
wx.redirectTo({url: route})
|
wx.redirectTo({url: route})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,7 +186,6 @@ export default {
|
|||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
// eslint-disable-next-line prefer-promise-reject-errors
|
// eslint-disable-next-line prefer-promise-reject-errors
|
||||||
reject(err)
|
reject(err)
|
||||||
console.error('[ERROR]', err)
|
|
||||||
},
|
},
|
||||||
complete: (res) => {
|
complete: (res) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -221,7 +215,6 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
console.log('当前网络不稳定!请重试...')
|
|
||||||
let errorMessage = {message: '当前网络不稳定!请重试'}
|
let errorMessage = {message: '当前网络不稳定!请重试'}
|
||||||
throw errorMessage
|
throw errorMessage
|
||||||
}
|
}
|
||||||
@ -231,8 +224,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
console.log(this.i18nL)
|
|
||||||
console.log('created in mixin', '插件打印')
|
|
||||||
let vm = this
|
let vm = this
|
||||||
let cacheBodyData = wx.getStorageSync('cacheBodyData')
|
let cacheBodyData = wx.getStorageSync('cacheBodyData')
|
||||||
if (cacheBodyData && vm.$app.$options.globalData.resubmit == 1) {
|
if (cacheBodyData && vm.$app.$options.globalData.resubmit == 1) {
|
||||||
@ -240,7 +231,6 @@ export default {
|
|||||||
data: cacheBodyData
|
data: cacheBodyData
|
||||||
}
|
}
|
||||||
vm.$app.$options.globalData.resubmit = 2
|
vm.$app.$options.globalData.resubmit = 2
|
||||||
console.log(data, '提交数组数据***********')
|
|
||||||
vm.$post({url: `${service.host}/save/weak/network/fat`, data}).then(({code, data}) => {
|
vm.$post({url: `${service.host}/save/weak/network/fat`, data}).then(({code, data}) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
vm.app.globalData.bodyFatData = null
|
vm.app.globalData.bodyFatData = null
|
||||||
@ -249,7 +239,6 @@ export default {
|
|||||||
}
|
}
|
||||||
wx.hideLoading()
|
wx.hideLoading()
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.log(err)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/mixins/sharing-page.js
Normal file
86
src/mixins/sharing-page.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import {service} from '../config'
|
||||||
|
import {boundedCall, sharingRequest, currentSharingUser, displayProfile} from '../utils/sharing-runtime'
|
||||||
|
import {readSharingCache, writeSharingCache} from '../utils/sharing-cache'
|
||||||
|
import {selectSharingPhoto} from '../utils/sharing-photo'
|
||||||
|
import {renderSharingCover, renderSharingPoster, storeSharingCode, saveSharingPoster} from '../utils/sharing'
|
||||||
|
|
||||||
|
export default function sharingPage(kind) {
|
||||||
|
return {
|
||||||
|
data:{profile:{id:0,nickname:'',photo:'',details:'',introduction:'',idealMate:''},profileLoading:true,profileReady:false,profileError:'',canvasReady:false,poster:'',cover:'',generating:false,posterError:'',posterStage:'正在准备海报',posterProgress:0,cacheNote:'',cached:false,saving:false},
|
||||||
|
onReady(){this.canvasReady=true;this.ensurePoster()},
|
||||||
|
onUnload(){this._sharingDisposed=true;if(this._sharingJob)this._sharingJob.cancelled=true},
|
||||||
|
methods:{
|
||||||
|
setPosterProgress(percent,stage){if(this._sharingDisposed)return;this.posterProgress=percent;this.posterStage=stage},
|
||||||
|
async startSharing(){
|
||||||
|
this._sharingDisposed=false
|
||||||
|
const id=currentSharingUser()
|
||||||
|
if(id){
|
||||||
|
const cached=await readSharingCache(id,kind)
|
||||||
|
if(this._sharingDisposed)return
|
||||||
|
if(cached){this.profile={...this.profile,id};this.applySharingCache(cached)}
|
||||||
|
}
|
||||||
|
if(!this._sharingDisposed)await this.loadSharingProfile()
|
||||||
|
},
|
||||||
|
applySharingCache(cache){this.poster=cache.poster;this.cover=cache.cover;this.cached=true;this.posterError='';this.setPosterProgress(100,'海报已准备好');this.cacheNote='已缓存,下次打开可直接使用'},
|
||||||
|
async loadSharingProfile(force=false){
|
||||||
|
if(this._sharingDisposed)return
|
||||||
|
this.profileLoading=true;this.profileError=''
|
||||||
|
this.posterError='';this.setPosterProgress(5,'正在读取个人资料')
|
||||||
|
try{
|
||||||
|
const data=await sharingRequest(`${service.user}/sharing/profile`)
|
||||||
|
if(this._sharingDisposed)return
|
||||||
|
const profile=displayProfile(data)
|
||||||
|
this.setPosterProgress(15,kind==='personal'?'正在选择适合的生活照':'正在准备专属邀请')
|
||||||
|
if(kind==='personal'){
|
||||||
|
const previousCache=await readSharingCache(profile.id,kind)
|
||||||
|
profile.photo=await selectSharingPhoto(profile.photos,{random:force,previous:this.profile.photo||previousCache?.selectedPhoto,preferred:previousCache?.selectedPhoto})
|
||||||
|
}
|
||||||
|
const cache=force?null:await readSharingCache(profile.id,kind,profile)
|
||||||
|
if(this._sharingDisposed)return
|
||||||
|
this.profile=profile;this.profileReady=true
|
||||||
|
if(cache)this.applySharingCache(cache)
|
||||||
|
else{this.poster='';this.cover='';this.cached=false;this.cacheNote=''}
|
||||||
|
this.profileLoading=false
|
||||||
|
if(!cache)this.setPosterProgress(25,'正在准备分享封面')
|
||||||
|
await this.ensurePoster(force)
|
||||||
|
}catch(error){if(!this._sharingDisposed)this.profileError=error.message||'资料加载失败,请重试'}
|
||||||
|
finally{if(!this._sharingDisposed)this.profileLoading=false}
|
||||||
|
},
|
||||||
|
async ensurePoster(force=false){
|
||||||
|
if(this._sharingDisposed||this.generating||!this.canvasReady||!this.profileReady||(!force&&this.poster))return
|
||||||
|
this.generating=true;this.posterError='';this.cacheNote='';this.setPosterProgress(25,'正在绘制分享封面')
|
||||||
|
const job={cancelled:false};this._sharingJob=job
|
||||||
|
const active=()=>{if(job.cancelled||this._sharingDisposed)throw new Error('已停止生成,请重试')}
|
||||||
|
const profile={...this.profile}
|
||||||
|
const generate=async()=>{
|
||||||
|
const cover=await renderSharingCover(this,profile,kind==='personal',job)
|
||||||
|
active();this.cover=cover;this.setPosterProgress(45,'正在获取专属小程序码')
|
||||||
|
const data=await sharingRequest(`${service.user}/sharing/code`,'POST',{kind})
|
||||||
|
active();this.setPosterProgress(65,'正在准备小程序码');profile.qrcode=await storeSharingCode(data,kind,profile.id)
|
||||||
|
active();this.setPosterProgress(75,'正在绘制高清海报');const poster=await renderSharingPoster(this,profile,kind==='personal',job)
|
||||||
|
active();const assets={poster,cover,qrcode:profile.qrcode}
|
||||||
|
this.setPosterProgress(90,'正在保存到本地')
|
||||||
|
try{const cache=await writeSharingCache(profile,kind,assets);active();return {...cache,cached:true}}
|
||||||
|
catch(error){active();return {...assets,cached:false}}
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const result=await boundedCall((resolve,reject)=>{generate().then(resolve,reject);return()=>{job.cancelled=true}},'生成超时,请检查网络后重试',30000)
|
||||||
|
active();this.poster=result.poster;this.cover=result.cover;this.cached=result.cached
|
||||||
|
this.setPosterProgress(100,'海报已准备好')
|
||||||
|
this.cacheNote=result.cached?'已缓存,下次打开可直接使用':'海报已生成,暂未能缓存到本地'
|
||||||
|
}catch(error){if(!this._sharingDisposed)this.posterError=error.message||'海报生成失败,请重试'}
|
||||||
|
finally{job.cancelled=true;if(!this._sharingDisposed)this.generating=false}
|
||||||
|
},
|
||||||
|
async refreshPoster(){if(this.generating||this.profileLoading)return;await this.loadSharingProfile(true)},
|
||||||
|
preview(){if(this.poster)wx.previewImage({urls:[this.poster],current:this.poster})},
|
||||||
|
posterImageFailed(){this.poster='';this.cached=false;this.posterError='图片已失效,请重新生成'},
|
||||||
|
async savePoster(){
|
||||||
|
if(!this.poster||this.saving)return
|
||||||
|
this.saving=true
|
||||||
|
try{await saveSharingPoster(this.poster);wx.showToast({title:'已保存到相册'})}
|
||||||
|
catch(_){wx.showModal({title:'未能保存',content:'请检查相册权限后重试,也可以点击海报放大查看。',showCancel:false})}
|
||||||
|
finally{if(!this._sharingDisposed)this.saving=false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -378,6 +378,13 @@ page {
|
|||||||
width: 184rpx;
|
width: 184rpx;
|
||||||
height: 68rpx;
|
height: 68rpx;
|
||||||
}
|
}
|
||||||
|
.ui-add-friend-btn{
|
||||||
|
background: #D92554;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 34rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.ui-operation-btn-v2{
|
.ui-operation-btn-v2{
|
||||||
width: 52rpx;
|
width: 52rpx;
|
||||||
height: 52rpx;
|
height: 52rpx;
|
||||||
@ -704,7 +711,7 @@ page {
|
|||||||
<image class="ui-operation-btn-v2" src="https://image.fulllinkai.com/202409/25/104374082c9e2bd160ee963b4f29c98f.png" mode="widthFix" @tap="modalName = 'praise'"></image>
|
<image class="ui-operation-btn-v2" src="https://image.fulllinkai.com/202409/25/104374082c9e2bd160ee963b4f29c98f.png" mode="widthFix" @tap="modalName = 'praise'"></image>
|
||||||
<image v-if="userInfo.base_profile.is_followed" class="ui-operation-btn-v2" src="https://image.fulllinkai.com/202407/30/48c1254fc413725432cc8d1c22be7440.png" mode="widthFix" @tap="attention"></image>
|
<image v-if="userInfo.base_profile.is_followed" class="ui-operation-btn-v2" src="https://image.fulllinkai.com/202407/30/48c1254fc413725432cc8d1c22be7440.png" mode="widthFix" @tap="attention"></image>
|
||||||
<image v-else class="ui-operation-btn-v2" src="https://image.fulllinkai.com/202407/30/c7aa12666362515181a8f237a3ea48ff.png" mode="widthFix" @tap="attention"></image>
|
<image v-else class="ui-operation-btn-v2" src="https://image.fulllinkai.com/202407/30/c7aa12666362515181a8f237a3ea48ff.png" mode="widthFix" @tap="attention"></image>
|
||||||
<image v-if="userInfo.other_info.is_friend == 0" class="ui-operation-btn" src="https://image.fulllinkai.com/202407/30/58d9ec020a61a2d5370f2adb77489c18.png" mode="widthFix" @tap="contactFriendFn"></image>
|
<view v-if="userInfo.other_info.is_friend == 0" class="ui-operation-btn ui-add-friend-btn f-fcc" @tap="contactFriendFn">加好友</view>
|
||||||
<image v-else class="ui-operation-btn" src="https://image.fulllinkai.com/202407/30/0bfb0220f4b120dc0b0e9b4fb7a70a7b.png" mode="widthFix" @tap="jumpPath('chitchat')"></image>
|
<image v-else class="ui-operation-btn" src="https://image.fulllinkai.com/202407/30/0bfb0220f4b120dc0b0e9b4fb7a70a7b.png" mode="widthFix" @tap="jumpPath('chitchat')"></image>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -751,7 +758,7 @@ page {
|
|||||||
<image class="u_apply_img" mode="widthFix" src="https://images.ufutx.com/202105/31/37e0eda3fe1fcc0af2cb3ca2a51d2291.png"></image>
|
<image class="u_apply_img" mode="widthFix" src="https://images.ufutx.com/202105/31/37e0eda3fe1fcc0af2cb3ca2a51d2291.png"></image>
|
||||||
<view class="font_34 color333 bold">打个招呼吧</view>
|
<view class="font_34 color333 bold">打个招呼吧</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="font_28 color666 ui-pt-16" v-if="!userData.rank_id">非会员每天只有3次打招呼</view>
|
<view class="font_28 color666 ui-pt-16" v-if="!friendEligibility.unlimited">真人认证、真人头像审核通过、完善资料、学历认证,每完成一项每天可免费加好友1次,最多4次</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="text-left ff ui-pt-30 ui-pl-36 ui-pr-36">
|
<view class="text-left ff ui-pt-30 ui-pl-36 ui-pr-36">
|
||||||
<textarea type="text" disabled show-confirm-bar="{{false}}" placeholder="输入打招呼内容" class="inputStyle" v-model="message" @input="typing" maxlength="60"/>
|
<textarea type="text" disabled show-confirm-bar="{{false}}" placeholder="输入打招呼内容" class="inputStyle" v-model="message" @input="typing" maxlength="60"/>
|
||||||
@ -764,7 +771,7 @@ page {
|
|||||||
<view class="u_apply_can f-fcc font_32 color999" @tap="modalName = ''">取消</view>
|
<view class="u_apply_can f-fcc font_32 color999" @tap="modalName = ''">取消</view>
|
||||||
<view class="font_30 u_apply_btn white f-fcc" @tap="sendMsg">发送</view>
|
<view class="font_30 u_apply_btn white f-fcc" @tap="sendMsg">发送</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="font_28 color999 text-center ui-pt-40" v-if="!userData.rank_id">还剩{{userInfo.other_info.surplus_num}}次,<text class="color-theme" @tap="jumpPath('vip')">成为会员</text>将不限制打招呼</view>
|
<view class="font_28 color999 text-center ui-pt-40" v-if="!friendEligibility.unlimited">已获得每天{{friendEligibility.daily_limit}}次免费机会,今日还剩{{friendEligibility.remaining}}次</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -820,6 +827,8 @@ wepy.page({
|
|||||||
modalName: '', // 弹框
|
modalName: '', // 弹框
|
||||||
pairedScore: 0,
|
pairedScore: 0,
|
||||||
userData: {}, // 自己的数据信息
|
userData: {}, // 自己的数据信息
|
||||||
|
friendEligibility: {unlimited: false, remaining: 0, daily_limit: 0},
|
||||||
|
checkingFriendEligibility: false,
|
||||||
userInfo: {}, // 当前用户详情数据
|
userInfo: {}, // 当前用户详情数据
|
||||||
profile_courtship: {},
|
profile_courtship: {},
|
||||||
approveList: [
|
approveList: [
|
||||||
@ -1231,7 +1240,7 @@ wepy.page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
// 发送打招呼消息
|
// 发送打招呼消息
|
||||||
sendMsg() {
|
async sendMsg() {
|
||||||
let vm = this
|
let vm = this
|
||||||
let data = {
|
let data = {
|
||||||
message: vm.message
|
message: vm.message
|
||||||
@ -1242,16 +1251,17 @@ wepy.page({
|
|||||||
}
|
}
|
||||||
if (vm.throttle) {
|
if (vm.throttle) {
|
||||||
vm.throttle = false
|
vm.throttle = false
|
||||||
|
if (!await vm.ensureFriendEligibility()) { vm.throttle = true; return }
|
||||||
vm.$post({url: `${service.host}/other/user/${vm.id}/friend`, data}).then(({code, data}) => {
|
vm.$post({url: `${service.host}/other/user/${vm.id}/friend`, data}).then(({code, data}) => {
|
||||||
if (code == 0) {
|
if (code == 0) {
|
||||||
vm.modalName = ''
|
vm.modalName = ''
|
||||||
vm.$showToast('消息已发送')
|
vm.$showToast('消息已发送')
|
||||||
if (vm.userInfo.other_info.surplus_num) {
|
// Refresh quota; guide unfinished tasks before offering membership.
|
||||||
vm.userInfo.other_info.surplus_num--
|
vm.ensureFriendEligibility()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
vm.throttle = true
|
vm.throttle = true
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
vm.ensureFriendEligibility()
|
||||||
vm.throttle = true
|
vm.throttle = true
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
vm.throttle = true
|
vm.throttle = true
|
||||||
@ -1270,14 +1280,58 @@ wepy.page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
// 联系朋友
|
// 联系朋友
|
||||||
contactFriendFn() {
|
async contactFriendFn() {
|
||||||
let vm = this
|
let vm = this
|
||||||
// 校验是否完成注册资料
|
// 登录后由服务端计算四项任务奖励及当日剩余次数。
|
||||||
if (!vm.$signInVerify()) {
|
if (!wx.getStorageSync('token')) {
|
||||||
vm.loginShow = true
|
vm.loginShow = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vm.getGreet(0)
|
if (vm.checkingFriendEligibility) return
|
||||||
|
vm.checkingFriendEligibility = true
|
||||||
|
try { if (await vm.ensureFriendEligibility()) vm.getGreet(0) }
|
||||||
|
finally { vm.checkingFriendEligibility = false }
|
||||||
|
},
|
||||||
|
async ensureFriendEligibility() {
|
||||||
|
try {
|
||||||
|
const {code, data} = await this.$get({url: `${service.host}/friend/request/eligibility`})
|
||||||
|
if (code !== 0 || !data) return false
|
||||||
|
this.friendEligibility = data
|
||||||
|
if (data.allowed === true) return true
|
||||||
|
this.modalName = ''
|
||||||
|
const tasks = {
|
||||||
|
real: {name: '真人认证', url: '/pages/users/realName'},
|
||||||
|
photo: {name: '上传真人头像并通过审核', url: '/pages/users/unmarriV2?progressShow=false'},
|
||||||
|
profile: {name: '完善资料', url: '/pages/users/unmarriV2?progressShow=false'},
|
||||||
|
education: {name: '学历认证', url: '/pages/users/degreeCertificate'}
|
||||||
|
}
|
||||||
|
const required = (data.required || []).filter(key => tasks[key])
|
||||||
|
if (required.length) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '领取更多免费机会',
|
||||||
|
content: '当前免费次数已用完。尚未完成:' + required.map(key => tasks[key].name).join('、') + '。每完成一项,每天增加1次免费加好友机会,最多4次/天。',
|
||||||
|
confirmText: '去完成',
|
||||||
|
success: res => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: required.map(key => tasks[key].name),
|
||||||
|
success: choice => { const task = tasks[required[choice.tapIndex]]; if (task) wx.navigateTo({url: task.url}) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (data.action === 'purchase') {
|
||||||
|
wx.showModal({
|
||||||
|
title: '今日免费次数已用完',
|
||||||
|
content: '四项任务已全部完成,今天的4次免费机会已用完。升级会员可无限次加好友。',
|
||||||
|
confirmText: '升级会员',
|
||||||
|
success: res => { if (res.confirm) wx.navigateTo({url: '/pages/users/upgradeVIP'}) }
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return data.allowed === true
|
||||||
|
} catch (e) { return false }
|
||||||
},
|
},
|
||||||
// 联系客服
|
// 联系客服
|
||||||
handleContact() {
|
handleContact() {
|
||||||
@ -1308,7 +1362,7 @@ wepy.page({
|
|||||||
wx.navigateTo({url: `/pages/home/personalDynamic?id=${vm.id}`})
|
wx.navigateTo({url: `/pages/home/personalDynamic?id=${vm.id}`})
|
||||||
} else if (type == 'chitchat') {
|
} else if (type == 'chitchat') {
|
||||||
wx.navigateTo({url: `/pages/news/chitchat?id=${vm.id}&name=${encodeURIComponent(vm.userInfo.base_profile.nickname)}&pic=${vm.userInfo.base_profile.photo}&type=${vm.userInfo.base_profile.type}&real=${vm.userInfo.approve_profile.is_real_approved}`})
|
wx.navigateTo({url: `/pages/news/chitchat?id=${vm.id}&name=${encodeURIComponent(vm.userInfo.base_profile.nickname)}&pic=${vm.userInfo.base_profile.photo}&type=${vm.userInfo.base_profile.type}&real=${vm.userInfo.approve_profile.is_real_approved}`})
|
||||||
} else if (type == 'vip' && !vm.isIos) {
|
} else if (type == 'vip') {
|
||||||
wx.navigateTo({url: `/pages/users/upgradeVIP`})
|
wx.navigateTo({url: `/pages/users/upgradeVIP`})
|
||||||
}
|
}
|
||||||
vm.modalName = ''
|
vm.modalName = ''
|
||||||
|
|||||||
@ -390,6 +390,7 @@ page {
|
|||||||
<!-- <scroll-view scroll-y="true" bindscroll="" class="scroll-content">-->
|
<!-- <scroll-view scroll-y="true" bindscroll="" class="scroll-content">-->
|
||||||
<view class="ui-home" v-if="pageLoading" @scroll="onPageScroll">
|
<view class="ui-home" v-if="pageLoading" @scroll="onPageScroll">
|
||||||
<cuCustom :searchName="'寻找心仪的Ta'" @tap="jumpPath('/pages/home/searchUsers')"></cuCustom>
|
<cuCustom :searchName="'寻找心仪的Ta'" @tap="jumpPath('/pages/home/searchUsers')"></cuCustom>
|
||||||
|
<!-- 首页轮播广告暂停展示
|
||||||
<swiper class="ui-swiper-box" circular="true" autoplay="true" indicator-dots="true" indicator-color="#c5c5c5" indicator-active-color="#ffffff">
|
<swiper class="ui-swiper-box" circular="true" autoplay="true" indicator-dots="true" indicator-color="#c5c5c5" indicator-active-color="#ffffff">
|
||||||
<block v-for="(item,index) in swiperList" :key="index">
|
<block v-for="(item,index) in swiperList" :key="index">
|
||||||
<swiper-item class=" ui-relative ">
|
<swiper-item class=" ui-relative ">
|
||||||
@ -397,10 +398,7 @@ page {
|
|||||||
</swiper-item>
|
</swiper-item>
|
||||||
</block>
|
</block>
|
||||||
</swiper>
|
</swiper>
|
||||||
<view :class="{'ui-fade-out': isScroll,'ui-fade-in': !isScroll}" class="ui-adv-box" v-if="!isClickClose" @tap="goToShop">
|
-->
|
||||||
<!-- <view class="ui-adv-message ui--message-fade"></view>-->
|
|
||||||
<div class="ui-adv-close" @tap.stop="isClickClose = true"></div>
|
|
||||||
</view>
|
|
||||||
<view class="f-fbc ui-pl-30 ui-pt-40 ui-pr-30">
|
<view class="f-fbc ui-pl-30 ui-pt-40 ui-pr-30">
|
||||||
<view class="font_34 color333 bold">置顶推荐</view>
|
<view class="font_34 color333 bold">置顶推荐</view>
|
||||||
<image class="ui-top-up" src="https://image.fulllinkai.com/202409/24/9c5a93366fc9e24dec02ba62c87ecf15.png" mode="widthFix" @tap="applyTopUp" v-if="topUpState != 2"></image>
|
<image class="ui-top-up" src="https://image.fulllinkai.com/202409/24/9c5a93366fc9e24dec02ba62c87ecf15.png" mode="widthFix" @tap="applyTopUp" v-if="topUpState != 2"></image>
|
||||||
@ -894,7 +892,7 @@ wepy.page({
|
|||||||
vm.getEveryday()
|
vm.getEveryday()
|
||||||
vm.getSquare()
|
vm.getSquare()
|
||||||
vm.applyTopUpState()
|
vm.applyTopUpState()
|
||||||
vm.getBannerList()
|
// vm.getBannerList() // 首页轮播广告暂停,不请求广告数据
|
||||||
vm.advModal = wx.getStorageSync('adv')
|
vm.advModal = wx.getStorageSync('adv')
|
||||||
if ((vm.advModal.id - 0) > 0) {
|
if ((vm.advModal.id - 0) > 0) {
|
||||||
vm.modalName = 'showAdv'
|
vm.modalName = 'showAdv'
|
||||||
@ -906,7 +904,7 @@ wepy.page({
|
|||||||
vm.getEveryday()
|
vm.getEveryday()
|
||||||
vm.getSquare()
|
vm.getSquare()
|
||||||
vm.applyTopUpState()
|
vm.applyTopUpState()
|
||||||
vm.getBannerList()
|
// vm.getBannerList() // 首页轮播广告暂停,不请求广告数据
|
||||||
vm.advModal = wx.getStorageSync('adv')
|
vm.advModal = wx.getStorageSync('adv')
|
||||||
if ((vm.advModal.id - 0) > 0) {
|
if ((vm.advModal.id - 0) > 0) {
|
||||||
vm.modalName = 'showAdv'
|
vm.modalName = 'showAdv'
|
||||||
|
|||||||
@ -358,12 +358,6 @@ wepy.page({
|
|||||||
type: 'navigateTo',
|
type: 'navigateTo',
|
||||||
path: '/pages/users/upgradeVIP'
|
path: '/pages/users/upgradeVIP'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: 'https://image.fulllinkai.com/202305/18/986abe4366a59ebc8c5437a63d4f957f.png',
|
|
||||||
title: '专属客服',
|
|
||||||
type: 'navigateTo',
|
|
||||||
path: '/pages/users/exclusiveService'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: 'https://image.fulllinkai.com/202201/13/0c8d92e86946c9a4244a92cacd4bd8d0.png',
|
icon: 'https://image.fulllinkai.com/202201/13/0c8d92e86946c9a4244a92cacd4bd8d0.png',
|
||||||
title: '我的发布',
|
title: '我的发布',
|
||||||
@ -371,22 +365,22 @@ wepy.page({
|
|||||||
path: '/pages/users/myDynamic'
|
path: '/pages/users/myDynamic'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'https://image.fulllinkai.com/202202/17/0cb2fe566b98d607136bde08e7f27f4d.png',
|
icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png',
|
||||||
title: '福恋指南',
|
title: '我的邀请',
|
||||||
type: 'navigateTo',
|
type: 'navigateTo',
|
||||||
path: 'https://love.ufutx.com/h5/#/guide'
|
path: '/pages/users/myShare'
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'https://image.fulllinkai.com/202202/21/c982ff4f02b9f834e6f4cd6886547f71.png',
|
|
||||||
title: '意见反馈',
|
|
||||||
type: 'navigateTo',
|
|
||||||
path: '/pages/users/report?type=user'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png',
|
icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png',
|
||||||
title: '分享我的卡片',
|
title: '我的卡片',
|
||||||
type: 'share',
|
type: 'navigateTo',
|
||||||
path: ''
|
path: '/pages/users/shareCard'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'https://image.fulllinkai.com/202202/17/0cb2fe566b98d607136bde08e7f27f4d.png',
|
||||||
|
title: '更多',
|
||||||
|
type: 'navigateTo',
|
||||||
|
path: '/pages/users/more'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'https://image.fulllinkai.com/202201/13/0b3e132b03ee70e1325229ef8885ba29.png',
|
icon: 'https://image.fulllinkai.com/202201/13/0b3e132b03ee70e1325229ef8885ba29.png',
|
||||||
@ -775,8 +769,8 @@ wepy.page({
|
|||||||
vm.getServiceCode(url)
|
vm.getServiceCode(url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (text === '分享我的卡片') {
|
if (text === '我的卡片') {
|
||||||
vm.hide = false
|
wx.navigateTo({url: '/pages/users/shareCard'})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (url && url.includes('page')) {
|
if (url && url.includes('page')) {
|
||||||
|
|||||||
BIN
src/pages/users/assets/ambassador-contact.jpg
Normal file
BIN
src/pages/users/assets/ambassador-contact.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
src/pages/users/assets/fulian-logo.png
Normal file
BIN
src/pages/users/assets/fulian-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
src/pages/users/assets/invitation-share-cover.png
Normal file
BIN
src/pages/users/assets/invitation-share-cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
src/pages/users/assets/personal-share-cover.png
Normal file
BIN
src/pages/users/assets/personal-share-cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
37
src/pages/users/more.wpy
Normal file
37
src/pages/users/more.wpy
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<view class="more-page">
|
||||||
|
<view class="more-heading">更多服务</view>
|
||||||
|
<view class="more-list">
|
||||||
|
<view class="more-row" @tap="openService"><text>专属客服</text><text>{{serviceLoading ? '加载中…' : '›'}}</text></view>
|
||||||
|
<view class="more-row" @tap="guide"><text>福恋指南</text><text>›</text></view>
|
||||||
|
<view class="more-row" @tap="open('/pages/users/report?type=user')"><text>意见反馈</text><text>›</text></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import wepy from '@wepy/core'
|
||||||
|
import https from '../../mixins/https'
|
||||||
|
import {service} from '../../config'
|
||||||
|
wepy.page({mixins:[https],data:{serviceLoading:false},methods: {
|
||||||
|
async openService() {
|
||||||
|
if (this.serviceLoading) return
|
||||||
|
this.serviceLoading=true
|
||||||
|
try {
|
||||||
|
const result=await this.$get({url:`${service.host}/crm/alone/cs/qrcode`})
|
||||||
|
if (result.data.qrcode) wx.previewImage({urls:[result.data.qrcode],current:result.data.qrcode})
|
||||||
|
else this.open('/pages/users/exclusiveService')
|
||||||
|
} catch (_) { wx.showToast({title:'客服信息加载失败,请重试',icon:'none'}) }
|
||||||
|
finally { this.serviceLoading=false }
|
||||||
|
},
|
||||||
|
open(url) { wx.navigateTo({url}) },
|
||||||
|
guide() { wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent('https://love.ufutx.com/h5/#/guide')}) }
|
||||||
|
}})
|
||||||
|
</script>
|
||||||
|
<style lang="less">
|
||||||
|
.more-page { min-height:100vh; padding:32rpx; box-sizing:border-box; background:#FFF9F5; color:#4E4145; }
|
||||||
|
.more-heading { font-size:36rpx; font-weight:600; color:#A3294D; margin:16rpx 0 32rpx; }
|
||||||
|
.more-list { background:#FFF1F4; border-radius:24rpx; padding:0 28rpx; }
|
||||||
|
.more-row { display:flex; justify-content:space-between; padding:32rpx 0; font-size:30rpx; border-bottom:1rpx solid #EED3DB; }
|
||||||
|
.more-row:last-child { border-bottom:0; }
|
||||||
|
</style>
|
||||||
|
<config>{navigationBarTitleText:'更多',navigationBarBackgroundColor:'#FFF9F5'}</config>
|
||||||
103
src/pages/users/myShare.wpy
Normal file
103
src/pages/users/myShare.wpy
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<view class="sharing-page invitation-page">
|
||||||
|
<view class="page-intro"><view class="intro-label">分享一份美好的可能</view><view class="intro-title">邀请朋友,认真相遇</view></view>
|
||||||
|
<view class="invitation-tabs">
|
||||||
|
<button :class="{'invitation-tab':true,'is-active':activeTab === 'poster'}" @tap="showPosterTab">专属邀请</button>
|
||||||
|
<button :class="{'invitation-tab':true,'is-active':activeTab === 'results'}" @tap="showResultsTab">邀请成果</button>
|
||||||
|
</view>
|
||||||
|
<view v-show="activeTab === 'poster'" class="share-panel">
|
||||||
|
<view class="section-top"><text class="panel-caption">我的专属邀请</text><button class="text-button" :disabled="generating || profileLoading" @tap="refreshPoster">更新海报</button></view>
|
||||||
|
<view v-if="generating || (profileLoading && !poster)" class="generation-progress">
|
||||||
|
<view class="generation-progress-heading"><text>{{posterStage}}</text><text>{{posterProgress}}%</text></view>
|
||||||
|
<view class="generation-progress-track"><view class="generation-progress-fill" style="width: {{posterProgress}}%;"></view></view>
|
||||||
|
<view class="generation-progress-note">首次生成需要下载图片,完成后会缓存到本地</view>
|
||||||
|
</view>
|
||||||
|
<view class="invite-stage">
|
||||||
|
<view class="invite-poster-frame" @tap="preview">
|
||||||
|
<image v-if="poster" class="invite-poster-image" src="{{poster}}" mode="widthFix" @error="posterImageFailed"></image>
|
||||||
|
<image v-else class="invite-poster-image" src="/pages/users/assets/invitation-share-cover.png" mode="widthFix"></image>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="preview-caption">{{poster ? '点击海报 · 查看大图' : '邀请封面预览 · 海报生成后可保存'}}</view>
|
||||||
|
<view v-if="generating || (profileLoading && !poster)" class="asset-status">{{profileLoading ? '正在准备你的邀请' : posterStage}}</view>
|
||||||
|
<view v-else-if="posterError || profileError" class="asset-error"><text>{{posterError || profileError}}</text><button class="text-button" @tap="refreshPoster">重新生成</button></view>
|
||||||
|
<view v-else-if="cacheNote" class="asset-status ready-status">{{cacheNote}}</view>
|
||||||
|
<view class="share-actions"><button class="secondary-button" :disabled="!poster || saving" @tap="savePoster">{{saving ? '正在保存…' : '保存邀请海报'}}</button><button class="primary-button" open-type="share" :disabled="!profile.id">邀请微信好友</button></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-show="activeTab === 'results'" class="results-tab">
|
||||||
|
<view class="results-panel">
|
||||||
|
<view class="stat-grid"><view class="stat-item"><text class="stat-number">{{overviewReady ? total : '—'}}</text><text class="stat-label">累计邀请 / 人</text></view><view class="stat-item"><text class="stat-number">{{overviewReady ? verified : '—'}}</text><text class="stat-label">通过真人认证 / 人</text></view></view>
|
||||||
|
<view v-if="overviewError" class="asset-error"><text>邀请数据暂未加载成功</text><button class="text-button" @tap="loadOverview">重试</button></view>
|
||||||
|
<view class="ambassador-block">
|
||||||
|
<view class="ambassador-title"><text>成为婚恋使者</text><text class="progress-count">{{overviewReady ? verified : '—'}} / 7</text></view>
|
||||||
|
<view class="progress-track"><view class="progress-fill" style="width: {{progressPercent}}%;"></view></view>
|
||||||
|
<view class="ambassador-bottom"><view class="ambassador-note"><text v-if="canApply">已满足申请条件</text><text v-else-if="overviewReady">还差 {{remaining}} 位真人认证</text><text v-else>邀请7位完成真人认证后可申请</text><text class="ambassador-subnote">添加荣耀老师微信,沟通申请</text></view><button class="apply-button" :disabled="!canApply || checkingEligibility" @tap="openApplication">{{checkingEligibility ? '查询中…' : '申请使者'}}</button></view>
|
||||||
|
<view v-if="eligibilityError" class="asset-error"><text>申请资格查询失败</text><button class="text-button" @tap="refreshEligibility">重新查询</button></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="orders-notice"><text>邀请订单</text><text>婚恋使者专属 · 暂未开放</text></view>
|
||||||
|
<view class="section-heading list-heading"><text>邀请用户</text><text class="section-meta">每一次分享,都可能促成相遇</text></view>
|
||||||
|
<view class="invite-list">
|
||||||
|
<view v-for="item in invitees" :key="item.id" class="invite-row" hover-class="invite-row-pressed" @tap="openInvitee(item.id)"><view class="invite-avatar">{{item.initial}}</view><view class="invite-name">{{item.nickname || '福恋用户'}}</view><view :class="{'verified-badge':item.verified,'pending-badge':!item.verified}">{{item.verified ? '真人认证已通过' : '待完成真人认证'}}</view><text class="invite-arrow">›</text></view>
|
||||||
|
<view v-if="!invitees.length && !listLoading && !listError" class="empty-state"><view class="empty-title">第一份邀请,从你开始</view><view>在“专属邀请”分享海报给朋友,邀请记录会展示在这里。</view></view>
|
||||||
|
<view v-if="listError" class="list-status">列表加载失败,请重试</view>
|
||||||
|
<view v-if="listLoading" class="list-status">正在加载邀请用户…</view>
|
||||||
|
<button v-if="!listLoading && (hasMore || listError)" class="load-more" @tap="loadMore">{{listError ? '重新加载' : '加载更多邀请'}}</button>
|
||||||
|
<view v-if="invitees.length && !hasMore && !listLoading && !listError" class="list-status">已展示全部邀请</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
<view v-if="showApplication" class="application-mask" @tap="closeApplication"><view class="application-dialog" @tap.stop="keepApplicationOpen"><view class="application-title">联系荣耀老师</view><view class="application-note">添加微信时请备注“申请婚恋使者”,与老师沟通申请事宜。</view><image class="application-code" src="{{contactImage}}" mode="widthFix" show-menu-by-longpress="true" @tap="previewContact"></image><view class="application-hint">点击放大二维码,或保存后在微信扫一扫中选择相册识别。</view><button class="primary-button" :disabled="savingContact" @tap="saveContact">{{savingContact ? '正在保存…' : '保存二维码'}}</button><button class="secondary-button application-close" @tap="closeApplication">关闭</button></view></view>
|
||||||
|
<canvas type="2d" id="fulianPoster" class="drawing-canvas"></canvas><canvas type="2d" id="fulianCover" class="drawing-canvas"></canvas>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import wepy from '@wepy/core'
|
||||||
|
import {service} from '../../config'
|
||||||
|
import sharingPage from '../../mixins/sharing-page'
|
||||||
|
import {sharingRequest, boundedCall} from '../../utils/sharing-runtime'
|
||||||
|
import {sharingPath, saveSharingPoster} from '../../utils/sharing'
|
||||||
|
wepy.page({
|
||||||
|
mixins:[sharingPage('invite')],
|
||||||
|
data:{activeTab:'poster',overviewReady:false,overviewError:false,total:0,verified:0,remaining:7,progressPercent:0,invitees:[],page:0,hasMore:true,listLoading:false,listError:false,canApply:false,checkingEligibility:false,eligibilityError:false,showApplication:false,savingContact:false,contactImage:'/pages/users/assets/ambassador-contact.jpg'},
|
||||||
|
onLoad(){this.startSharing();this.loadOverview();this.loadMore()},
|
||||||
|
onShow(){if(this.overviewReady)this.refreshEligibility()},
|
||||||
|
onShareAppMessage(){return {title:'邀请你加入福恋,上福恋认真找对象',path:this.profile.id?sharingPath(this.profile.id,false):'/pages/tabBar/welcome',imageUrl:this.cover||'/pages/users/assets/invitation-share-cover.png'}},
|
||||||
|
methods:{
|
||||||
|
openInvitee(value){
|
||||||
|
const id=Number(value)
|
||||||
|
if(!Number.isSafeInteger(id)||id<=0)return
|
||||||
|
wx.navigateTo({url:`/pages/home/information?id=${id}`,fail(){wx.showToast({title:'暂时无法打开用户详情,请重试',icon:'none'})}})
|
||||||
|
},
|
||||||
|
showPosterTab(){this.activeTab='poster'},
|
||||||
|
showResultsTab(){this.activeTab='results'},
|
||||||
|
setOverview(data){this.total=Number(data.total)||0;this.verified=Number(data.verified)||0;this.remaining=Math.max(0,7-this.verified);this.progressPercent=Math.min(100,Math.max(0,this.verified/7*100));this.canApply=data.can_apply===true&&this.verified>=7;this.overviewReady=true},
|
||||||
|
async loadOverview(){this.overviewError=false;try{const data=await sharingRequest(`${service.user}/sharing/overview`);if(!this._sharingDisposed)this.setOverview(data)}catch(_){if(!this._sharingDisposed)this.overviewError=true}},
|
||||||
|
async refreshEligibility(){
|
||||||
|
if(this.checkingEligibility)return false
|
||||||
|
this.checkingEligibility=true;this.eligibilityError=false
|
||||||
|
try{const data=await sharingRequest(`${service.user}/sharing/overview`);if(this._sharingDisposed)return false;this.setOverview(data);return this.canApply}
|
||||||
|
catch(_){if(!this._sharingDisposed){this.canApply=false;this.showApplication=false;this.eligibilityError=true}return false}
|
||||||
|
finally{if(!this._sharingDisposed)this.checkingEligibility=false}
|
||||||
|
},
|
||||||
|
async openApplication(){if(!this.canApply||this.checkingEligibility)return;if(await this.refreshEligibility())this.showApplication=true},
|
||||||
|
closeApplication(){this.showApplication=false},keepApplicationOpen(){},
|
||||||
|
previewContact(){wx.previewImage({urls:[this.contactImage],current:this.contactImage})},
|
||||||
|
async saveContact(){
|
||||||
|
if(this.savingContact)return;this.savingContact=true
|
||||||
|
try{const image=await boundedCall((resolve,reject)=>wx.getImageInfo({src:this.contactImage,success:resolve,fail:reject}),'二维码读取失败');await saveSharingPoster(image.path);wx.showToast({title:'二维码已保存',icon:'success'})}
|
||||||
|
catch(_){wx.showModal({title:'未能保存',content:'请检查相册权限后重试,也可以点击二维码放大查看。',showCancel:false})}
|
||||||
|
finally{if(!this._sharingDisposed)this.savingContact=false}
|
||||||
|
},
|
||||||
|
async loadMore(){
|
||||||
|
if(this.listLoading)return;this.listLoading=true;this.listError=false
|
||||||
|
try{const data=await sharingRequest(`${service.user}/sharing/invitees`,'GET',{page:this.page+1});if(this._sharingDisposed)return;const known=new Set(this.invitees.map(item=>item.id));this.invitees=this.invitees.concat(data.data.filter(item=>!known.has(item.id)).map(item=>({...item,initial:Array.from(item.nickname||'友')[0]})));this.page=data.page;this.hasMore=data.has_more}
|
||||||
|
catch(_){if(!this._sharingDisposed)this.listError=true}
|
||||||
|
finally{if(!this._sharingDisposed)this.listLoading=false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="less" src="../../styles/sharing.less"></style>
|
||||||
|
<config>{navigationBarTitleText:'我的邀请',navigationBarBackgroundColor:'#FFF9F5'}</config>
|
||||||
33
src/pages/users/shareCard.wpy
Normal file
33
src/pages/users/shareCard.wpy
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<view class="sharing-page personal-page">
|
||||||
|
<view class="page-intro"><view class="intro-label">属于你的认真介绍</view><view class="intro-title">让合适的人,认识真实的你</view><view class="intro-note">一张卡片,开启一次真诚的相识。</view></view>
|
||||||
|
<view class="section-top card-tools"><text class="panel-caption">我的个人海报</text><button class="text-button" :disabled="generating || profileLoading" @tap="refreshPoster">更新海报</button></view>
|
||||||
|
<view v-if="generating || (profileLoading && !poster)" class="generation-progress">
|
||||||
|
<view class="generation-progress-heading"><text>{{posterStage}}</text><text>{{posterProgress}}%</text></view>
|
||||||
|
<view class="generation-progress-track"><view class="generation-progress-fill" style="width: {{posterProgress}}%;"></view></view>
|
||||||
|
<view class="generation-progress-note">首次生成需要下载图片,完成后会缓存到本地</view>
|
||||||
|
</view>
|
||||||
|
<view class="personal-stage">
|
||||||
|
<image v-if="poster" class="personal-poster" src="{{poster}}" mode="widthFix" @tap="preview" @error="posterImageFailed"></image>
|
||||||
|
<view v-else class="personal-fallback"><view class="fallback-brand"><image src="/pages/users/assets/fulian-logo.png" mode="aspectFit"></image><text>福恋</text></view><image v-if="profile.photo" class="fallback-photo" src="{{profile.photo}}" mode="aspectFit"></image><view v-else class="fallback-photo fallback-blank"><image src="/pages/users/assets/fulian-logo.png" mode="aspectFit"></image><text>期待认真相识</text></view><view class="fallback-name">{{profile.nickname || '我的卡片'}}</view><view class="fallback-details">{{profile.details}}</view><view class="fallback-intro">{{profile.introduction || '上福恋认真找对象'}}</view><view class="fallback-footer">{{posterError || profileError ? '海报暂未生成' : '正在准备你的个人海报'}}</view></view>
|
||||||
|
</view>
|
||||||
|
<view v-if="generating || (profileLoading && !poster)" class="asset-status card-status">{{profileLoading ? '正在读取个人资料' : posterStage}}</view>
|
||||||
|
<view v-else-if="posterError || profileError" class="asset-error card-status"><text>{{posterError || profileError}}</text><button class="text-button" @tap="refreshPoster">重新生成</button></view>
|
||||||
|
<view v-else-if="cacheNote" class="asset-status ready-status card-status">{{cacheNote}} · 点击查看大图</view>
|
||||||
|
<view class="card-tip">点击“更新海报”,随机换一张合适的生活照。</view>
|
||||||
|
<view class="card-dock"><button class="secondary-button" :disabled="!poster || saving" @tap="savePoster">{{saving ? '正在保存…' : '保存海报'}}</button><button class="primary-button" open-type="share" :disabled="!profile.id">分享给好友</button></view>
|
||||||
|
<canvas type="2d" id="fulianPoster" class="drawing-canvas"></canvas><canvas type="2d" id="fulianCover" class="drawing-canvas"></canvas>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import wepy from '@wepy/core'
|
||||||
|
import sharingPage from '../../mixins/sharing-page'
|
||||||
|
import {sharingPath} from '../../utils/sharing'
|
||||||
|
wepy.page({
|
||||||
|
mixins:[sharingPage('personal')],
|
||||||
|
onLoad(){this.startSharing()},
|
||||||
|
onShareAppMessage(){return {title:this.profile.nickname?`我是${this.profile.nickname},期待与你相识`:'我的福恋卡片,期待与你相识',path:this.profile.id?sharingPath(this.profile.id,true):'/pages/tabBar/welcome',imageUrl:this.cover||'/pages/users/assets/personal-share-cover.png'}}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="less" src="../../styles/sharing.less"></style>
|
||||||
|
<config>{navigationBarTitleText:'我的卡片',navigationBarBackgroundColor:'#FFF9F5'}</config>
|
||||||
@ -23,14 +23,17 @@
|
|||||||
<view class="font_24 white flo_l" style="color: #f7e4ad;width: 70%; ">
|
<view class="font_24 white flo_l" style="color: #f7e4ad;width: 70%; ">
|
||||||
{{ user.deadline_str ? user.deadline_str + ' 到期' : '未激活' }}
|
{{ user.deadline_str ? user.deadline_str + ' 到期' : '未激活' }}
|
||||||
</view>
|
</view>
|
||||||
<swiper class="card-swiper" circular="true" @change="cardSwiper">
|
<view class="plan-heading">选择适合你的会员</view>
|
||||||
<swiper-item v-for="(item,index) in rankList" :key="index" class="{{cardCur==index?'cur':''}}"
|
<view class="plan-hint">左右滑动或点击卡片,比较套餐价格</view>
|
||||||
@tap="showFn(index)">
|
<view class="membership-carousel" @touchstart="planTouchStart" @touchend="planTouchEnd">
|
||||||
<view class="swiper-item radius shadow " style="border-radius: 0">
|
<view v-for="(item,index) in rankList" :key="item.id" class="membership-plan {{item.position}}" @tap="selectPlan(index)">
|
||||||
<image src="{{system === 'iOS' ? (item.ios_pic || item.pic) : item.pic}}" mode="aspectFix"></image>
|
<view class="plan-badge">{{cardCur == index ? '✓ 已选套餐' : '点击选择'}}</view>
|
||||||
|
<image class="plan-picture" v-if="item.pic" :src="item.pic" mode="aspectFit"></image>
|
||||||
|
<view class="plan-name">{{item.name}}</view>
|
||||||
|
<view class="plan-price"><text class="plan-currency" v-if="item.displayPrice != '待确认'">¥</text>{{item.displayPrice}}</view>
|
||||||
|
<view class="plan-duration">{{item.month}}个月</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</swiper-item>
|
|
||||||
</swiper>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="mainWrapper animation-slide-bottom bg_f">
|
<view class="mainWrapper animation-slide-bottom bg_f">
|
||||||
<view class="title _vipRights">会员套餐权益:</view>
|
<view class="title _vipRights">会员套餐权益:</view>
|
||||||
@ -70,16 +73,17 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view style="height: 42px"></view>
|
<view style="height: 42px"></view>
|
||||||
|
<view class="recent-member-order" style="padding:24rpx;text-align:center" @tap="checkRecentOrder">查询最近会员订单</view>
|
||||||
<!-- 支付按钮:全端统一显示 -->
|
<!-- 支付按钮:全端统一显示 -->
|
||||||
<view class="payBtn" @tap="showFn()">
|
<view class="payBtn" @tap="showFn()">
|
||||||
<view class="l_text">
|
<view class="l_text">
|
||||||
<span>
|
<span>
|
||||||
合计: <span style="color: #FF2946">¥</span><span class="price">{{ payData.discount_price }}</span>
|
{{selectedPlan.name || '请选择套餐'}} · {{selectedPlan.displayPrice == '待确认' ? '价格待确认' : '¥' + selectedPlan.displayPrice}}
|
||||||
</span>
|
</span>
|
||||||
</view>
|
</view>
|
||||||
<view class="r_text">
|
<view class="r_text">
|
||||||
<span class="font_32">
|
<span class="font_32">
|
||||||
立即购买
|
{{purchasing ? '正在确认订单' : (purchaseEnabled ? '购买会员' : '暂不可购买')}}
|
||||||
</span>
|
</span>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -114,11 +118,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
|
|||||||
<span class="value bold color333" style="font-size: 60rpx;font-weight: 700;"
|
<span class="value bold color333" style="font-size: 60rpx;font-weight: 700;"
|
||||||
v-else>{{ deductionPrice > 0 ? deductionPrice : '0.00' }}</span>
|
v-else>{{ deductionPrice > 0 ? deductionPrice : '0.00' }}</span>
|
||||||
</view>
|
</view>
|
||||||
<view class="text-left f-fcc">
|
|
||||||
<span class="font_24" style="color: #3C2913;text-decoration-line: line-through;">原价:¥{{
|
|
||||||
payData.price
|
|
||||||
}}</span>
|
|
||||||
</view>
|
|
||||||
<view class="item-xl text-left f-fcc">
|
<view class="item-xl text-left f-fcc">
|
||||||
<span class="font_24" style="color: #66676C;">{{ payData.title }}</span>
|
<span class="font_24" style="color: #66676C;">{{ payData.title }}</span>
|
||||||
</view>
|
</view>
|
||||||
@ -127,7 +127,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
|
|||||||
<image class="u_payData_img"
|
<image class="u_payData_img"
|
||||||
src="https://images.health.ufutx.com/202512/25/5bcd5b54fc5325aa50e841e0cf9542d0.png"
|
src="https://images.health.ufutx.com/202512/25/5bcd5b54fc5325aa50e841e0cf9542d0.png"
|
||||||
mode="aspectFit" lazy-load="true"></image>
|
mode="aspectFit" lazy-load="true"></image>
|
||||||
<view class="font_28" style="color: #0E0E0E;">微信支付</view>
|
<view class="font_28" style="color: #0E0E0E;">{{system === 'iOS' ? 'Apple 支付' : '微信支付'}}</view>
|
||||||
</view>
|
</view>
|
||||||
<image src="https://images.health.ufutx.com/202512/25/911f75d508bc6c0de593fbb815a2c479.png" mode="aspectFit"
|
<image src="https://images.health.ufutx.com/202512/25/911f75d508bc6c0de593fbb815a2c479.png" mode="aspectFit"
|
||||||
class="iconV2"></image>
|
class="iconV2"></image>
|
||||||
@ -168,6 +168,8 @@ import wepy from '@wepy/core'
|
|||||||
import https from '../../mixins/https'
|
import https from '../../mixins/https'
|
||||||
import base from '../../mixins/base'
|
import base from '../../mixins/base'
|
||||||
import {service} from '../../config.js'
|
import {service} from '../../config.js'
|
||||||
|
import {buyMembership} from '../../utils/virtual-payment'
|
||||||
|
import {membershipPlans, planPrice, defaultPlanIndex} from '../../utils/membership-plans'
|
||||||
|
|
||||||
const app = getApp().$wepy.$options
|
const app = getApp().$wepy.$options
|
||||||
|
|
||||||
@ -181,6 +183,8 @@ wepy.page({
|
|||||||
activeV2: false,
|
activeV2: false,
|
||||||
swiperList: [],
|
swiperList: [],
|
||||||
cardCur: 0,
|
cardCur: 0,
|
||||||
|
planTouchX: 0,
|
||||||
|
planTouchY: 0,
|
||||||
specialVIP: [],
|
specialVIP: [],
|
||||||
payData: {
|
payData: {
|
||||||
coin: '',
|
coin: '',
|
||||||
@ -198,6 +202,9 @@ wepy.page({
|
|||||||
modalName: '',
|
modalName: '',
|
||||||
chat_user_id: '',
|
chat_user_id: '',
|
||||||
trade_no: '',
|
trade_no: '',
|
||||||
|
purchaseEnabled: false,
|
||||||
|
purchasing: false,
|
||||||
|
virtualProducts: [],
|
||||||
system: '', // 系统类型 iOS/Android
|
system: '', // 系统类型 iOS/Android
|
||||||
share_type: '',
|
share_type: '',
|
||||||
shareId: '',
|
shareId: '',
|
||||||
@ -210,6 +217,7 @@ wepy.page({
|
|||||||
loginShow: false
|
loginShow: false
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
selectedPlan() { return this.rankList[this.cardCur] || {name: '', displayPrice: '待确认'} },
|
||||||
residue() {
|
residue() {
|
||||||
return (this.score - (this.price - this.my_rank_price)).toFixed(2)
|
return (this.score - (this.price - this.my_rank_price)).toFixed(2)
|
||||||
}
|
}
|
||||||
@ -230,6 +238,7 @@ wepy.page({
|
|||||||
this.rankList = []
|
this.rankList = []
|
||||||
// 获取系统类型
|
// 获取系统类型
|
||||||
this.system = app.globalData.systemInfo.system.split(' ')[0]
|
this.system = app.globalData.systemInfo.system.split(' ')[0]
|
||||||
|
this.refreshPurchase()
|
||||||
this.getPageData()
|
this.getPageData()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -256,13 +265,58 @@ wepy.page({
|
|||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
cardCur() {
|
cardCur() {
|
||||||
this.payData.title = `超级会员(${this.rankList[this.cardCur].name})`
|
this.syncSelectedPlan()
|
||||||
this.payData.price = this.rankList[this.cardCur].price
|
|
||||||
this.payData.discount_price = this.rankList[this.cardCur].discount_price
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
syncSelectedPlan() {
|
||||||
|
const item = this.rankList[this.cardCur]
|
||||||
|
if (!item) return
|
||||||
|
this.rankList = this.rankList.map((plan, index) => ({...plan,
|
||||||
|
displayPrice: planPrice(plan, this.virtualProducts),
|
||||||
|
position: index === this.cardCur ? 'is-selected' : index === (this.cardCur + this.rankList.length - 1) % this.rankList.length ? 'is-left' : 'is-right'
|
||||||
|
}))
|
||||||
|
this.payData.title = `超级会员(${item.name})`
|
||||||
|
this.payData.price = item.price
|
||||||
|
this.payData.discount_price = this.rankList[this.cardCur].displayPrice
|
||||||
|
this.shareId = item.id
|
||||||
|
},
|
||||||
|
selectPlan(index) {
|
||||||
|
if (this.purchasing || !this.rankList[index]) return
|
||||||
|
this.cardSwiper({$wx: {detail: {current: index}}})
|
||||||
|
this.syncSelectedPlan()
|
||||||
|
},
|
||||||
|
planTouchStart(e) {
|
||||||
|
const touch = (e.$wx || e).touches[0]
|
||||||
|
this.planTouchX = touch.clientX
|
||||||
|
this.planTouchY = touch.clientY
|
||||||
|
},
|
||||||
|
planTouchEnd(e) {
|
||||||
|
const touch = (e.$wx || e).changedTouches[0]
|
||||||
|
const dx = touch.clientX - this.planTouchX, dy = touch.clientY - this.planTouchY
|
||||||
|
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy) || this.rankList.length < 2) return
|
||||||
|
this.selectPlan((this.cardCur + (dx < 0 ? 1 : -1) + this.rankList.length) % this.rankList.length)
|
||||||
|
},
|
||||||
|
pendingStorageKey() {const u = wx.getStorageSync('userInfo') || {}; return 'virtual-member-order:' + (u.id || u.user_id || 'anonymous')},
|
||||||
|
async checkRecentOrder() {
|
||||||
|
const orderNo = wx.getStorageSync(this.pendingStorageKey())
|
||||||
|
if (!orderNo) {wx.showToast({title:'暂无待查询订单',icon:'none'});return}
|
||||||
|
try {
|
||||||
|
const result = await this.paymentAPI().post('/order/virtual/' + encodeURIComponent(orderNo) + '/confirm', {})
|
||||||
|
if (result.state === 'FULFILLED') {this.getPageData();wx.removeStorageSync(this.pendingStorageKey());wx.showToast({title:'会员权益已到账',icon:'success'})}
|
||||||
|
else {wx.showModal({title:'会员订单',content:'支付结果仍在确认中,请勿重复购买。\n如需帮助,请添加福恋小天使微信号:ploves_004,联系客服人工处理。',showCancel:false})}
|
||||||
|
} catch (e) {wx.showModal({title:'会员订单',content:(e.message || '查询失败,请稍后重试') + '\n如需帮助,请添加福恋小天使微信号:ploves_004,联系客服人工处理。',showCancel:false})}
|
||||||
|
},
|
||||||
|
paymentAPI() {
|
||||||
|
const unwrap = r => { if (r.code !== 0) throw new Error(r.message || '请求失败'); return r.data }
|
||||||
|
return {get: path => this.$get({url: service.host + path}).then(unwrap), post: (path, data) => this.$post({url: service.host + path, data}).then(unwrap)}
|
||||||
|
},
|
||||||
|
async refreshPurchase() {
|
||||||
|
this.purchaseEnabled = false
|
||||||
|
try {const status = await this.paymentAPI().get('/rank/purchase/status'); this.virtualProducts = status.products || []; this.purchaseEnabled = status.enabled === true} catch (e) {this.virtualProducts = []}
|
||||||
|
this.syncSelectedPlan()
|
||||||
|
},
|
||||||
gotoApp(item) {
|
gotoApp(item) {
|
||||||
let URL = `https://love.ufutx.com/h5/#/serviceAgreementV3?title=${item}`
|
let URL = `https://love.ufutx.com/h5/#/serviceAgreementV3?title=${item}`
|
||||||
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
||||||
@ -274,13 +328,10 @@ wepy.page({
|
|||||||
vm.user = data.rank_info
|
vm.user = data.rank_info
|
||||||
let {type, is_subscribe} = wx.getStorageSync('userInfo')
|
let {type, is_subscribe} = wx.getStorageSync('userInfo')
|
||||||
vm.specialVIP = data.ranks
|
vm.specialVIP = data.ranks
|
||||||
vm.rankList = []
|
vm.rankList = membershipPlans(data.ranks.sub_ranks)
|
||||||
data.ranks.sub_ranks.forEach((item) => {
|
vm.cardCur = defaultPlanIndex(vm.rankList)
|
||||||
vm.rankList.push(item)
|
if (!vm.rankList.length) {wx.hideLoading();return}
|
||||||
})
|
vm.syncSelectedPlan()
|
||||||
vm.payData.title = `超级会员(${vm.rankList[0].name})`
|
|
||||||
vm.payData.price = `${vm.rankList[0].price}`
|
|
||||||
vm.payData.discount_price = `${vm.rankList[0].discount_price}`
|
|
||||||
vm.payData.pay_type = data.pay_type
|
vm.payData.pay_type = data.pay_type
|
||||||
vm.shareId = vm.rankList[vm.cardCur].id
|
vm.shareId = vm.rankList[vm.cardCur].id
|
||||||
if (wx.getStorageSync('from_user_id')) {
|
if (wx.getStorageSync('from_user_id')) {
|
||||||
@ -312,28 +363,19 @@ wepy.page({
|
|||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
},
|
},
|
||||||
showFn(index) {
|
async showFn(index) {
|
||||||
if (!this.$signInVerify()) {
|
if (this.purchasing) return
|
||||||
this.loginShow = true
|
if (!this.$signInVerify()) {this.loginShow = true; return}
|
||||||
return
|
await this.refreshPurchase()
|
||||||
}
|
if (!this.purchaseEnabled) {wx.showToast({title:'会员购买暂不可用',icon:'none'});return}
|
||||||
// 移除原来的iOS禁止支付逻辑
|
const item = this.rankList[typeof index === 'number' ? index : this.cardCur]
|
||||||
|
const product = item && this.virtualProducts.find(p => p.sub_rank_id === item.id)
|
||||||
|
if (!product) {wx.showToast({title:'套餐暂不可购买',icon:'none'});return}
|
||||||
this.wxPayShow = 'cash'
|
this.wxPayShow = 'cash'
|
||||||
this.coinMoney = (this.payData.coin / 10).toFixed(2)
|
this.payData.payId = item.id
|
||||||
this.deductionPrice = this.payData.coin >= this.specialVIP.can_coin_amount ? (this.payData.discount_price - this.specialVIP.can_cash_amount).toFixed(2) : (this.payData.discount_price - this.coinMoney).toFixed(2)
|
this.payData.discount_price = (product.price_fen / 100).toFixed(2)
|
||||||
if (this.rankList[this.cardCur].id) {
|
this.payData.title = item.name
|
||||||
this.modalName = 'DialogModal'
|
this.modalName = 'DialogModal'
|
||||||
let monay = 0
|
|
||||||
if (this.payData.discount_price < this.payData.coin / 10) {
|
|
||||||
monay = 0
|
|
||||||
} else {
|
|
||||||
monay = (this.payData.discount_price - this.payData.coin / 10).toFixed(2)
|
|
||||||
}
|
|
||||||
this.payData.monay = Math.abs(monay).toFixed(2)
|
|
||||||
this.payData.payId = this.rankList[this.cardCur].id
|
|
||||||
} else {
|
|
||||||
this.modalName = 'Modal'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
hideModal() {
|
hideModal() {
|
||||||
this.modalName = ''
|
this.modalName = ''
|
||||||
@ -356,143 +398,17 @@ wepy.page({
|
|||||||
showTost() {
|
showTost() {
|
||||||
this.$showToast('请同意《福恋服务协议》')
|
this.$showToast('请同意《福恋服务协议》')
|
||||||
},
|
},
|
||||||
// ======================================
|
async conversion() {
|
||||||
// 核心支付方法(Android原接口,iOS新虚拟支付接口)
|
if (this.purchasing) return
|
||||||
// ======================================
|
this.purchasing = true
|
||||||
conversion(item) {
|
this.modalName = ''
|
||||||
let that = this
|
try {
|
||||||
that.modalName = ''
|
await buyMembership(wx, this.paymentAPI(), this.payData.payId, Math.round(Number(this.payData.discount_price) * 100), orderNo => { this.trade_no = orderNo; wx.setStorageSync(this.pendingStorageKey(), orderNo) })
|
||||||
that.$showLoading('支付中...')
|
this.getPageData()
|
||||||
|
wx.removeStorageSync(this.pendingStorageKey())
|
||||||
// 根据系统类型选择不同的接口和参数
|
wx.showToast({title:'会员权益已到账',icon:'success'})
|
||||||
if (that.system === 'iOS') {
|
} catch (e) {wx.showModal({title:'会员支付',content:(e.message || '支付暂不可用,请稍后查询订单') + '\n如已扣款,请先查询订单,勿重复购买。\n如需帮助,请添加福恋小天使微信号:ploves_004,联系客服人工处理。',showCancel:false})}
|
||||||
// 先获取微信登录 code
|
finally {this.purchasing = false;wx.hideLoading()}
|
||||||
that.$showLoading('获取支付信息...')
|
|
||||||
|
|
||||||
wx.login({
|
|
||||||
success: (loginRes) => {
|
|
||||||
const code = loginRes.code
|
|
||||||
console.log('获取到code:', code)
|
|
||||||
|
|
||||||
let url = `${service.host}/rank/buy/virtual`
|
|
||||||
let data = {
|
|
||||||
sub_rank_id: this.payData.payId, // 必须:会员套餐ID
|
|
||||||
pay_type: this.wxPayShow, // 必须:支付类型
|
|
||||||
share_type: this.share_type || '', // 必须:分享类型
|
|
||||||
code: code // 微信登录code,用于后端签名
|
|
||||||
// chat_user_id: this.chat_user_id || '', // 非必须:聊天用户ID
|
|
||||||
// from_user_id: wx.getStorageSync('from_user_id') || '' // 非必须:来源用户ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用iOS虚拟支付接口创建订单
|
|
||||||
that.$post({url: url, data}).then(({code, data}) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
that.$Toast_error(data?.msg || '订单创建失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
that.trade_no = data?.order_no
|
|
||||||
console.log('---', that.trade_no)
|
|
||||||
|
|
||||||
// 测试订单直接回调
|
|
||||||
if (data.length == 0 || data.config?.payment_debug) {
|
|
||||||
return that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// iOS虚拟支付
|
|
||||||
const virtualPayData = data || {}
|
|
||||||
wx.requestVirtualPayment({
|
|
||||||
signData: virtualPayData.sign_data,
|
|
||||||
paySig: virtualPayData.paySig || '',
|
|
||||||
signature: virtualPayData.signature || '',
|
|
||||||
mode: virtualPayData.mode || 'short_series_goods',
|
|
||||||
success(res) {
|
|
||||||
console.log('requestVirtualPayment success', res)
|
|
||||||
// 虚拟支付成功后回调
|
|
||||||
that.$post({
|
|
||||||
url: `${service.host}/order/callback/virtual`,
|
|
||||||
data: {orderNo: that.trade_no}
|
|
||||||
}).then(() => {
|
|
||||||
that.getPageData()
|
|
||||||
wx.showModal({ // 使用模态框提示用户进行操作
|
|
||||||
title: '支付完成',
|
|
||||||
content: '会员开通成功,权益将在几分钟内到账,到账后即可生效。',
|
|
||||||
showCancel: false,
|
|
||||||
confirmText: `知道了`,
|
|
||||||
success: function (res) {
|
|
||||||
if (res.confirm) {
|
|
||||||
let userInfo = wx.getStorageSync('userInfo')
|
|
||||||
userInfo.rank_id = 9
|
|
||||||
wx.setStorageSync('userInfo', userInfo)
|
|
||||||
} else if (res.cancel) {
|
|
||||||
console.log('用户点击取消')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail({errMsg, errCode}) {
|
|
||||||
console.error('虚拟支付失败:', errMsg, errCode)
|
|
||||||
that.$Toast_error('支付失败,请重试')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error('订单创建失败:', err)
|
|
||||||
that.$Toast_error('订单创建失败,请重试')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail: (err) => {
|
|
||||||
console.error('wx.login失败:', err)
|
|
||||||
that.$Toast_error('获取登录信息失败,请重试')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// ============== Android:原有普通支付接口 ==============
|
|
||||||
let url = `${service.host}/rank/buy`
|
|
||||||
|
|
||||||
let data = {
|
|
||||||
sub_rank_id: this.payData.payId,
|
|
||||||
pay_type: this.wxPayShow
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用原接口创建订单
|
|
||||||
that.$post({url: url, data}).then(({code, data}) => {
|
|
||||||
that.trade_no = data.order.trade_no
|
|
||||||
|
|
||||||
// 测试订单直接回调
|
|
||||||
if (data.length == 0 || data.config?.payment_debug) {
|
|
||||||
return that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android微信支付
|
|
||||||
let wxconfig = data.config
|
|
||||||
wx.requestPayment({
|
|
||||||
timeStamp: wxconfig.timeStamp,
|
|
||||||
nonceStr: wxconfig.nonceStr,
|
|
||||||
package: wxconfig.package,
|
|
||||||
signType: wxconfig.signType || 'MD5',
|
|
||||||
paySign: wxconfig.paySign,
|
|
||||||
success(res) {
|
|
||||||
that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(() => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
let userInfo = wx.getStorageSync('userInfo')
|
|
||||||
userInfo.rank_id = 9
|
|
||||||
wx.setStorageSync('userInfo', userInfo)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail(res) {
|
|
||||||
wx.showToast({title: '已取消支付', icon: 'none'})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
payChoice(e) {
|
payChoice(e) {
|
||||||
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
||||||
@ -543,8 +459,10 @@ page {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
background: #171718;
|
background: #171718;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 70vw;
|
min-height: 610rpx;
|
||||||
|
height: auto;
|
||||||
padding-top: 6vw;
|
padding-top: 6vw;
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
|
||||||
.userinfo-avatar {
|
.userinfo-avatar {
|
||||||
width: 110rpx;
|
width: 110rpx;
|
||||||
@ -649,13 +567,23 @@ page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-swiper {
|
.plan-heading { clear: both; padding: 34rpx 32rpx 0; color: #fff; font-size: 32rpx; font-weight: 600; }
|
||||||
width: 100% !important;
|
.plan-hint { padding: 10rpx 32rpx 0; color: #cbbdc1; font-size: 23rpx; }
|
||||||
|
.membership-carousel { position: relative; height: 386rpx; margin: 18rpx 20rpx 0; }
|
||||||
.swiper-item {
|
.membership-plan { position: absolute; box-sizing: border-box; width: 28%; top: 30rpx; height: 316rpx; padding: 18rpx 10rpx 12rpx; border: 2rpx solid #EED3DB; border-radius: 24rpx; background: #FFF9F5; text-align: center; color: #4E4145; transition: left .25s, width .25s, top .25s, height .25s; }
|
||||||
border-radius: 22rpx;
|
.membership-plan.is-left { left: 0; }
|
||||||
}
|
.membership-plan.is-right { left: 72%; }
|
||||||
}
|
.membership-plan.is-selected { left: 30%; top: 0; width: 40%; height: 376rpx; border: 4rpx solid #D92554; background: #FFF1F4; box-shadow: 0 12rpx 30rpx rgba(217,37,84,.2); z-index: 2; }
|
||||||
|
.plan-badge { color: #97717D; font-size: 20rpx; height: 30rpx; }
|
||||||
|
.is-selected .plan-badge { color: #D92554; font-weight: 600; }
|
||||||
|
.plan-picture { width: 100%; height: 80rpx; display: block; margin: 6rpx auto 8rpx; }
|
||||||
|
.is-selected .plan-picture { height: 102rpx; }
|
||||||
|
.plan-name { color: #A3294D; font-size: 28rpx; font-weight: 600; }
|
||||||
|
.is-selected .plan-name { font-size: 34rpx; }
|
||||||
|
.plan-price { color: #A3294D; font-size: 42rpx; line-height: 1.4; font-weight: 700; white-space: nowrap; }
|
||||||
|
.is-selected .plan-price { color: #D92554; font-size: 60rpx; }
|
||||||
|
.plan-currency { font-size: 24rpx; margin-right: 2rpx; }
|
||||||
|
.plan-duration { font-size: 22rpx; color: #97717D; }
|
||||||
|
|
||||||
.iconV2 {
|
.iconV2 {
|
||||||
height: 36rpx;
|
height: 36rpx;
|
||||||
|
|||||||
@ -53,19 +53,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<block v-if="type == 'single'">
|
<block v-if="type == 'single'">
|
||||||
<view class="payBtn" v-if="system != 'iOS' || active == '1'" @tap="showFn()">
|
<view class="payBtn" @tap="showFn()">单项服务暂不可购买</view>
|
||||||
<view class="l_text">
|
|
||||||
<span class="font_32 bold">
|
|
||||||
<span>¥</span>{{specialVIP.discount_price}}
|
|
||||||
</span>
|
|
||||||
<!-- <span style="text-decoration: line-through;" class="font_22">{{specialVIP.price}}</span>-->
|
|
||||||
</view>
|
|
||||||
<view class="r_text">
|
|
||||||
<span class="font_32">
|
|
||||||
立即购买
|
|
||||||
</span>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</block>
|
</block>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<button class="btn text-center font_30 btn-box white radius shadow bg-blue margin-top" open-type="share">分享给好友</button>
|
<button class="btn text-center font_30 btn-box white radius shadow bg-blue margin-top" open-type="share">分享给好友</button>
|
||||||
@ -116,7 +104,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="m_pay_bu f-fcc">
|
<view class="m_pay_bu f-fcc">
|
||||||
<view class="u_pay_bu f-fcc font_32 white bold" bindtap="conversion">立即支付</view>
|
<view class="u_pay_bu f-fcc font_32 white bold" bindtap="conversion">暂不可购买</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -196,15 +184,12 @@
|
|||||||
}
|
}
|
||||||
if (e.other_user_id) {
|
if (e.other_user_id) {
|
||||||
this.other_user_id = e.other_user_id
|
this.other_user_id = e.other_user_id
|
||||||
console.log(this.other_user_id, 'this.other_user_id')
|
|
||||||
}
|
}
|
||||||
if (e.chat_user_id) {
|
if (e.chat_user_id) {
|
||||||
this.chat_user_id = e.chat_user_id
|
this.chat_user_id = e.chat_user_id
|
||||||
}
|
}
|
||||||
console.log(this.chat_user_id, '99999')
|
|
||||||
this.rankList = []
|
this.rankList = []
|
||||||
this.system = wx.getStorageSync('system')
|
this.system = wx.getStorageSync('system')
|
||||||
console.log(this.system)
|
|
||||||
this.initPageData()
|
this.initPageData()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -215,7 +200,6 @@
|
|||||||
let openid = wx.getStorageSync('openid')
|
let openid = wx.getStorageSync('openid')
|
||||||
let fromUserID = wx.getStorageSync('user_id')
|
let fromUserID = wx.getStorageSync('user_id')
|
||||||
let url = `/pages/users/upgradeVIP2?from_openid=${openid}&share_user_id=${that.id}&share_type=share&from_user_id=${fromUserID}&index=${that.cardCur}`
|
let url = `/pages/users/upgradeVIP2?from_openid=${openid}&share_user_id=${that.id}&share_type=share&from_user_id=${fromUserID}&index=${that.cardCur}`
|
||||||
console.log(url)
|
|
||||||
return {
|
return {
|
||||||
title: that.shareText,
|
title: that.shareText,
|
||||||
path: url,
|
path: url,
|
||||||
@ -239,9 +223,7 @@
|
|||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
cardCur() {
|
cardCur() {
|
||||||
console.log('payData', this.cardCur)
|
|
||||||
// if (this.active == 1) {
|
// if (this.active == 1) {
|
||||||
console.log(this.rankList[this.cardCur])
|
|
||||||
this.payData.title = this.rankList[this.cardCur].title
|
this.payData.title = this.rankList[this.cardCur].title
|
||||||
this.payData.price = this.rankList[this.cardCur].price
|
this.payData.price = this.rankList[this.cardCur].price
|
||||||
this.payData.discount_price = this.rankList[this.cardCur].discount_price
|
this.payData.discount_price = this.rankList[this.cardCur].discount_price
|
||||||
@ -271,7 +253,6 @@
|
|||||||
// rank_id: data.rank_id
|
// rank_id: data.rank_id
|
||||||
// }
|
// }
|
||||||
// wx.setStorageSync('userInfo', userInfo)
|
// wx.setStorageSync('userInfo', userInfo)
|
||||||
console.log(data)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// 判断是否json
|
// 判断是否json
|
||||||
@ -296,12 +277,17 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
that.rankList = data.services.map(item => {
|
that.rankList = (Array.isArray(data.services) ? data.services : []).map(item => {
|
||||||
if (that.isValidJSON(item.feature)) {
|
if (that.isValidJSON(item.feature)) {
|
||||||
item.feature = JSON.parse(item.feature)
|
item.feature = JSON.parse(item.feature)
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
|
if (!that.rankList.length) {
|
||||||
|
that.specialVIP = {feature: [], discount_desc: '暂无可展示的服务套餐,单项服务暂不可购买'}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!that.rankList[that.cardCur_1]) that.cardCur_1 = 0
|
||||||
that.specialVIP = that.rankList[that.cardCur_1]
|
that.specialVIP = that.rankList[that.cardCur_1]
|
||||||
that.payData.title = `${that.rankList[that.cardCur_1].title}`
|
that.payData.title = `${that.rankList[that.cardCur_1].title}`
|
||||||
that.payData.price = `${that.rankList[that.cardCur_1].price}`
|
that.payData.price = `${that.rankList[that.cardCur_1].price}`
|
||||||
@ -312,10 +298,10 @@
|
|||||||
if (wx.getStorageSync('from_user_id')) {
|
if (wx.getStorageSync('from_user_id')) {
|
||||||
// that.worthShare()
|
// that.worthShare()
|
||||||
}
|
}
|
||||||
wx.hideLoading()
|
}).catch(() => {
|
||||||
console.log(that.payData, 'rankList')
|
that.rankList = []
|
||||||
console.log(that.rankList, 'rankList')
|
that.specialVIP = {feature: [], discount_desc: '服务套餐加载失败,请稍后重试;单项服务暂不可购买'}
|
||||||
})
|
}).finally(() => wx.hideLoading())
|
||||||
},
|
},
|
||||||
worthShare() {
|
worthShare() {
|
||||||
let vm = this,
|
let vm = this,
|
||||||
@ -333,48 +319,21 @@
|
|||||||
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
|
||||||
},
|
},
|
||||||
cardSwiper(e) {
|
cardSwiper(e) {
|
||||||
console.log(e, 'e===')
|
|
||||||
this.cardCur = e.$wx.detail.current
|
this.cardCur = e.$wx.detail.current
|
||||||
this.shareText = this.rankList[e.$wx.detail.current].title
|
this.shareText = this.rankList[e.$wx.detail.current].title
|
||||||
this.sharePic = this.rankList[e.$wx.detail.current].pic
|
this.sharePic = this.rankList[e.$wx.detail.current].pic
|
||||||
// if (this.active == 1) {
|
// if (this.active == 1) {
|
||||||
console.log(this.cardCur)
|
|
||||||
this.specialVIP = this.rankList[this.cardCur]
|
this.specialVIP = this.rankList[this.cardCur]
|
||||||
// }
|
// }
|
||||||
clearTimeout(this.timeID)
|
clearTimeout(this.timeID)
|
||||||
this.timeID = setTimeout(() => {
|
this.timeID = setTimeout(() => {
|
||||||
this.shareId = this.rankList[this.cardCur].id
|
this.shareId = this.rankList[this.cardCur].id
|
||||||
console.log(this.shareId)
|
|
||||||
if (wx.getStorageSync('from_user_id')) {
|
if (wx.getStorageSync('from_user_id')) {
|
||||||
this.worthShare()
|
this.worthShare()
|
||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
},
|
},
|
||||||
showFn (index) { // 弹框支付
|
showFn() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
|
||||||
if (!this.$signInVerify()) {
|
|
||||||
this.loginShow = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.wxPayShow = 'cash'
|
|
||||||
this.coinMoney = (this.payData.coin / 10).toFixed(2)
|
|
||||||
// this.deductionPrice = this.payData.coin >= this.specialVIP.can_coin_amount ? (this.payData.discount_price - this.specialVIP.can_cash_amount).toFixed(2) : (this.payData.discount_price - this.coinMoney).toFixed(2)
|
|
||||||
if (this.rankList[this.cardCur].id) {
|
|
||||||
// let monay = 0
|
|
||||||
// if (this.payData.discount_price < this.payData.coin / 10) {
|
|
||||||
// monay = 0
|
|
||||||
// } else {
|
|
||||||
// monay = (this.payData.discount_price - this.payData.coin / 10).toFixed(2)
|
|
||||||
// }
|
|
||||||
// console.log(monay, 'monay=')
|
|
||||||
// this.payData.monay = Math.abs(monay).toFixed(2)
|
|
||||||
this.payData.payId = this.rankList[this.cardCur].id
|
|
||||||
this.modalName = 'DialogModal'
|
|
||||||
} else {
|
|
||||||
this.modalName = 'Modal'
|
|
||||||
}
|
|
||||||
// this.payData = JSON.stringify(this.payData)
|
|
||||||
console.log(this.payData, '===')
|
|
||||||
},
|
|
||||||
hideModal () {
|
hideModal () {
|
||||||
this.modalName = ''
|
this.modalName = ''
|
||||||
},
|
},
|
||||||
@ -397,75 +356,7 @@
|
|||||||
showTost() {
|
showTost() {
|
||||||
this.$showToast('请同意《福恋服务协议》')
|
this.$showToast('请同意《福恋服务协议》')
|
||||||
},
|
},
|
||||||
conversion(item) {
|
conversion() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
|
||||||
console.log(this.payData.payId)
|
|
||||||
let that = this,
|
|
||||||
url = `${service.host}/rank/service/${this.payData.payId}/buy`
|
|
||||||
that.modalName = ''
|
|
||||||
that.$showLoading('支付中...')
|
|
||||||
let data = {}
|
|
||||||
// if (that.active == 1) {
|
|
||||||
data = {
|
|
||||||
// chat_user_id: this.chat_user_id,
|
|
||||||
// other_user_id: this.other_user_id,
|
|
||||||
pay_type: this.wxPayShow
|
|
||||||
}
|
|
||||||
// } else {
|
|
||||||
// data = {
|
|
||||||
// sub_rank_id: this.payData.payId,
|
|
||||||
// chat_user_id: this.chat_user_id,
|
|
||||||
// other_user_id: this.other_user_id,
|
|
||||||
// pay_type: this.wxPayShow
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
that.$post({url: url, data}).then(({code, data}) => {
|
|
||||||
that.trade_no = data.trade_no
|
|
||||||
if (data.length == 0) {
|
|
||||||
that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.cardCur_1 = 0
|
|
||||||
that.getUserData()
|
|
||||||
that.initPageData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let wxconfig = data.config
|
|
||||||
// wx.config(JSON.parse(response.data.data.order.wx_pay.js));
|
|
||||||
if (wxconfig.payment_debug) {
|
|
||||||
return that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wx.requestPayment({
|
|
||||||
timeStamp: wxconfig.timeStamp, // 支付签名时间戳,注意微信jssdk中的所有使用timestamp字段均为小写。但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符
|
|
||||||
nonceStr: wxconfig.nonceStr, // 支付签名随机串,不长于 32 位
|
|
||||||
package: wxconfig.package, // 统一支付接口返回的prepay_id参数值,提交格式如:prepay_id=***)
|
|
||||||
signType: wxconfig.signType, // 签名方式,默认为'SHA1',使用新版支付需传入'MD5'
|
|
||||||
paySign: wxconfig.paySign, // 支付签名
|
|
||||||
success: function (res) {
|
|
||||||
that.$post({url: `${service.host}/order/${that.trade_no}/callback`}).then(({code, data}) => {
|
|
||||||
that.getUserData()
|
|
||||||
that.$Toast_success('支付成功')
|
|
||||||
let userInfo = wx.getStorageSync('userInfo')
|
|
||||||
userInfo.rank_id = 9
|
|
||||||
wx.setStorageSync('userInfo', userInfo)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail: function (res) {
|
|
||||||
wx.showToast({
|
|
||||||
title: '已取消支付',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).finally(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
wx.hideLoading()
|
|
||||||
}, 1200)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
payChoice(e) {
|
payChoice(e) {
|
||||||
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
|
||||||
},
|
},
|
||||||
@ -506,6 +397,7 @@
|
|||||||
._bc-user{
|
._bc-user{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: url('https://images.ufutx.com/202004/09/5fd9264a4da7c310d1e086a92be7195a.png');
|
background: url('https://images.ufutx.com/202004/09/5fd9264a4da7c310d1e086a92be7195a.png');
|
||||||
|
background-color: #191919;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -635,29 +527,20 @@
|
|||||||
letter-spacing: 8rpx;
|
letter-spacing: 8rpx;
|
||||||
}
|
}
|
||||||
.payBtn{
|
.payBtn{
|
||||||
position: relative;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translate(-50%);
|
width: 100%;
|
||||||
width: 480rpx;
|
padding: 28rpx 24rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||||
height: 60px;
|
box-sizing: border-box;
|
||||||
background-image: url("https://images.ufutx.com/202007/17/fb7b8b1cd0d1b289d1e029257a97e949.png");
|
background: #fff;
|
||||||
background-size: contain;
|
color: #666;
|
||||||
background-repeat: no-repeat;
|
text-align: center;
|
||||||
.l_text{
|
font-size: 30rpx;
|
||||||
position: absolute;
|
z-index: 10;
|
||||||
left: 60rpx;
|
|
||||||
top: 12rpx;
|
|
||||||
}
|
|
||||||
.r_text{
|
|
||||||
color: #f8e8b2;
|
|
||||||
position: absolute;
|
|
||||||
right: 56rpx;
|
|
||||||
top: 12rpx;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-vip{
|
.bg-vip{
|
||||||
background: #333333;
|
background: #333333;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
95
src/styles/sharing.less
Normal file
95
src/styles/sharing.less
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
.sharing-page { min-height:100vh; padding:32rpx 28rpx 52rpx; box-sizing:border-box; background:#FFF9F5; color:#4E4145; font-size:28rpx; }
|
||||||
|
.sharing-page button { box-sizing:border-box; margin:0; font-size:28rpx; line-height:1.4; font-weight:500; border-radius:18rpx; padding:24rpx 16rpx; }
|
||||||
|
.sharing-page button::after { border:none; }
|
||||||
|
.sharing-page button[disabled] { opacity:.48; }
|
||||||
|
.page-intro { margin:4rpx 4rpx 28rpx; }
|
||||||
|
.intro-label { color:#97717D; font-size:24rpx; letter-spacing:2rpx; margin-bottom:12rpx; }
|
||||||
|
.intro-title { color:#A3294D; font-size:40rpx; line-height:1.45; font-weight:600; }
|
||||||
|
.intro-note { color:#97717D; font-size:26rpx; line-height:1.6; margin-top:12rpx; }
|
||||||
|
.share-panel { background:#FFF1F4; border-radius:28rpx; padding:24rpx; }
|
||||||
|
.section-top { display:flex; align-items:center; justify-content:space-between; gap:12rpx; }
|
||||||
|
.panel-caption { color:#A3294D; font-size:25rpx; font-weight:500; }
|
||||||
|
.sharing-page .text-button { padding:14rpx 8rpx; background:transparent; color:#A3294D; font-size:24rpx; flex-shrink:0; }
|
||||||
|
.invite-stage { margin:14rpx 0 18rpx; }
|
||||||
|
.invite-poster-frame { width:100%; background:#FFF9F5; border-radius:18rpx; overflow:hidden; box-shadow:0 10rpx 28rpx rgba(163,41,77,.12); }
|
||||||
|
.invite-poster-image { width:100%; display:block; }
|
||||||
|
.poster-placeholder { height:960rpx; display:flex; flex-direction:column; justify-content:center; align-items:center; color:#A3294D; gap:20rpx; font-size:22rpx; }
|
||||||
|
.poster-placeholder image { width:74rpx; height:74rpx; }
|
||||||
|
.poster-placeholder .placeholder-small { font-size:18rpx; color:#97717D; }
|
||||||
|
.preview-caption { color:#97717D; font-size:23rpx; text-align:center; margin:18rpx 0 4rpx; }
|
||||||
|
.asset-status { color:#97717D; font-size:23rpx; line-height:1.6; padding:10rpx 0 16rpx; }
|
||||||
|
.ready-status { color:#97717D; }
|
||||||
|
.asset-error { color:#A3294D; display:flex; align-items:center; justify-content:space-between; gap:12rpx; font-size:24rpx; line-height:1.6; padding:10rpx 0; }
|
||||||
|
.share-actions { display:flex; gap:16rpx; }
|
||||||
|
.share-actions button { flex:1; min-width:0; }
|
||||||
|
.sharing-page .primary-button { background:#D92554; color:#FFF9F5; }
|
||||||
|
.sharing-page .secondary-button { background:#FFF9F5; color:#A3294D; border:1rpx solid #EED3DB; }
|
||||||
|
.section-heading { font-size:32rpx; color:#A3294D; font-weight:600; margin:34rpx 4rpx 18rpx; }
|
||||||
|
.results-panel { border:1rpx solid #EED3DB; border-radius:26rpx; padding:26rpx; }
|
||||||
|
.stat-grid { display:flex; padding:2rpx 0 28rpx; }
|
||||||
|
.stat-item { flex:1; display:flex; flex-direction:column; gap:8rpx; padding-left:14rpx; }
|
||||||
|
.stat-item + .stat-item { border-left:1rpx solid #EED3DB; padding-left:40rpx; }
|
||||||
|
.stat-number { font-size:52rpx; color:#A3294D; line-height:1.2; font-weight:600; }
|
||||||
|
.stat-label { font-size:24rpx; color:#97717D; }
|
||||||
|
.ambassador-block { border-top:1rpx solid #EED3DB; padding-top:24rpx; }
|
||||||
|
.ambassador-title { display:flex; justify-content:space-between; align-items:center; color:#A3294D; font-size:28rpx; font-weight:500; }
|
||||||
|
.progress-count { font-size:25rpx; }
|
||||||
|
.progress-track { height:10rpx; margin:22rpx 0; border-radius:10rpx; background:#EED3DB; overflow:hidden; }
|
||||||
|
.progress-fill { height:100%; background:#D92554; border-radius:10rpx; }
|
||||||
|
.ambassador-bottom { display:flex; align-items:center; justify-content:space-between; gap:16rpx; }
|
||||||
|
.ambassador-note { font-size:25rpx; line-height:1.5; flex:1; }
|
||||||
|
.ambassador-subnote { display:block; color:#97717D; font-size:22rpx; margin-top:8rpx; }
|
||||||
|
.sharing-page .apply-button { padding:20rpx; background:#D92554; color:#FFF9F5; font-size:25rpx; flex-shrink:0; }
|
||||||
|
.orders-notice { display:flex; justify-content:space-between; gap:16rpx; padding:28rpx 6rpx 0; font-size:25rpx; }
|
||||||
|
.orders-notice text:last-child { color:#97717D; font-size:23rpx; }
|
||||||
|
.list-heading { display:flex; align-items:baseline; justify-content:space-between; flex-wrap:wrap; gap:10rpx; }
|
||||||
|
.section-meta { font-size:22rpx; font-weight:400; color:#97717D; }
|
||||||
|
.invite-list { border-top:1rpx solid #EED3DB; }
|
||||||
|
.invite-row { display:flex; align-items:center; gap:18rpx; padding:26rpx 0; border-bottom:1rpx solid #EED3DB; }
|
||||||
|
.invite-avatar { width:66rpx; height:66rpx; border-radius:50%; background:#FFF1F4; color:#A3294D; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
|
||||||
|
.invite-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:28rpx; }
|
||||||
|
.verified-badge,.pending-badge { font-size:21rpx; flex-shrink:0; border-radius:8rpx; padding:8rpx 10rpx; }
|
||||||
|
.verified-badge { color:#A3294D; background:#FFF1F4; }
|
||||||
|
.pending-badge { color:#97717D; }
|
||||||
|
.empty-state { text-align:center; padding:50rpx 24rpx; color:#97717D; font-size:25rpx; line-height:1.8; }
|
||||||
|
.empty-title { color:#A3294D; font-size:29rpx; margin-bottom:10rpx; }
|
||||||
|
.list-status { padding:30rpx 0; text-align:center; color:#97717D; font-size:24rpx; }
|
||||||
|
.sharing-page .load-more { background:transparent; color:#A3294D; width:100%; padding:30rpx 0; font-size:25rpx; }
|
||||||
|
.personal-page { padding-bottom:calc(172rpx + env(safe-area-inset-bottom)); }
|
||||||
|
.personal-page .intro-title { font-size:36rpx; }
|
||||||
|
.card-tools { margin:0 4rpx 12rpx; }
|
||||||
|
.personal-stage { padding:24rpx; border-radius:28rpx; background:#FFF1F4; }
|
||||||
|
.personal-poster { display:block; width:100%; border-radius:18rpx; box-shadow:0 12rpx 32rpx rgba(163,41,77,.12); }
|
||||||
|
.personal-fallback { background:#FFF9F5; border-radius:18rpx; padding:28rpx; }
|
||||||
|
.fallback-brand { display:flex; align-items:center; gap:12rpx; color:#A3294D; font-size:26rpx; font-weight:600; margin-bottom:24rpx; }
|
||||||
|
.fallback-brand image { width:44rpx; height:44rpx; }
|
||||||
|
.fallback-photo { width:100%; height:360rpx; border-radius:16rpx; display:block; background:#FFF1F4; }
|
||||||
|
.fallback-blank { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:24rpx; color:#97717D; }
|
||||||
|
.fallback-blank image { width:96rpx; height:96rpx; }
|
||||||
|
.fallback-name { color:#A3294D; font-size:34rpx; font-weight:600; margin:24rpx 0 12rpx; }
|
||||||
|
.fallback-details { color:#97717D; font-size:23rpx; }
|
||||||
|
.fallback-intro { font-size:25rpx; line-height:1.7; margin:18rpx 0; max-height:130rpx; overflow:hidden; }
|
||||||
|
.fallback-footer { border-top:1rpx solid #EED3DB; color:#97717D; padding-top:20rpx; font-size:23rpx; }
|
||||||
|
.card-status { padding:22rpx 8rpx 0; }
|
||||||
|
.card-tip { font-size:23rpx; color:#97717D; text-align:center; padding:20rpx 0; line-height:1.7; }
|
||||||
|
.card-dock { position:fixed; z-index:20; bottom:0; left:0; right:0; display:flex; gap:18rpx; padding:22rpx 28rpx; padding-bottom:calc(22rpx + env(safe-area-inset-bottom)); background:#FFF9F5; border-top:1rpx solid #EED3DB; }
|
||||||
|
.card-dock button { flex:1; }
|
||||||
|
.drawing-canvas { position:fixed; left:-2000px; top:0; width:750px; height:1120px; pointer-events:none; }
|
||||||
|
.application-mask { position:fixed; top:0; right:0; bottom:0; left:0; z-index:1000; background:rgba(78,65,69,.6); display:flex; align-items:center; justify-content:center; padding:24rpx; box-sizing:border-box; }
|
||||||
|
.application-dialog { width:100%; max-width:640rpx; max-height:90vh; overflow-y:auto; background:#FFF9F5; border-radius:28rpx; padding:28rpx; box-sizing:border-box; }
|
||||||
|
.application-title { color:#A3294D; font-size:34rpx; font-weight:600; text-align:center; }
|
||||||
|
.application-note { font-size:28rpx; line-height:1.7; margin:18rpx 0; }
|
||||||
|
.application-code { display:block; width:100%; }
|
||||||
|
.application-hint { font-size:24rpx; color:#97717D; line-height:1.6; margin:20rpx 0; }
|
||||||
|
.application-dialog button { width:100%; }
|
||||||
|
.sharing-page .application-close { margin-top:16rpx; }
|
||||||
|
.invitation-tabs { display:flex; padding:8rpx; margin-bottom:28rpx; border-radius:22rpx; background:#FFF1F4; gap:8rpx; }
|
||||||
|
.sharing-page .invitation-tab { flex:1; padding:22rpx 12rpx; border-radius:16rpx; background:transparent; color:#97717D; font-size:28rpx; }
|
||||||
|
.sharing-page .invitation-tab.is-active { background:#D92554; color:#FFF9F5; font-weight:600; }
|
||||||
|
.invite-row-pressed { background:#FFF1F4; }
|
||||||
|
.invite-arrow { color:#97717D; font-size:36rpx; flex-shrink:0; line-height:1; }
|
||||||
|
.generation-progress { margin:18rpx 0 24rpx; padding:22rpx; background:#FFF9F5; border:1rpx solid #EED3DB; border-radius:18rpx; }
|
||||||
|
.generation-progress-heading { display:flex; justify-content:space-between; gap:16rpx; color:#A3294D; font-size:25rpx; }
|
||||||
|
.generation-progress-track { height:12rpx; margin:18rpx 0; border-radius:12rpx; background:#EED3DB; overflow:hidden; }
|
||||||
|
.generation-progress-fill { height:100%; background:#D92554; border-radius:12rpx; transition:width .25s ease; }
|
||||||
|
.generation-progress-note { font-size:22rpx; line-height:1.6; color:#97717D; }
|
||||||
10
src/utils/invitation.js
Normal file
10
src/utils/invitation.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export function invitationFromOptions(options) {
|
||||||
|
const query = (options && options.query) || {}
|
||||||
|
let sceneID = ''
|
||||||
|
try {
|
||||||
|
const match = decodeURIComponent(query.scene || '').match(/(?:^|&)from_user_id=([1-9][0-9]*)(?:&|$)/)
|
||||||
|
if (match) sceneID = match[1]
|
||||||
|
} catch (_) {}
|
||||||
|
const value = String(sceneID || query.from_user_id || '')
|
||||||
|
return /^[1-9][0-9]*$/.test(value) && Number.isSafeInteger(Number(value)) ? value : ''
|
||||||
|
}
|
||||||
17
src/utils/membership-plans.js
Normal file
17
src/utils/membership-plans.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Only show durations supplied by the API; never manufacture a missing plan or price.
|
||||||
|
export function membershipPlans(rows) {
|
||||||
|
const plans = [1, 3, 12].map(month => (rows || []).find(item => Number(item.month) === month)).filter(Boolean)
|
||||||
|
return plans.map(item => ({...item, displayPrice: '待确认', position: ''}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planPrice(item, products) {
|
||||||
|
const product = (products || []).find(p => Number(p.sub_rank_id) === Number(item.id))
|
||||||
|
const raw = product ? Number(product.price_fen) / 100 : (item.discount_price == null ? item.price : item.discount_price)
|
||||||
|
if (raw === '' || raw == null || !Number.isFinite(Number(raw)) || Number(raw) < 0) return '待确认'
|
||||||
|
return Number(raw).toFixed(2).replace(/\.00$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPlanIndex(plans) {
|
||||||
|
const quarter = plans.findIndex(item => Number(item.month) === 3)
|
||||||
|
return quarter < 0 ? 0 : quarter
|
||||||
|
}
|
||||||
54
src/utils/sharing-cache.js
Normal file
54
src/utils/sharing-cache.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import {boundedCall, fileAccess} from './sharing-runtime'
|
||||||
|
|
||||||
|
const VERSION = 'v6'
|
||||||
|
const MAX_AGE = 24 * 60 * 60 * 1000
|
||||||
|
const INDEX = 'fulian-sharing-cache-' + VERSION
|
||||||
|
|
||||||
|
function digest(text) {
|
||||||
|
let value = 2166136261
|
||||||
|
for (let i=0;i<text.length;i++) { value ^= text.charCodeAt(i); value = Math.imul(value,16777619) }
|
||||||
|
return (value >>> 0).toString(16)
|
||||||
|
}
|
||||||
|
function identity(id,kind) {
|
||||||
|
if (!Number.isSafeInteger(Number(id)) || Number(id)<=0 || !['invite','personal'].includes(kind)) throw new Error('分享信息无效')
|
||||||
|
return `${Number(id)}-${kind}`
|
||||||
|
}
|
||||||
|
export function sharingFingerprint(profile,kind) {
|
||||||
|
const values = kind === 'invite' ? [profile.id,profile.nickname] : [profile.id,profile.nickname,profile.photo,profile.photos,profile.details,profile.introduction,profile.idealMate]
|
||||||
|
return digest(JSON.stringify([VERSION,kind,values]))
|
||||||
|
}
|
||||||
|
function readIndex() { try { return wx.getStorageSync(INDEX) || {} } catch (_) { return {} } }
|
||||||
|
function ownFile(value) { return typeof value === 'string' && value.startsWith(wx.env.USER_DATA_PATH+'/fulian-share-'+VERSION+'-') && !value.includes('..') }
|
||||||
|
function removeFiles(record) {
|
||||||
|
if (!record) return
|
||||||
|
for(const key of ['poster','cover','qrcode']) if(ownFile(record[key])) wx.getFileSystemManager().unlink({filePath:record[key],fail(){}})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSharingCache(id,kind,profile) {
|
||||||
|
if (!wx.getStorageSync('token')) return null
|
||||||
|
const index=readIndex(),key=identity(id,kind),record=index[key]
|
||||||
|
if (!record || record.version!==VERSION || record.auth!==digest(String(wx.getStorageSync('token'))) || record.savedAt>Date.now() || Date.now()-record.savedAt>MAX_AGE) return null
|
||||||
|
if (profile && record.fingerprint!==sharingFingerprint(profile,kind)) return null
|
||||||
|
if (![record.poster,record.cover,record.qrcode].every(ownFile)) return null
|
||||||
|
const present=await Promise.all([record.poster,record.cover,record.qrcode].map(fileAccess))
|
||||||
|
return present.every(Boolean) ? record : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSharingCache(profile,kind,assets) {
|
||||||
|
const key=identity(profile.id,kind)
|
||||||
|
const prefix=`${wx.env.USER_DATA_PATH}/fulian-share-${VERSION}-${key}-${Date.now()}-${Math.random().toString(16).slice(2,8)}`
|
||||||
|
const paths={poster:prefix+'-poster.png',cover:prefix+'-cover.png',qrcode:prefix+'-qr.png'}
|
||||||
|
try {
|
||||||
|
for (const name of ['poster','cover','qrcode']) {
|
||||||
|
await boundedCall((resolve,reject)=>wx.getFileSystemManager().copyFile({srcPath:assets[name],destPath:paths[name],success:resolve,fail:reject}), '图片缓存失败', 3000)
|
||||||
|
}
|
||||||
|
const index=readIndex(),previous=index[key]
|
||||||
|
const record={...paths,selectedPhoto:profile.photo||'',version:VERSION,fingerprint:sharingFingerprint(profile,kind),auth:digest(String(wx.getStorageSync('token')||'')),savedAt:Date.now()}
|
||||||
|
index[key]=record
|
||||||
|
const expired=Object.keys(index).filter(k=>k!==key).sort((a,b)=>(index[b].savedAt||0)-(index[a].savedAt||0)).slice(3)
|
||||||
|
const discarded=expired.map(k=>index[k]);expired.forEach(k=>delete index[k])
|
||||||
|
wx.setStorageSync(INDEX,index)
|
||||||
|
removeFiles(previous);discarded.forEach(removeFiles)
|
||||||
|
return record
|
||||||
|
} catch(error) { removeFiles(paths); throw error }
|
||||||
|
}
|
||||||
30
src/utils/sharing-photo.js
Normal file
30
src/utils/sharing-photo.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {boundedCall} from './sharing-runtime'
|
||||||
|
|
||||||
|
// Prefer sufficient resolution and minimal crop for the 686 x 470 poster.
|
||||||
|
// This is layout suitability, not face recognition or an appearance rating.
|
||||||
|
export function photoScore(width,height) {
|
||||||
|
if(!Number.isFinite(width)||!Number.isFinite(height)||Math.min(width,height)<400)return -1
|
||||||
|
const ratio=width/height
|
||||||
|
if(ratio<0.6||ratio>2.2)return -1
|
||||||
|
const crop=Math.min(ratio/(686/470),(686/470)/ratio)
|
||||||
|
const resolution=Math.min(1,Math.min(width/686,height/470))
|
||||||
|
return crop*0.8+resolution*0.2
|
||||||
|
}
|
||||||
|
export async function selectSharingPhoto(photos,options={}) {
|
||||||
|
const candidates=[...new Set((photos||[]).filter(url=>typeof url==='string'&&url.trim()))].slice(0,9)
|
||||||
|
const checked=await Promise.all(candidates.map(async url=>{
|
||||||
|
try{
|
||||||
|
const info=await boundedCall((resolve,reject)=>wx.getImageInfo({src:url,success:resolve,fail:reject}),'生活照加载超时',5000)
|
||||||
|
return {url,score:photoScore(info.width,info.height)}
|
||||||
|
}catch(_){return {url,score:-1}}
|
||||||
|
}))
|
||||||
|
const eligible=checked.filter(item=>item.score>=0)
|
||||||
|
if(!eligible.length)return ''
|
||||||
|
if(options.random){
|
||||||
|
const alternatives=eligible.filter(item=>item.url!==options.previous)
|
||||||
|
const pool=alternatives.length?alternatives:eligible
|
||||||
|
return pool[Math.floor(Math.random()*pool.length)].url
|
||||||
|
}
|
||||||
|
if(options.preferred&&eligible.some(item=>item.url===options.preferred))return options.preferred
|
||||||
|
return eligible.sort((a,b)=>b.score-a.score)[0].url
|
||||||
|
}
|
||||||
63
src/utils/sharing-runtime.js
Normal file
63
src/utils/sharing-runtime.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// Bounded, sharing-only I/O. Does not alter the legacy request/login flow.
|
||||||
|
export function boundedCall(register, message, timeout = 12000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false, cancel
|
||||||
|
const finish = (ok, value) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
ok ? resolve(value) : reject(value instanceof Error ? value : new Error(message))
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
finish(false, new Error(message))
|
||||||
|
if (cancel) { try { cancel() } catch (_) {} }
|
||||||
|
}, timeout)
|
||||||
|
try { cancel = register(value => finish(true, value), error => finish(false, error)) }
|
||||||
|
catch (error) { finish(false, error) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sharingRequest(url, method = 'GET', data = {}) {
|
||||||
|
return boundedCall((resolve, reject) => {
|
||||||
|
const task = wx.request({
|
||||||
|
url, method, data, timeout:10000,
|
||||||
|
header:{Authorization:'Bearer ' + (wx.getStorageSync('token') || ''), 'X-Requested-With':'XMLHttpRequest'},
|
||||||
|
success(response) {
|
||||||
|
const body = response.data
|
||||||
|
if (response.statusCode === 200 && body && body.code === 0 && body.data != null) return resolve(body.data)
|
||||||
|
if (response.statusCode === 401 || (body && (body.code === 2 || body.code === 3))) return reject(new Error('登录状态已失效,请重新登录后再试'))
|
||||||
|
if(url.endsWith('/sharing/code')){
|
||||||
|
if(response.statusCode!==200)return reject(new Error(`分享码接口异常(HTTP ${Number(response.statusCode)||0}),请联系客服`))
|
||||||
|
const messages={qr_token_refresh:'分享码凭证更新失败,请稍后重试',qr_upstream_unavailable:'分享码服务暂不可用,请稍后重试',qr_image_read:'分享码图片读取失败,请重试',qr_image_format:'分享码图片格式异常,请联系客服',qr_wechat_rejected:'微信未能生成分享码,请联系客服'}
|
||||||
|
const message=messages[body&&body.error_key]
|
||||||
|
const code=Number(body&&body.upstream_code)
|
||||||
|
if(message)return reject(new Error(message+(Number.isSafeInteger(code)&&code!==0?`(微信码 ${code})`:'')))
|
||||||
|
// Old servers do not have error_key. Only expose known safe messages.
|
||||||
|
const known=['生成分享码失败,请稍后重试','生成分享码失败','分享码格式无效','分享类型无效']
|
||||||
|
if(body&&known.includes(body.message))return reject(new Error(body.message))
|
||||||
|
return reject(new Error('分享码获取失败,请联系客服核查接口版本'))
|
||||||
|
}
|
||||||
|
reject(new Error('暂时无法获取分享信息,请稍后重试'))
|
||||||
|
},
|
||||||
|
fail() { reject(new Error('网络连接失败,请检查网络后重试')) }
|
||||||
|
})
|
||||||
|
return () => task && task.abort && task.abort()
|
||||||
|
}, '请求超时,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileAccess(filePath) {
|
||||||
|
return boundedCall((resolve,reject) => wx.getFileSystemManager().access({path:filePath,success:()=>resolve(true),fail:reject}), '本地图片读取失败', 2000).catch(()=>false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentSharingUser() {
|
||||||
|
if (!wx.getStorageSync('token')) return 0
|
||||||
|
const info = wx.getStorageSync('userInfo') || {}
|
||||||
|
const id = Number(wx.getStorageSync('user_id') || info.id)
|
||||||
|
return Number.isSafeInteger(id) && id > 0 ? id : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayProfile(data) {
|
||||||
|
const id = Number(data.id)
|
||||||
|
if (!Number.isSafeInteger(id) || id <= 0) throw new Error('请先登录后再分享')
|
||||||
|
return {id, nickname:data.nickname || '福恋用户',photo:'',photos:Array.isArray(data.photos)?data.photos.filter(url=>typeof url==='string'):[],details:[data.city,data.degree,data.industry].filter(Boolean).join(' · '),introduction:data.introduction || '',idealMate:data.ideal_mate || ''}
|
||||||
|
}
|
||||||
153
src/utils/sharing.js
Normal file
153
src/utils/sharing.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import {boundedCall} from './sharing-runtime'
|
||||||
|
|
||||||
|
const LOGO='/pages/users/assets/fulian-logo.png'
|
||||||
|
export function sharingPath(id,personal) {
|
||||||
|
const value=Number(id)
|
||||||
|
if (!Number.isSafeInteger(value)||value<=0) throw new Error('请先登录后分享')
|
||||||
|
return personal ? `/pages/home/information?id=${value}` : `/pages/tabBar/welcome?from_user_id=${value}`
|
||||||
|
}
|
||||||
|
export function storeSharingCode(data,kind,id) {
|
||||||
|
const extension=data.mime==='image/png'?'png':data.mime==='image/jpeg'?'jpg':''
|
||||||
|
if (!extension||!data.image) return Promise.reject(new Error('分享码暂不可用,请重试'))
|
||||||
|
const path=`${wx.env.USER_DATA_PATH}/fulian-sharing-qr-${Number(id)}-${kind}.${extension}`
|
||||||
|
return boundedCall((resolve,reject)=>wx.getFileSystemManager().writeFile({filePath:path,data:data.image,encoding:'base64',success:()=>resolve(path),fail:reject}), '分享码保存失败',3000)
|
||||||
|
}
|
||||||
|
export function saveSharingPoster(filePath) {
|
||||||
|
return boundedCall((resolve,reject)=>wx.saveImageToPhotosAlbum({filePath,success:resolve,fail:reject}), '图片未能保存,请检查相册权限',15000)
|
||||||
|
}
|
||||||
|
function check(job) { if (job && job.cancelled) throw new Error('已停止生成,请重试') }
|
||||||
|
function font(ctx,size,weight=400) { ctx.font=`${weight} ${size}px sans-serif` }
|
||||||
|
function roundRect(ctx,x,y,w,h,r,fill) {
|
||||||
|
ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath()
|
||||||
|
if(fill){ctx.fillStyle=fill;ctx.fill()}
|
||||||
|
}
|
||||||
|
function textLines(ctx,text,x,y,width,lineHeight,limit) {
|
||||||
|
let line='',row=0
|
||||||
|
for(const char of Array.from(String(text||'').replace(/\s+/g,' ').trim())){
|
||||||
|
if(ctx.measureText(line+char).width>width){
|
||||||
|
if(row===limit-1){while(line&&ctx.measureText(line+'…').width>width)line=line.slice(0,-1);ctx.fillText(line+'…',x,y+row*lineHeight);return}
|
||||||
|
const carry=/[,。!?;:、)】》」』]/.test(char)?line.slice(-1):''
|
||||||
|
if(carry)line=line.slice(0,-1)
|
||||||
|
ctx.fillText(line,x,y+row*lineHeight);row++;line=carry
|
||||||
|
}
|
||||||
|
line+=char
|
||||||
|
}
|
||||||
|
ctx.fillText(line,x,y+row*lineHeight)
|
||||||
|
}
|
||||||
|
export function photoPlacement(iw,ih,w,h){
|
||||||
|
// Keep the whole source visible: a center cover crop can remove faces,
|
||||||
|
// especially when a portrait/group photo is placed in a landscape slot.
|
||||||
|
const scale=Math.min(w/iw,h/ih)
|
||||||
|
const width=iw*scale,height=ih*scale
|
||||||
|
return {x:(w-width)/2,y:(h-height)/2,width,height}
|
||||||
|
}
|
||||||
|
function photoFill(ctx,image,x,y,w,h,r=0){
|
||||||
|
ctx.save();if(r){roundRect(ctx,x,y,w,h,r);ctx.clip()}
|
||||||
|
ctx.fillStyle='#FFF1F4';ctx.fillRect(x,y,w,h)
|
||||||
|
const fit=photoPlacement(image.width,image.height,w,h)
|
||||||
|
ctx.drawImage(image,0,0,image.width,image.height,x+fit.x,y+fit.y,fit.width,fit.height);ctx.restore()
|
||||||
|
}
|
||||||
|
async function canvasFor(page,id,width,height,job){
|
||||||
|
check(job)
|
||||||
|
const canvas=await boundedCall((resolve,reject)=>{
|
||||||
|
wx.createSelectorQuery().in(page.$wx).select('#'+id).fields({node:true,size:true}).exec(result=>{
|
||||||
|
if(!result||!result[0]||!result[0].node)return reject(new Error('画布尚未就绪,请重试'))
|
||||||
|
resolve(result[0].node)
|
||||||
|
})
|
||||||
|
},'画布准备超时,请重试',3000)
|
||||||
|
check(job);canvas.width=width;canvas.height=height
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
export function canvasImagePath(path,original) {
|
||||||
|
// getImageInfo may remove the leading slash from a package path. Canvas
|
||||||
|
// resolves that value relative to the current page rather than package root.
|
||||||
|
if(typeof path!=='string'||!path)throw new Error('图片路径无效,请重试')
|
||||||
|
if(path.startsWith('pages/')||path.startsWith('images/'))return '/'+path
|
||||||
|
if(original===LOGO&&!/^(\/|[a-z][a-z0-9+.-]*:)/i.test(path))return original
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
async function loadImage(canvas,src,job,label='图片'){
|
||||||
|
const info=await boundedCall((resolve,reject)=>wx.getImageInfo({src,success:resolve,fail:reject}),label+'加载失败,请检查网络后重试',8000)
|
||||||
|
check(job)
|
||||||
|
const path=canvasImagePath(info.path,src)
|
||||||
|
return boundedCall((resolve,reject)=>{
|
||||||
|
const image=canvas.createImage()
|
||||||
|
image.onload=()=>resolve(image);image.onerror=()=>reject(new Error(label+'读取失败,请重试'));image.src=path
|
||||||
|
},label+'读取超时,请重试',5000)
|
||||||
|
}
|
||||||
|
async function exportCanvas(canvas,width,height,job){
|
||||||
|
check(job)
|
||||||
|
return boundedCall((resolve,reject)=>wx.canvasToTempFilePath({canvas,x:0,y:0,width,height,destWidth:width,destHeight:height,fileType:'png',success:value=>resolve(value.tempFilePath),fail:reject}),'海报导出失败,请重试',5000)
|
||||||
|
}
|
||||||
|
function brand(ctx,logo,width=750){
|
||||||
|
ctx.drawImage(logo,40,30,56,56);ctx.fillStyle='#A3294D';font(ctx,30,600);ctx.fillText('福恋',110,70)
|
||||||
|
font(ctx,22);ctx.textAlign='right';ctx.fillText('上福恋认真找对象',width-40,66);ctx.textAlign='left'
|
||||||
|
}
|
||||||
|
function invitationLetter(ctx,logo,x,y,w,h){
|
||||||
|
roundRect(ctx,x+18,y+12,w,h,22,'#EED3DB')
|
||||||
|
roundRect(ctx,x,y,w,h,22,'#FFF9F5')
|
||||||
|
ctx.strokeStyle='#EED3DB';ctx.lineWidth=2
|
||||||
|
ctx.beginPath();ctx.moveTo(x+16,y+16);ctx.lineTo(x+w/2,y+h*0.64);ctx.lineTo(x+w-16,y+16);ctx.stroke()
|
||||||
|
ctx.beginPath();ctx.moveTo(x+16,y+h-16);ctx.lineTo(x+w*.35,y+h*.52);ctx.moveTo(x+w-16,y+h-16);ctx.lineTo(x+w*.65,y+h*.52);ctx.stroke()
|
||||||
|
ctx.fillStyle='#FFF1F4';ctx.beginPath();ctx.arc(x+w/2,y+h*.6,42,0,Math.PI*2);ctx.fill()
|
||||||
|
ctx.drawImage(logo,x+w/2-28,y+h*.6-28,56,56)
|
||||||
|
}
|
||||||
|
export function drawSharingPoster(ctx,images,profile,personal){
|
||||||
|
ctx.fillStyle='#FFF9F5';ctx.fillRect(0,0,750,1120);brand(ctx,images.logo)
|
||||||
|
if(personal){
|
||||||
|
roundRect(ctx,32,116,686,470,28,'#FFF1F4')
|
||||||
|
if(images.photo)photoFill(ctx,images.photo,32,116,686,470,28)
|
||||||
|
else{ctx.drawImage(images.logo,305,250,140,140);ctx.fillStyle='#A3294D';font(ctx,28);ctx.textAlign='center';ctx.fillText('期待认真相识',375,456);ctx.textAlign='left'}
|
||||||
|
ctx.fillStyle='#A3294D';font(ctx,42,600);textLines(ctx,profile.nickname||'我的卡片',44,650,660,50,1)
|
||||||
|
ctx.fillStyle='#97717D';font(ctx,24);textLines(ctx,profile.details,44,696,662,34,1)
|
||||||
|
ctx.fillStyle='#4E4145';font(ctx,28);textLines(ctx,profile.introduction||'很高兴在福恋与你相遇。',44,751,662,40,2)
|
||||||
|
if(profile.idealMate){font(ctx,24);textLines(ctx,'期待的你:'+profile.idealMate,44,845,662,34,1)}
|
||||||
|
ctx.fillStyle='#EED3DB';ctx.fillRect(44,881,662,1)
|
||||||
|
ctx.drawImage(images.qrcode,44,918,148,148);ctx.fillStyle='#A3294D';font(ctx,32,600);ctx.fillText('扫码认识我',224,978)
|
||||||
|
ctx.fillStyle='#97717D';font(ctx,24);ctx.fillText('从一次真诚的了解开始',224,1024)
|
||||||
|
}else{
|
||||||
|
roundRect(ctx,32,120,686,660,30,'#FFF1F4')
|
||||||
|
ctx.fillStyle='#97717D';font(ctx,24);ctx.fillText('一份邀请 · 一次美好相遇',70,184)
|
||||||
|
ctx.fillStyle='#A3294D';font(ctx,58,600);ctx.fillText('把认真相遇',70,282);ctx.fillText('分享给身边的人',70,362)
|
||||||
|
ctx.fillStyle='#4E4145';font(ctx,27);ctx.fillText('邀请朋友加入福恋,开启认真交往。',70,426)
|
||||||
|
invitationLetter(ctx,images.logo,192,474,348,188)
|
||||||
|
ctx.fillStyle='#A3294D';font(ctx,26);textLines(ctx,`${profile.nickname||'一位朋友'} 邀请你加入`,70,720,610,36,1)
|
||||||
|
roundRect(ctx,32,816,686,264,28,'#FFF1F4')
|
||||||
|
ctx.drawImage(images.qrcode,60,852,184,184);ctx.fillStyle='#A3294D';font(ctx,32,600);ctx.fillText('扫码加入福恋',278,920)
|
||||||
|
ctx.fillStyle='#4E4145';font(ctx,26);ctx.fillText('上福恋认真找对象',278,968)
|
||||||
|
ctx.fillStyle='#97717D';font(ctx,22);ctx.fillText('让美好的相遇,从这里开始',278,1010)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function drawSharingCover(ctx,images,profile,personal){
|
||||||
|
ctx.fillStyle='#FFF9F5';ctx.fillRect(0,0,750,600);brand(ctx,images.logo)
|
||||||
|
if(!personal){
|
||||||
|
roundRect(ctx,28,118,694,442,28,'#FFF1F4')
|
||||||
|
ctx.fillStyle='#97717D';font(ctx,23);ctx.fillText('送给认真寻找爱情的你',58,178)
|
||||||
|
ctx.fillStyle='#A3294D';font(ctx,46,600);ctx.fillText('一份邀请',58,260);ctx.fillText('一次美好相遇',58,322)
|
||||||
|
invitationLetter(ctx,images.logo,438,220,222,156)
|
||||||
|
ctx.fillStyle='#4E4145';font(ctx,26);ctx.fillText('加入福恋,让彼此的故事开始',58,412)
|
||||||
|
roundRect(ctx,58,458,250,58,29,'#D92554')
|
||||||
|
ctx.fillStyle='#FFF9F5';font(ctx,25,500);ctx.fillText('点击开启认真相识',82,496)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roundRect(ctx,32,120,686,438,28,'#FFF1F4')
|
||||||
|
if(personal&&images.photo)photoFill(ctx,images.photo,424,148,264,364,20)
|
||||||
|
else{roundRect(ctx,424,148,264,364,20,'#FFF9F5');ctx.drawImage(images.logo,482,256,148,148)}
|
||||||
|
ctx.fillStyle='#A3294D';font(ctx,42,600);textLines(ctx,personal?(profile.nickname||'我的卡片'):'邀请你加入福恋',60,226,personal?330:620,56,2)
|
||||||
|
ctx.fillStyle='#4E4145';font(ctx,27);textLines(ctx,personal?(profile.introduction||'期待与你认真相识'):'把福恋分享给认真寻找爱情的人',60,356,personal?330:620,42,3)
|
||||||
|
ctx.fillStyle='#A3294D';font(ctx,25);ctx.fillText(personal?'点击认识我 →':'点击开启认真相识 →',60,512)
|
||||||
|
}
|
||||||
|
async function render(page,profile,personal,cover,job){
|
||||||
|
const width=750,height=cover?600:1120,canvas=await canvasFor(page,cover?'fulianCover':'fulianPoster',width,height,job)
|
||||||
|
const [logo,photo,qrcode]=await Promise.all([loadImage(canvas,LOGO,job,'品牌图片'),personal&&profile.photo?loadImage(canvas,profile.photo,job,'生活照').catch(()=>null):null,cover?null:loadImage(canvas,profile.qrcode,job,'小程序码')])
|
||||||
|
check(job)
|
||||||
|
const ctx=canvas.getContext('2d');ctx.textBaseline='alphabetic'
|
||||||
|
if(cover)drawSharingCover(ctx,{logo,photo},profile,personal)
|
||||||
|
else drawSharingPoster(ctx,{logo,photo,qrcode},profile,personal)
|
||||||
|
return exportCanvas(canvas,width,height,job)
|
||||||
|
}
|
||||||
|
export function renderSharingPoster(page,profile,personal,job){
|
||||||
|
if(!profile.qrcode)return Promise.reject(new Error('分享码暂未准备好,请重试'))
|
||||||
|
return render(page,profile,personal,false,job)
|
||||||
|
}
|
||||||
|
export function renderSharingCover(page,profile,personal,job){return render(page,profile,personal,true,job)}
|
||||||
34
src/utils/virtual-payment.js
Normal file
34
src/utils/virtual-payment.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// All terminals use virtual payment. Never grant entitlements from a client callback.
|
||||||
|
export function checkVirtualSupport(wxApi) {
|
||||||
|
const info = wxApi.getSystemInfoSync()
|
||||||
|
if (typeof wxApi.requestVirtualPayment !== 'function' || (wxApi.canIUse && !wxApi.canIUse('requestVirtualPayment'))) throw new Error('当前微信版本不支持会员支付,请升级微信')
|
||||||
|
if (/ios/i.test(info.platform || '') || /^iOS/i.test(info.system || '')) {
|
||||||
|
const version = (info.version || '').split('.').map(Number)
|
||||||
|
if ((version[0] || 0) < 8 || (version[0] === 8 && (version[1] || 0) === 0 && (version[2] || 0) < 68) || Number((info.system || '').replace(/^iOS\s*/i, '').split('.')[0]) < 15) throw new Error('请升级至 iOS 15 和微信 8.0.68 或更高版本')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function buyMembership(wxApi, api, id, expectedPriceFen, onCreated) {
|
||||||
|
checkVirtualSupport(wxApi)
|
||||||
|
const status = await api.get('/rank/purchase/status')
|
||||||
|
const product = status.products && status.products.find(p => p.sub_rank_id === id)
|
||||||
|
if (!status.enabled || !product) throw new Error('会员购买暂不可用')
|
||||||
|
if (product.price_fen !== expectedPriceFen) throw new Error('套餐价格已更新,请重新确认')
|
||||||
|
const login = await new Promise((resolve, reject) => wxApi.login({success: resolve, fail: reject}))
|
||||||
|
if (!login.code) throw new Error('微信登录校验失败')
|
||||||
|
const order = await api.post('/rank/buy/virtual', {sub_rank_id: id, code: login.code})
|
||||||
|
const data = JSON.parse(order.sign_data)
|
||||||
|
if (data.goodsPrice !== expectedPriceFen || data.buyQuantity !== 1 || data.productId !== product.product_id || data.outTradeNo !== order.order_no) throw new Error('支付订单与确认信息不一致,请重新下单')
|
||||||
|
onCreated(order.order_no)
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => wxApi.requestVirtualPayment({signData: order.sign_data, paySig: order.paySig, signature: order.signature, mode: order.mode, success: resolve, fail: reject}))
|
||||||
|
} catch (e) {
|
||||||
|
if (/cancel/i.test(e.errMsg || '')) throw new Error('已取消支付,可在订单中查询最终状态')
|
||||||
|
throw new Error('支付未完成,请稍后在订单中查询;如提示支付受限,请联系客服')
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const result = await api.post('/order/virtual/' + encodeURIComponent(order.order_no) + '/confirm', {})
|
||||||
|
if (result.state === 'FULFILLED') return result
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
}
|
||||||
|
throw new Error('支付结果确认中,请稍后查询订单,勿重复购买')
|
||||||
|
}
|
||||||
6
tools/build/README.txt
Normal file
6
tools/build/README.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
正式编译:Node 16.20.2,依赖使用现有锁文件。npm run build 仅编译,不执行上传。
|
||||||
|
tools/build/build.cjs 在 WePY 完成后规范化非法 WXML 单引号转义,避免预览 unexpected character。
|
||||||
|
正式 AppID 经用户确认修改为 wxc41491431733671e;不改变首页商城跳转的其他应用 AppID。
|
||||||
|
正式制品从 main 的 Git 导出构建,不带 mock、local runtime 或测试入口;接口指向现有线上域名。
|
||||||
|
购买仍由服务端开关控制,默认关闭;不能把编译成功视为支付能力开通或生产验证。
|
||||||
|
前后端回滚交接见服务端 tools/release/ROLLBACK.txt。
|
||||||
11
tools/build/build.cjs
Normal file
11
tools/build/build.cjs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
'use strict'
|
||||||
|
// Compile only. Never upload, preview, or run the application.
|
||||||
|
const fs=require('fs'),path=require('path'),cp=require('child_process')
|
||||||
|
const root=path.resolve(__dirname,'../..')
|
||||||
|
const result=cp.spawnSync(process.execPath,[require.resolve('@wepy/cli/bin/wepy.js'),'build','--no-cache'],{cwd:root,env:{...process.env,NODE_ENV:'production'},stdio:'inherit'})
|
||||||
|
if(result.error || result.status!==0)process.exit(result.status || 1)
|
||||||
|
const fix=require('./fix-template-expressions.cjs')
|
||||||
|
let count=0
|
||||||
|
function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(p.endsWith('.wxml')){fs.writeFileSync(p,fix(fs.readFileSync(p,'utf8')));count++}}}
|
||||||
|
walk(path.join(root,'weapp'))
|
||||||
|
console.log('Normalized WXML expressions in '+count+' templates')
|
||||||
17
tools/build/fix-template-expressions.cjs
Normal file
17
tools/build/fix-template-expressions.cjs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
'use strict'
|
||||||
|
const vm = require('vm')
|
||||||
|
// WePY 2 escapes quotes in :class/:style expressions as if they were string
|
||||||
|
// contents. WXML expects an expression here, so those escapes are invalid.
|
||||||
|
// Repair only expressions that fail parsing and become valid after removing
|
||||||
|
// that extra layer. Preserve valid strings (including escaped apostrophes).
|
||||||
|
function valid(expr) {
|
||||||
|
try { new vm.Script('(' + expr + ')'); return true } catch (_) { return false }
|
||||||
|
}
|
||||||
|
module.exports = function fixTemplates(markup) {
|
||||||
|
return markup.replace(/\{\{([\s\S]*?)\}\}/g, (whole, expr) => {
|
||||||
|
if (valid(expr) || !expr.includes("\\'")) return whole
|
||||||
|
const fixed = expr.replace(/\\'/g, "'")
|
||||||
|
if (!valid(fixed)) throw new Error('Unparseable generated WXML expression')
|
||||||
|
return '{{' + fixed + '}}'
|
||||||
|
})
|
||||||
|
}
|
||||||
33
tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt
Normal file
33
tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
会员相关页面本地验收记录(2026-10-30)
|
||||||
|
|
||||||
|
范围:微信开发者工具模拟器,mp-local-api-build;不上传、不发布、不连接真实支付。
|
||||||
|
工具:Node 16.20.2、miniprogram-automator 0.13.0、微信开发者工具 2.02.2609240。
|
||||||
|
|
||||||
|
已核实:
|
||||||
|
1. 会员中心 purchaseEnabled=false;点击购买提示会员购买暂不可用。
|
||||||
|
2. 查询最近会员订单:无待查订单提示;合成 PENDING 保留待查记录;合成 FULFILLED 提示已到账并清除待查记录。
|
||||||
|
3. 未支持的合成订单显示查询失败,不访问真实服务。
|
||||||
|
4. 单项服务页空列表正常展示,点击显示暂不可购买;模拟请求失败显示可读说明。
|
||||||
|
5. 修复单项服务列表为空/索引越界及失败后 loading 未结束问题。
|
||||||
|
6. 修复单项服务关闭提示重叠、背景图片不可用时用户信息对比度不足;iOS 也显示关闭提示。
|
||||||
|
|
||||||
|
AppID:生成验收项目采用 wxc41491431733671e,与本机后端两个小程序 AppID 配置一致且 CLI 权限校验通过。
|
||||||
|
源项目仍为 wxe486777f4379507e(同时出现在商城跳转中),未改生产配置。正式应用身份仍应由管理平台核对。
|
||||||
|
早先 README 中 code 10 阻塞记录已被本次成功验收替代。
|
||||||
|
|
||||||
|
复现(工作目录 E:/pj/love,先加载 .local-acceptance/Enter-Environment.ps1):
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/build.cjs --api --appid=wxc41491431733671e
|
||||||
|
官方 CLI cache --clean compile --project E:/pj/love/.local-acceptance/mp-local-api-build
|
||||||
|
官方 CLI auto --project E:/pj/love/.local-acceptance/mp-local-api-build --auto-port 19420
|
||||||
|
等待模拟器编译完成后:
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/devtools-membership.test.cjs
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/pages.test.cjs
|
||||||
|
node ufutx_love_mp/tools/local-acceptance/payment.test.cjs
|
||||||
|
最终上述构建、页面方法、支付逻辑测试退出 0;两仓库 git diff --check 通过(仅换行符警告)。
|
||||||
|
自动化初次重载存在超时/页面元信息错误;清理项目编译缓存并等待加载后重试成功。
|
||||||
|
|
||||||
|
证据:E:/pj/love/.local-acceptance/logs/membership-closed-click.png、order-empty.png、single-service-closed.png。
|
||||||
|
自动化通过元素 tap 操作页面;最终脚本观察 toast/modal 的参数并自动处理弹窗,截图用于布局检查。
|
||||||
|
订单状态来自专用 LOCAL-ACCEPTANCE-PENDING/FULFILLED mock,不代表平台回调或真实会员发放验收通过。
|
||||||
|
单项服务使用空列表合成数据,没有虚构商品权益或价格。正式商品图片和真实终端效果仍待业务验收。
|
||||||
|
本轮服务端未新增修改;保留两个 feature/virtual-payment-remediation 分支的已有变更,未提交、合并或发布。
|
||||||
10
tools/local-acceptance/Open-DevTools.ps1
Normal file
10
tools/local-acceptance/Open-DevTools.ps1
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
param([string]$CliPath = 'D:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat')
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$workspace = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||||
|
$workspace = Split-Path $workspace -Parent
|
||||||
|
$project = Join-Path $workspace '.local-acceptance\mp-local-api-build'
|
||||||
|
if (!(Test-Path -LiteralPath $CliPath)) { throw 'WeChat DevTools CLI not found; pass -CliPath.' }
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $project 'project.config.json'))) { throw 'Build the local acceptance project first.' }
|
||||||
|
# GPU flag takes effect only when launching a fresh IDE process. No cache deletion.
|
||||||
|
& $CliPath open --disable-gpu --project $project
|
||||||
|
# CLI can return exit 0 even on permission failure; inspect its printed result.
|
||||||
87
tools/local-acceptance/README.txt
Normal file
87
tools/local-acceptance/README.txt
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
最新状态:会员相关页面已完成模拟器验收,详见 MEMBERSHIP-ACCEPTANCE.txt。
|
||||||
|
重建请使用 --api --appid=wxc41491431733671e;以下旧 code 10 记录仅保留作排查历史。
|
||||||
|
|
||||||
|
福恋本地页面验收(2026-10-30)
|
||||||
|
|
||||||
|
空白窗口排查:官方 CLI quit 后以 open --disable-gpu 重启,已确认主进程
|
||||||
|
带禁用 GPU 参数;无项目 open 成功,辅助功能文本可读取完整项目列表。
|
||||||
|
截图仍超时,因此不声称已验证视觉恢复。CLI islogin 为 true,项目 AppID
|
||||||
|
与源配置一致,但普通 open 和 auto 均被 code 10 拒绝,页面尚未执行。
|
||||||
|
可用 Open-DevTools.ps1 打开本地项目;已有进程须先正常退出才应用 GPU 参数。
|
||||||
|
没有删除工具缓存、登录数据或修改权限。CLI 错误也可能退出 0,必须检查输出。
|
||||||
|
|
||||||
|
开发者工具自动化更新:用户授权本地 API 验收使用仓库已有 AppID。
|
||||||
|
重建使用 node .\ufutx_love_mp\tools\local-acceptance\build.cjs --api --source-appid。
|
||||||
|
该选项仅保留源项目 AppID,不改变接口隔离;不带该选项仍生成游客配置。
|
||||||
|
稳定版 2.02.2609240 的 CLI auto 拒绝 touristappid;使用源 AppID 后返回
|
||||||
|
code 10“登录用户不是该小程序的开发者”。需用户登录有权限的账号后继续。
|
||||||
|
重新构建、api.test.cjs、pages.test.cjs 及两仓库 diff --check 已通过;
|
||||||
|
页面渲染和模拟器点击验收仍未完成,数据方法测试不等同于界面验收。
|
||||||
|
|
||||||
|
新增真实本地 API 模式:导入 E:\pj\love\.local-acceptance\mp-local-api-build。
|
||||||
|
重建命令:node .\ufutx_love_mp\tools\local-acceptance\build.cjs --api
|
||||||
|
登录、资料读取/保存、用户状态和 DMA 请求转发到 http://127.0.0.1:18766;其他流程仍为 mock。
|
||||||
|
后端启动及范围见 love/tools/local-acceptance/README.txt。该模式仅适用本机开发者工具。
|
||||||
|
对应集成验证:node .\ufutx_love_mp\tools\local-acceptance\api.test.cjs
|
||||||
|
真实 API 网络失败不降级为模拟成功;纯离线模式仍按下文使用。
|
||||||
|
|
||||||
|
导入目录:E:\pj\love\.local-acceptance\mp-mock-build
|
||||||
|
在微信开发者工具中导入该目录的 project.config.json,使用测试号/游客模式。
|
||||||
|
不要导入原仓库或旧 mp-build 目录运行;它们仍是原有生产接口配置。
|
||||||
|
新包首屏应显示“福恋 · 本地模拟验收”,每页右上角有“本地模拟 · 合成数据”。
|
||||||
|
未出现这些标识则停止运行并核对导入目录。
|
||||||
|
本机未发现微信开发者工具安装记录,因此没有声称通过模拟器渲染或点击验收。
|
||||||
|
|
||||||
|
实现边界
|
||||||
|
这是在小程序 wx.request 边界注入的进程内 mock,不运行 Go 服务,也不伪造私有模块实现。
|
||||||
|
http://127.0.0.1:18765/go/api 是请求标识,由包内适配器处理,没有对应 HTTP 监听进程。
|
||||||
|
页面启动不依赖数据库、Docker、私有模块权限或互联网。
|
||||||
|
登录返回合成用户;API、IM、认证 SDK 均被隔离。真实支付、退款、短信、上传、外部跳转禁用。
|
||||||
|
未知接口明确提示“尚未模拟”,不会回退到原接口或返回假成功。
|
||||||
|
所有存储加 acceptance: 前缀;不使用真实 token,不清理原应用数据。
|
||||||
|
远程图片/字体引用使用本地占位资源;因此该包适合布局和基本交互验收,不能证明正式素材视觉一致。
|
||||||
|
会员价格、权益、认证标准、审批结果和权限规则均待核实,没有据名称生成。
|
||||||
|
|
||||||
|
建议验收
|
||||||
|
1. 从首屏进入首页:查看合成推荐用户、广场,点进用户详情,返回。
|
||||||
|
2. 打开“我的”、消息:查看测试用户,消息/订单为空态,不出现真实消息。
|
||||||
|
3. 首屏“编辑测试资料”:修改自我描述并保存,再打开应保持新内容。
|
||||||
|
4. 首屏“发布测试动态”:只输入文字,发布后到动态页查看;尝试点赞/取消点赞。
|
||||||
|
5. 首屏切换“请求失败场景”,再进入列表查看错误提示;回首屏关闭后重试。
|
||||||
|
6. 首屏“重置合成数据”恢复初始数据。重新编译返回首屏。
|
||||||
|
仅保存在开发者工具本地存储。测试注册验证码为 000000,没有发送短信。
|
||||||
|
照片/视频上传、真实 IM 互通、认证和购买不在可验收流程内;不会模拟支付成功或授予权益。
|
||||||
|
|
||||||
|
重建当前工作区修改
|
||||||
|
PowerShell 工作目录:E:\pj\love
|
||||||
|
. .\.local-acceptance\Enter-Environment.ps1
|
||||||
|
node .\ufutx_love_mp\tools\local-acceptance\build.cjs
|
||||||
|
node .\ufutx_love_mp\tools\local-acceptance\test.cjs
|
||||||
|
node .\ufutx_love_mp\tools\local-acceptance\pages.test.cjs
|
||||||
|
任一命令失败即停止;不要使用旧包掩盖构建失败。
|
||||||
|
构建只覆盖 mp-mock-build/src、weapp 等专用生成文件,依赖复用已禁用安装脚本的独立 node_modules。
|
||||||
|
不修改业务 src、原 project.config.json、依赖锁文件或正常 build 脚本。
|
||||||
|
构建日志:.local-acceptance/logs/mp-mock-build.log;产物 manifest 记录版本与摘要。
|
||||||
|
此包禁止 preview/deploy、上传或发布;它不是服务端回归或上线依据。
|
||||||
|
|
||||||
|
验证范围
|
||||||
|
test.cjs:覆盖登录、分页、资料保存、发布/点赞、状态持久化、
|
||||||
|
错误场景、未知接口拒绝、越权字段不写入、外部 URL 拦截、支付/上传/IM 传输拦截、存储隔离。
|
||||||
|
pages.test.cjs:执行实际页面脚本的数据加载方法,覆盖首页/动态/消息/我的/用户详情/编辑;
|
||||||
|
不是模拟器测试,不执行海报组件、页面渲染或真实 SDK。
|
||||||
|
另检查所有生成 JavaScript 语法、入口顺序、本地验收首页配置。
|
||||||
|
后续接回真实服务端仍需私有模块权限、完整 schema、受限测试配置与独立业务回归。
|
||||||
|
|
||||||
|
本次执行结果
|
||||||
|
Node 16.20.2 / npm 8.19.4,工具均位于工作区 .local-acceptance/tools。
|
||||||
|
build.cjs 首次编译子进程被沙箱限制;获工具授权后编译成功,退出 0。
|
||||||
|
pages.test.cjs 首次因测试桩缺少 systemInfo 失败,补齐测试桩后通过。
|
||||||
|
最终构建、test.cjs、pages.test.cjs 均退出 0;101 个生成 JS 文件语法检查通过。
|
||||||
|
两个仓库 diff --check 通过;服务端无新增修改;小程序新增 tools/local-acceptance。
|
||||||
|
小程序位于 feature/local-acceptance-mock,未提交、推送、合并或发布;保留原有文档修改。
|
||||||
|
|
||||||
|
2026-10-30 预览 WXML 错误修复:
|
||||||
|
导航组件生成模板第 21 行含非法转义引号;根因是旧版 WePY :class/:style 表达式转义。
|
||||||
|
本地构建新增 fix-template-expressions.cjs:仅修复解析失败且去除额外单引号转义后可解析的表达式,保留合法字符串转义。
|
||||||
|
重建 mp-mock-build 完成。templates.test.cjs 使用开发工具自带 wcc.exe 离线编译全部 87 个模板,退出 0;转义回归、102 个 JS 语法检查及隔离测试通过。
|
||||||
|
未执行预览上传;预览码生成结果仍由用户重新点击预览验证。本次未修改业务源码或生产配置。
|
||||||
28
tools/local-acceptance/api.test.cjs
Normal file
28
tools/local-acceptance/api.test.cjs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
'use strict'
|
||||||
|
const assert=require('assert'),http=require('http'),fs=require('fs'),path=require('path'),vm=require('vm')
|
||||||
|
const {install,ORIGIN}=require('./runtime')
|
||||||
|
const store={},transport=[]
|
||||||
|
const wx={getStorageSync:k=>store[k],setStorageSync:(k,v)=>store[k]=v,removeStorageSync:k=>delete store[k],getStorageInfoSync:()=>({keys:Object.keys(store)}),showToast(){},request(options){
|
||||||
|
const url=new URL(options.url);assert.equal(url.origin,'http://127.0.0.1:18766');transport.push(url.pathname)
|
||||||
|
const body=options.data?JSON.stringify(options.data):''
|
||||||
|
const request=http.request(url,{method:options.method||'GET',headers:{...(options.header||{}),'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},res=>{let raw='';res.on('data',data=>raw+=data);res.on('end',()=>{const r={statusCode:res.statusCode,data:JSON.parse(raw),errMsg:'request:ok'};if(options.success)options.success(r);if(options.complete)options.complete(r)})})
|
||||||
|
request.setTimeout(8000,()=>request.destroy(Error('Local timeout')));request.on('error',e=>{if(options.fail)options.fail(e)});request.end(body);return {abort:()=>request.destroy()}
|
||||||
|
}}
|
||||||
|
const local=install(wx,{apiOrigin:'http://127.0.0.1:18766'})
|
||||||
|
const request=(route,method='GET',data)=>new Promise((resolve,reject)=>wx.request({url:ORIGIN+route,method,data,header:{Authorization:'Bearer '+(wx.getStorageSync('token')||'')},success:r=>resolve(r.data),fail:reject}))
|
||||||
|
async function main(){
|
||||||
|
let r=await request('/login','POST',{code:'LOCAL-MOCK-CODE'});assert.equal(r.code,0);wx.setStorageSync('token',r.data.token)
|
||||||
|
r=await request('/user/profile');assert.equal(r.code,0);const previous=r.data.introduction
|
||||||
|
try{assert.equal((await request('/user/other/profie','PUT',{introduction:'LOCAL_BRIDGE_CHECK'})).code,0);assert.equal((await request('/user/profile')).data.introduction,'LOCAL_BRIDGE_CHECK')}
|
||||||
|
finally{await request('/user/other/profie','PUT',{introduction:previous})}
|
||||||
|
assert.equal((await request('/user/status')).data.is_base_profile,true)
|
||||||
|
assert.equal((await request('/dma/carousel/list')).code,0)
|
||||||
|
const before=transport.length;assert.equal((await request('/moment/list')).code,0);assert.equal((await request('/order/refund','POST',{})).code,1);assert.equal(transport.length,before)
|
||||||
|
local.scenario('failure',true);assert.equal((await request('/user/profile')).code,1);assert.equal(transport.length,before);local.scenario('failure',false)
|
||||||
|
const output=path.resolve(__dirname,'../../../.local-acceptance/mp-local-api-build/weapp')
|
||||||
|
let files=0;function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(p.endsWith('.js')){new vm.Script(fs.readFileSync(p,'utf8'),{filename:p});files++}}}walk(output)
|
||||||
|
const app=fs.readFileSync(path.join(output,'app.js'),'utf8');assert(app.startsWith("require('./acceptance-runtime').install(wx, {apiOrigin:'http://127.0.0.1:18766'});"))
|
||||||
|
assert.equal(fs.readFileSync(path.join(output,'acceptance-runtime.js'),'utf8'),fs.readFileSync(path.join(__dirname,'runtime.js'),'utf8'))
|
||||||
|
console.log('PASS: mini-program adapter -> local gateway -> actual Go API -> MySQL read/write/read-back; mock-only and blocked routes never use transport; '+files+' generated JS files parse.')
|
||||||
|
}
|
||||||
|
main().catch(error=>{console.error('FAIL: '+error.message);process.exitCode=1})
|
||||||
61
tools/local-acceptance/build.cjs
Normal file
61
tools/local-acceptance/build.cjs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use strict'
|
||||||
|
const fs=require('fs'), path=require('path'), cp=require('child_process')
|
||||||
|
const repo=path.resolve(__dirname,'../..'), workspace=path.dirname(repo)
|
||||||
|
const apiMode=process.argv.includes('--api')
|
||||||
|
const appidArg=process.argv.find(arg=>arg.startsWith('--appid='))
|
||||||
|
const localAppid=appidArg && appidArg.slice(8)
|
||||||
|
if(appidArg && !/^wx[0-9a-f]{16}$/.test(localAppid)) throw Error('Invalid local AppID')
|
||||||
|
const root=path.join(workspace,'.local-acceptance'), build=path.join(root,apiMode?'mp-local-api-build':'mp-mock-build')
|
||||||
|
const modules=path.join(root,'mp-build','node_modules')
|
||||||
|
if(!fs.existsSync(path.join(modules,'@wepy/cli/bin/wepy.js'))) throw Error('Run the isolated dependency installation first')
|
||||||
|
fs.mkdirSync(build,{recursive:true})
|
||||||
|
// Only remove this tool's generated directories, never source or another dependency tree.
|
||||||
|
for(const name of ['src','weapp']) {const target=path.resolve(build,name);if(!target.startsWith(build+path.sep))throw Error('Unsafe target');fs.rmSync(target,{recursive:true,force:true})}
|
||||||
|
fs.cpSync(path.join(repo,'src'),path.join(build,'src'),{recursive:true})
|
||||||
|
for(const name of ['package.json','package-lock.json','wepy.config.js'])fs.copyFileSync(path.join(repo,name),path.join(build,name))
|
||||||
|
if(!fs.existsSync(path.join(build,'node_modules')))fs.symlinkSync(modules,path.join(build,'node_modules'),'junction')
|
||||||
|
function walk(dir,fn){for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,entry.name);if(entry.isDirectory())walk(p,fn);else fn(p)}}
|
||||||
|
// Static media is local. No remote pictures, fonts, embedded web pages or SDK destinations remain.
|
||||||
|
const urlPattern=/(?:https?|wss?):\/\/[^\s"'`<>\\)]+/gi
|
||||||
|
let replaced=0
|
||||||
|
walk(path.join(build,'src'),p=>{if(!/\.(js|wpy|wxss|less|json|wxml)$/.test(p))return;let text=fs.readFileSync(p,'utf8');text=text.replace(urlPattern,()=>{replaced++;return '/images/tabBar/my.png'});text=text.replace(/<web-view\b[^>]*>(?:<\/web-view>)?/g,'<view>本地模拟:外部网页已禁用</view>');fs.writeFileSync(p,text)})
|
||||||
|
// Replace just the two host assignments in the copied config, with a hard assertion on source shape.
|
||||||
|
const config=path.join(build,'src/config.js');let text=fs.readFileSync(config,'utf8');let hosts=0
|
||||||
|
text=text.replace(/^(host|hostGo) = '[^']*'.*$/gm,(_,name)=>{hosts++;return name+" = 'http://127.0.0.1:18765/go/api'"})
|
||||||
|
if(hosts!==2)throw Error('Expected exactly two active API host assignments')
|
||||||
|
fs.writeFileSync(config,text)
|
||||||
|
// The legacy error branch throws after rejecting. In the acceptance copy, unsupported mocks
|
||||||
|
// should show the existing modal and reject normally, not crash the simulator callback.
|
||||||
|
const requests=path.join(build,'src/mixins/https.js')
|
||||||
|
fs.writeFileSync(requests,fs.readFileSync(requests,'utf8').replace(/throw errorMessage/g,'return'))
|
||||||
|
fs.copyFileSync(path.join(__dirname,'im.js'),path.join(build,'src/utils/im.js'))
|
||||||
|
fs.writeFileSync(path.join(build,'src/mp_ecard_sdk/main.js'),"export function initEid() {}\nexport function startEid() { wx.showModal({title:'本地模拟',content:'实名认证未执行,认证状态不会改变',showCancel:false}) }\n")
|
||||||
|
const result=cp.spawnSync(process.execPath,[path.join(modules,'@wepy/cli/bin/wepy.js'),'build','--no-cache'],{cwd:build,env:{...process.env,NODE_ENV:'production'},encoding:'utf8',maxBuffer:20*1024*1024})
|
||||||
|
fs.mkdirSync(path.join(root,'logs'),{recursive:true});fs.writeFileSync(path.join(root,'logs/mp-mock-build.log'),(result.stdout||'')+(result.stderr||''))
|
||||||
|
if(result.status!==0)throw Error('WePY build failed: '+(result.error?result.error.code:result.status)+'; inspect local log (redact before sharing)')
|
||||||
|
const output=path.join(build,'weapp')
|
||||||
|
const fixTemplates=require('./fix-template-expressions.cjs')
|
||||||
|
walk(output,p=>{if(p.endsWith('.wxml'))fs.writeFileSync(p,fixTemplates(fs.readFileSync(p,'utf8')))})
|
||||||
|
fs.copyFileSync(path.join(__dirname,'runtime.js'),path.join(output,'acceptance-runtime.js'))
|
||||||
|
const appFile=path.join(output,'app.js');fs.writeFileSync(appFile,"require('./acceptance-runtime').install(wx"+(apiMode?", {apiOrigin:'http://127.0.0.1:18766'}":"")+");\n"+fs.readFileSync(appFile,'utf8'))
|
||||||
|
const appPath=path.join(output,'app.json');const app=JSON.parse(fs.readFileSync(appPath));app.pages.unshift('local-acceptance/index');app.window.navigationBarTitleText='本地模拟验收';fs.writeFileSync(appPath,JSON.stringify(app,null,2))
|
||||||
|
const panel=path.join(output,'local-acceptance');fs.mkdirSync(panel,{recursive:true})
|
||||||
|
fs.writeFileSync(path.join(panel,'index.json'),JSON.stringify({navigationBarTitleText:'本地模拟验收'}))
|
||||||
|
fs.writeFileSync(path.join(panel,'index.wxml'),'<view style="padding:40rpx"><view style="font-size:40rpx;font-weight:bold">福恋 · 本地模拟验收</view><view style="margin:30rpx 0">合成数据,不访问后端或第三方服务。远程图片已替换,外部字体可能与正式版不同。支付、认证、上传不属于本轮可验收范围。</view><button bindtap="enter">进入首页</button><button bindtap="membership">会员购买关闭验收</button><button bindtap="edit">编辑测试资料</button><button bindtap="publish">发布测试动态</button><button bindtap="reset">重置合成数据</button><button bindtap="failure">切换请求失败场景:{{failed}}</button><view>重新编译回到此入口。测试注册验证码:000000。不要输入真实个人资料。</view></view>')
|
||||||
|
fs.writeFileSync(path.join(panel,'index.js'),`Page({data:{failed:false},onShow(){this.setData({failed:wx.__loveAcceptance.snapshot().failure})},enter(){wx.switchTab({url:'/pages/tabBar/home'})},membership(){wx.navigateTo({url:'/pages/users/upgradeVIP'})},edit(){wx.navigateTo({url:'/pages/users/selfTextarea?text=自我描述'})},publish(){wx.navigateTo({url:'/pages/dynamic/issue'})},reset(){wx.__loveAcceptance.reset();wx.reLaunch({url:'/local-acceptance/index'})},failure(){wx.__loveAcceptance.scenario('failure',!this.data.failed);this.onShow()}})`)
|
||||||
|
// Permanent visual marker on every generated page, including subpackages.
|
||||||
|
walk(output,p=>{if(p.endsWith('.wxml')){let markup=fs.readFileSync(p,'utf8').replace(/open-type="[^"]*"/g,'disabled="true" data-local-disabled="true"');fs.writeFileSync(p,markup+'\n<view style="position:fixed;right:8rpx;top:8rpx;z-index:2147483647;background:#fff1b8;color:#613400;padding:4rpx 12rpx;font-size:20rpx;pointer-events:none">本地模拟 · 合成数据</view>\n')}})
|
||||||
|
const project=JSON.parse(fs.readFileSync(path.join(repo,'project.config.json')))
|
||||||
|
if(!process.argv.includes('--source-appid')) project.appid='touristappid';if(localAppid)project.appid=localAppid;project.projectname=apiMode?'福恋-本地API验收':'福恋-本地模拟验收';project.setting={...project.setting,urlCheck:!apiMode};delete project.packOptions
|
||||||
|
fs.writeFileSync(path.join(build,'project.config.json'),JSON.stringify(project,null,2))
|
||||||
|
const testPackage=JSON.parse(fs.readFileSync(path.join(build,'package.json')))
|
||||||
|
testPackage.private=true
|
||||||
|
testPackage.scripts={build:'node ../../ufutx_love_mp/tools/local-acceptance/build.cjs'+(apiMode?' --api':'')+(process.argv.includes('--source-appid')?' --source-appid':'')+(localAppid?' --appid='+localAppid:''),test:'node ../../ufutx_love_mp/tools/local-acceptance/test.cjs'}
|
||||||
|
fs.writeFileSync(path.join(build,'package.json'),JSON.stringify(testPackage,null,2))
|
||||||
|
// Built vendor code can contain dormant SDK URLs. Scrub them too; runtime denies all native transport.
|
||||||
|
walk(output,p=>{if(!/\.(js|wxml|wxss|json)$/.test(p)||p.endsWith('acceptance-runtime.js'))return;let t=fs.readFileSync(p,'utf8');t=t.replace(urlPattern,url=>url.startsWith('http://127.0.0.1:18765/')||url==='http://127.0.0.1:18766'?url:'/images/tabBar/my.png');fs.writeFileSync(p,t)})
|
||||||
|
if(apiMode){const p=path.join(panel,'index.wxml');fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace('福恋 · 本地模拟验收','福恋 · 本地 API 验收').replace('合成数据,不访问后端或第三方服务。','登录、资料读取/保存与 DMA 走本机真实 Go API;其他流程仍为 mock。').replace('重置合成数据','重置 mock 数据(不重置数据库)'))}
|
||||||
|
fs.writeFileSync(path.join(build,'ACCEPTANCE-ONLY.txt'),'LOCAL MOCK ONLY. Never upload or publish this project. Generated from the current working tree. See repository tools/local-acceptance/README.txt.\n')
|
||||||
|
const crypto=require('crypto')
|
||||||
|
fs.writeFileSync(path.join(build,'acceptance-manifest.json'),JSON.stringify({builtAt:new Date().toISOString(),node:process.version,mode:apiMode?'local-api-with-mock-fallbacks':'offline-in-process-mock',source:repo,privateGoModulesUsed:apiMode,lockSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(repo,'package-lock.json'))).digest('hex'),runtimeSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(__dirname,'runtime.js'))).digest('hex')},null,2))
|
||||||
|
console.log(JSON.stringify({build,staticUrlsReplaced:replaced,mode:apiMode?'local-api-with-mock-fallbacks':'offline-in-process-mock',privateGoModulesUsed:apiMode}))
|
||||||
34
tools/local-acceptance/devtools-membership.test.cjs
Normal file
34
tools/local-acceptance/devtools-membership.test.cjs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
const path=require('path'),assert=require('assert');
|
||||||
|
const root=path.resolve(__dirname,'../../../.local-acceptance');
|
||||||
|
const auto=require(path.join(root,'automation/node_modules/miniprogram-automator'));
|
||||||
|
const timer=setTimeout(()=>{console.error('Timeout');process.exit(2)},55000);
|
||||||
|
(async()=>{const mp=await auto.connect({wsEndpoint:'ws://127.0.0.1:19420'});try{
|
||||||
|
await mp.evaluate(()=>{wx.__acceptanceMessages=[];if(!wx.__savedToast)wx.__savedToast=wx.showToast;if(!wx.__savedModal)wx.__savedModal=wx.showModal;wx.showToast=function(o){wx.__acceptanceMessages.push(o.title);return undefined};wx.showModal=function(o){wx.__acceptanceMessages.push(o.content);if(o.success)o.success({confirm:true,cancel:false})}});
|
||||||
|
let p=await mp.navigateTo('/pages/users/upgradeVIP');await p.waitFor(1200);
|
||||||
|
assert.strictEqual(await p.data('purchaseEnabled'),false);await (await p.$('.payBtn')).tap();await p.waitFor(250);
|
||||||
|
await mp.screenshot({path:path.join(root,'logs/membership-closed-click.png')});
|
||||||
|
await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};wx.removeStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'))});
|
||||||
|
await (await p.$('.recent-member-order')).tap();await p.waitFor(250);
|
||||||
|
await mp.screenshot({path:path.join(root,'logs/order-empty.png')});
|
||||||
|
for(const state of ['PENDING','FULFILLED']){
|
||||||
|
await mp.evaluate(s=>{const u=wx.getStorageSync('userInfo')||{};wx.setStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'),'LOCAL-ACCEPTANCE-'+s)},state);
|
||||||
|
await (await p.$('.recent-member-order')).tap();await p.waitFor(500);
|
||||||
|
const stored=await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};return wx.getStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'))});
|
||||||
|
assert.equal(!!stored,state==='PENDING');
|
||||||
|
}
|
||||||
|
await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};wx.setStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'),'LOCAL-ACCEPTANCE-UNKNOWN')});
|
||||||
|
await (await p.$('.recent-member-order')).tap();await p.waitFor(500);
|
||||||
|
await mp.evaluate(()=>{const u=wx.getStorageSync('userInfo')||{};wx.removeStorageSync('virtual-member-order:'+(u.id||u.user_id||'anonymous'))});
|
||||||
|
p=await mp.navigateTo('/pages/users/upgradeVIP2');await p.waitFor(1000);
|
||||||
|
assert.equal((await p.data('rankList')).length,0);assert.match(await p.data('specialVIP.discount_desc'),/暂无/);
|
||||||
|
const b=await p.$('.payBtn');assert(b,'Single-service closed button missing');await b.tap();await p.waitFor(250);
|
||||||
|
await mp.screenshot({path:path.join(root,'logs/single-service-closed.png')});
|
||||||
|
await mp.evaluate(()=>wx.__loveAcceptance.scenario('failure',true));
|
||||||
|
const failed=await mp.navigateTo('/pages/users/upgradeVIP2');await failed.waitFor(800);
|
||||||
|
assert.match(await failed.data('specialVIP.discount_desc'),/加载失败/);
|
||||||
|
await mp.evaluate(()=>wx.__loveAcceptance.scenario('failure',false));
|
||||||
|
await mp.navigateBack();
|
||||||
|
const messages=await mp.evaluate(()=>wx.__acceptanceMessages);
|
||||||
|
for(const expected of ['会员购买暂不可用','暂无待查询订单','支付结果仍在确认中','会员权益已到账','单项服务暂不可购买'])assert(messages.some(s=>s.includes(expected)),expected);
|
||||||
|
console.log(JSON.stringify({passed:true,messages:[...new Set(messages)]}));
|
||||||
|
}finally{await mp.evaluate(()=>{wx.__loveAcceptance.scenario('failure',false);if(wx.__savedToast)wx.showToast=wx.__savedToast;if(wx.__savedModal)wx.showModal=wx.__savedModal});await mp.disconnect();clearTimeout(timer)}})().catch(e=>{console.error(e.message);clearTimeout(timer);process.exitCode=1});
|
||||||
2
tools/local-acceptance/fix-template-expressions.cjs
Normal file
2
tools/local-acceptance/fix-template-expressions.cjs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
'use strict'
|
||||||
|
module.exports = require('../build/fix-template-expressions.cjs')
|
||||||
20
tools/local-acceptance/friend-eligibility.test.cjs
Normal file
20
tools/local-acceptance/friend-eligibility.test.cjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const fs=require('fs'),vm=require('vm'),assert=require('assert');
|
||||||
|
const source=fs.readFileSync('ufutx_love_mp/src/pages/home/information.wpy','utf8').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm,'');
|
||||||
|
let options,modal,sheet,navigations=[];
|
||||||
|
const wx={getStorageSync:()=> 'synthetic-token',showModal:o=>{modal=o},showActionSheet:o=>{sheet=o},navigateTo:o=>navigations.push(o.url)};
|
||||||
|
vm.runInNewContext(source,{wepy:{page:o=>{options=o}},https:{},base:{},service:{host:'https://synthetic.invalid'},wx,console});
|
||||||
|
const allow={allowed:true,action:'allow',remaining:1,unlimited:false,required:['education']};
|
||||||
|
function page(statuses){let gets=0,posts=0,greetings=0;return Object.assign({},options.methods,{id:123,message:'synthetic',throttle:true,modalName:'',checkingFriendEligibility:false,$signInVerify:()=>true,$showToast:()=>{},$get:async()=>({code:0,data:statuses[Math.min(gets++,statuses.length-1)]}),$post:async()=>{posts++;return {code:0}},getGreet:()=>{greetings++},counts:()=>({gets,posts,greetings})})}
|
||||||
|
(async()=>{
|
||||||
|
for(const [key,url] of Object.entries({real:'/pages/users/realName',photo:'/pages/users/unmarriV2?progressShow=false',profile:'/pages/users/unmarriV2?progressShow=false',education:'/pages/users/degreeCertificate'})) {
|
||||||
|
const p=page([{action:'tasks',required:[key],allowed:false,remaining:0}]);await p.contactFriendFn();assert.equal(p.counts().greetings,0);assert(!modal.content.includes('升级会员'));modal.success({confirm:false});assert.equal(navigations.length,0);modal.success({confirm:true});sheet.success({tapIndex:0});assert.equal(navigations.pop(),url);await p.sendMsg();assert.equal(p.counts().posts,0);
|
||||||
|
}
|
||||||
|
let p=page([{action:'tasks',required:['real','photo','profile','education'],allowed:false}]);await p.contactFriendFn();modal.success({confirm:true});assert.equal(sheet.itemList.length,4);
|
||||||
|
p=page([{action:'purchase',required:[],remaining:0,allowed:false}]);await p.contactFriendFn();assert.equal(navigations.length,0);assert(modal.content.includes('无限次'));modal.success({confirm:true});assert.equal(navigations.pop(),'/pages/users/upgradeVIP');
|
||||||
|
p=page([allow]);await p.contactFriendFn();assert.equal(p.counts().greetings,1);
|
||||||
|
p=page([allow,{action:'tasks',required:['photo'],remaining:0,allowed:false}]);await p.sendMsg();await new Promise(setImmediate);assert.equal(p.counts().posts,1);assert(modal.content.includes('真人头像'));assert.equal(navigations.length,0);
|
||||||
|
p=page([allow,{action:'purchase',required:[],remaining:0,allowed:false}]);await p.sendMsg();await new Promise(setImmediate);assert.equal(p.counts().posts,1);assert.equal(modal.confirmText,'升级会员');
|
||||||
|
p=page([{allowed:true,unlimited:true,remaining:-1,action:'allow'}]);await p.sendMsg();await new Promise(setImmediate);assert.equal(navigations.length,0);
|
||||||
|
p=page([]);p.$get=async()=>{throw Error('synthetic offline')};await p.sendMsg();assert.equal(p.counts().posts,0);assert(p.throttle);
|
||||||
|
console.log('PASS task choice, four independent rewards, exhausted guidance, membership confirmation, post-send refresh, member bypass, network failure; no real requests');
|
||||||
|
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||||
13
tools/local-acceptance/im.js
Normal file
13
tools/local-acceptance/im.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Local-only SDK substitute. Never imports the real IM SDK.
|
||||||
|
export function IM() {
|
||||||
|
const send = options => {
|
||||||
|
const msg = {idClient: 'local-'+Date.now(), time: Date.now(), from: '900000001', to: options.to, body: options.body || '', type: 'text', scene: options.scene}
|
||||||
|
if (options.onSendBefore) options.onSendBefore(msg)
|
||||||
|
return Promise.resolve(msg)
|
||||||
|
}
|
||||||
|
const unsupported = () => Promise.reject(new Error('本地模拟:媒体上传已禁用'))
|
||||||
|
return {on(){},off(){},connect(){return Promise.resolve()},disconnect(){return Promise.resolve()},
|
||||||
|
msg: {sendTextMsg:send,sendImageMsg:unsupported,sendVideoMsg:unsupported,sendAudioMsg:unsupported,sendMsgReceipt:()=>Promise.resolve()},
|
||||||
|
session:{resetSessionUnreadCount:()=>Promise.resolve(),getSession:()=>Promise.resolve({unread:0})},
|
||||||
|
user:{getUsersNameCardFromServer:()=>Promise.resolve([])},team:{updateMyMemberInfo:()=>Promise.resolve()}}
|
||||||
|
}
|
||||||
12
tools/local-acceptance/membership-plans.test.cjs
Normal file
12
tools/local-acceptance/membership-plans.test.cjs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const fs=require('fs'),vm=require('vm'),assert=require('assert');
|
||||||
|
const helpers={};vm.createContext(helpers);vm.runInContext(fs.readFileSync('ufutx_love_mp/src/utils/membership-plans.js','utf8').replace(/export /g,''),helpers);
|
||||||
|
const rows=[{id:12,month:12,name:'年卡',discount_price:388},{id:15,month:0,name:'隐藏测试'},{id:10,month:1,name:'月卡',discount_price:78},{id:11,month:3,name:'季卡',discount_price:108}];
|
||||||
|
const plans=helpers.membershipPlans(rows);assert.equal(plans.length,3);assert.equal(plans[helpers.defaultPlanIndex(plans)].id,11);assert.equal(plans[0].id,10);assert.equal(plans[2].id,12);
|
||||||
|
assert.equal(helpers.planPrice(plans[1],[]),'108');assert.equal(helpers.planPrice(plans[1],[{sub_rank_id:11,price_fen:10999}]),'109.99');assert.equal(helpers.planPrice({id:99},[]),'待确认');assert.equal(helpers.defaultPlanIndex([]),0);
|
||||||
|
let options;const wx={getStorageSync:()=>''};const source=fs.readFileSync('ufutx_love_mp/src/pages/users/upgradeVIP.wpy','utf8').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm,'');
|
||||||
|
vm.runInNewContext(source,{...helpers,membershipPlans:helpers.membershipPlans,planPrice:helpers.planPrice,defaultPlanIndex:helpers.defaultPlanIndex,wepy:{page:o=>options=o},https:{},base:{},service:{},buyMembership:()=>{},wx,getApp:()=>({$wepy:{$options:{}}}),setTimeout:()=>0,clearTimeout:()=>{}});
|
||||||
|
const p=Object.assign({},options.methods,{rankList:plans,cardCur:1,payData:{},virtualProducts:[],purchasing:false});p.syncSelectedPlan();assert.equal(p.rankList[1].position,'is-selected');assert.equal(p.rankList[0].position,'is-left');assert.equal(p.rankList[2].position,'is-right');assert.equal(p.payData.discount_price,'108');
|
||||||
|
p.selectPlan(0);assert.equal(p.shareId,10);assert.equal(p.rankList[0].position,'is-selected');
|
||||||
|
p.planTouchStart({touches:[{clientX:200,clientY:20}]});p.planTouchEnd({changedTouches:[{clientX:100,clientY:25}]});assert.equal(p.cardCur,1);assert.equal(p.shareId,11);
|
||||||
|
p.planTouchStart({touches:[{clientX:100,clientY:20}]});p.planTouchEnd({changedTouches:[{clientX:103,clientY:120}]});assert.equal(p.cardCur,1);
|
||||||
|
console.log('PASS quarter default, duration order, live prices, unknown price, selection, swipe and vertical-scroll guard');
|
||||||
43
tools/local-acceptance/pages.test.cjs
Normal file
43
tools/local-acceptance/pages.test.cjs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
'use strict'
|
||||||
|
// Executes actual page data-loading methods with the local contract. No UI renderer or app startup.
|
||||||
|
const fs=require('fs'),path=require('path'),vm=require('vm'),assert=require('assert')
|
||||||
|
const repo=path.resolve(__dirname,'../..'), modules=path.resolve(repo,'../.local-acceptance/mp-build/node_modules')
|
||||||
|
const babel=require(path.join(modules,'@babel/core'))
|
||||||
|
const {createMock,ORIGIN}=require('./runtime');const cache={};const mock=createMock({get:k=>cache[k],set:(k,v)=>cache[k]=v})
|
||||||
|
const user=mock.request(ORIGIN+'/login','POST',{}).data
|
||||||
|
const app={$wepy:{$options:{globalData:{systemInfo:{system:'Windows 10'}}}}}
|
||||||
|
const storage={userInfo:{...user.user,is_base_profile:true,name:user.user.nickname},adv:user.adv}
|
||||||
|
const wx=new Proxy({getStorageSync:k=>storage[k],setStorageSync:(k,v)=>storage[k]=v},{get:(o,k)=>o[k]||(()=>{})})
|
||||||
|
const errors=[]
|
||||||
|
function page(file){
|
||||||
|
let definition
|
||||||
|
const content=fs.readFileSync(path.join(repo,'src',file),'utf8').match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
|
||||||
|
const code=babel.transformSync(content,{babelrc:false,configFile:false,plugins:[require(path.join(modules,'@babel/plugin-transform-modules-commonjs'))]}).code
|
||||||
|
const requireMock=id=>{
|
||||||
|
if(id==='@wepy/core')return {page:opts=>definition=opts}
|
||||||
|
if(id.includes('config'))return {service:{host:ORIGIN,user:ORIGIN+'/user'}}
|
||||||
|
if(id.includes('plugins'))return {commentTimeHandle:s=>s,formatRemainTimeData:()=>({d:0,h:0,m:0,s:0})}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
vm.runInNewContext(code,{require:requireMock,exports:{},module:{exports:{}},wx,getApp:()=>app,console:{log(...args){if(args.some(x=>x&&x.stack))errors.push('caught page exception')},error(){errors.push('page error')}},setTimeout(){},clearTimeout(){},setInterval(){},clearInterval(){}})
|
||||||
|
const instance=Object.assign({},JSON.parse(JSON.stringify(definition.data||{})),{$app:app.$wepy,$nextTick(){},$showLoading(){},$showToast(){},$signInVerify:()=>true})
|
||||||
|
for(const [key,fn] of Object.entries(definition.methods||{}))instance[key]=fn.bind(instance)
|
||||||
|
for(const [key,method] of [['$get','GET'],['$post','POST'],['$put','PUT'],['$delete','DELETE']])instance[key]=({url,data})=>{const r=mock.request(url,method,data);if(r.code!==0){errors.push('unsupported page request: '+url.slice(ORIGIN.length));return Promise.reject(Error(r.message))}return Promise.resolve(r)}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
const tick=()=>new Promise(resolve=>setImmediate(resolve))
|
||||||
|
async function run(){
|
||||||
|
const home=page('pages/tabBar/home.wpy');for(const name of ['getTopUser','getArea','getEveryday','getSquare','applyTopUpState','getBannerList']){home[name]();await tick()}
|
||||||
|
assert.equal(home.topList.length,3);assert.equal(home.squareList.length,3);assert.equal(home.referrals.length,4)
|
||||||
|
const dynamic=page('pages/tabBar/dynamic.wpy');dynamic.getList();dynamic.getHotTopics();await tick();assert.equal(dynamic.dynamicList.length,1)
|
||||||
|
const news=page('pages/tabBar/news.wpy');news.getList();news.getNewCount();await tick();assert.equal(news.menuList[0].count,0)
|
||||||
|
const mine=page('pages/tabBar/user.wpy');mine.getIsBaseInfo();mine.initPageData();await tick();assert.equal(mine.user.id,900000001)
|
||||||
|
const other=page('pages/home/information.wpy');other.id=900000002;other.getDetail();await tick()
|
||||||
|
const editor=page('pages/users/selfTextarea.wpy');editor.text='自我描述';editor.getuser();await tick();assert.equal(editor.value,mock.snapshot().profile.introduction)
|
||||||
|
const service=page('pages/users/upgradeVIP2.wpy');service.initPageData();await tick();assert.equal(service.rankList.length,0);assert.match(service.specialVIP.discount_desc,/暂无可展示/)
|
||||||
|
for(const state of ['PENDING','FULFILLED'])assert.equal(mock.request(ORIGIN+'/order/virtual/LOCAL-ACCEPTANCE-'+state+'/confirm','POST',{}).data.state,state)
|
||||||
|
assert.notEqual(mock.request(ORIGIN+'/order/virtual/REAL-ORDER/confirm','POST',{}).code,0)
|
||||||
|
assert.deepEqual(errors,[])
|
||||||
|
console.log('PASS: actual home/dynamic/news/user/detail/editor data methods consume local mock contracts without recorded exceptions. Rendering not tested.')
|
||||||
|
}
|
||||||
|
run().catch(e=>{console.error(e);process.exitCode=1})
|
||||||
24
tools/local-acceptance/payment.test.cjs
Normal file
24
tools/local-acceptance/payment.test.cjs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
'use strict'
|
||||||
|
const fs=require('fs'),vm=require('vm'),assert=require('assert')
|
||||||
|
const source=fs.readFileSync('ufutx_love_mp/src/utils/virtual-payment.js','utf8').replace(/export /g,'')
|
||||||
|
const context={setTimeout:fn=>fn(),Promise,Error};vm.createContext(context);vm.runInContext(source+';this.buy=buyMembership;',context)
|
||||||
|
let payments=0,posts=[],state='FULFILLED',enabled=true,mismatch=false,cancel=false
|
||||||
|
const wx={getSystemInfoSync:()=>({platform:'android',system:'Android 14',version:'8.0.70'}),canIUse:()=>true,login:o=>o.success({code:'synthetic-code'}),requestVirtualPayment:o=>{payments++;cancel?o.fail({errMsg:'requestVirtualPayment:fail cancel'}):o.success({})}}
|
||||||
|
const api={get:async()=>({enabled,products:[{sub_rank_id:1,product_id:'synthetic',price_fen:100}]}),post:async(path,data)=>{posts.push(path);return path==='/rank/buy/virtual'?{order_no:'local-order',sign_data:JSON.stringify({goodsPrice:mismatch?101:100,buyQuantity:1,productId:'synthetic',outTradeNo:'local-order'}),mode:'short_series_goods',paySig:'synthetic',signature:'synthetic'}:{state}}}
|
||||||
|
;(async()=>{
|
||||||
|
enabled=false;await assert.rejects(context.buy(wx,api,1,100,()=>{}),/暂不可用/);assert.equal(posts.length,0);assert.equal(payments,0)
|
||||||
|
enabled=true;let order;await context.buy(wx,api,1,100,n=>order=n);assert.equal(order,'local-order');assert.equal(payments,1)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'ios',system:'iOS 18',version:'8.0.70'});await context.buy(wx,api,1,100,()=>{});assert.equal(payments,2)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'ios',system:'iOS 14',version:'8.0.70'});await assert.rejects(context.buy(wx,api,1,100,()=>{}),/升级/);assert.equal(payments,2)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'windows',system:'Windows 11',version:'4.0'});await context.buy(wx,api,1,100,()=>{});assert.equal(payments,3)
|
||||||
|
wx.getSystemInfoSync=()=>({platform:'ohos',system:'HarmonyOS',version:'8.0.70'});await context.buy(wx,api,1,100,()=>{});assert.equal(payments,4)
|
||||||
|
mismatch=true;await assert.rejects(context.buy(wx,api,1,100,()=>{}),/不一致/);assert.equal(payments,4);mismatch=false
|
||||||
|
await assert.rejects(context.buy(wx,api,1,200,()=>{}),/价格已更新/)
|
||||||
|
cancel=true;const cancelStart=posts.length;let cancelledOrder;await assert.rejects(context.buy(wx,api,1,100,n=>cancelledOrder=n),/取消/);cancel=false
|
||||||
|
assert.equal(cancelledOrder,'local-order','cancel must preserve order for later reconciliation')
|
||||||
|
assert.deepEqual(posts.slice(cancelStart),['/rank/buy/virtual'],'cancel must not invoke fulfilment or confirmation')
|
||||||
|
state='PENDING';const pendingStart=posts.length;await assert.rejects(context.buy(wx,api,1,100,()=>{}),/确认中/)
|
||||||
|
assert.equal(posts.slice(pendingStart).filter(p=>p.endsWith('/confirm')).length,5,'pending query must stop after bounded retries')
|
||||||
|
assert(!posts.some(p=>p==='/rank/buy' || p.includes('/callback')))
|
||||||
|
console.log('PASS: closed purchase, Android/iOS/Windows/HarmonyOS unified virtual pay, unsupported iOS, changed price/order mismatch, cancellation, pending confirmation; no legacy payment callback')
|
||||||
|
})().catch(e=>{console.error(e.message);process.exitCode=1})
|
||||||
129
tools/local-acceptance/runtime.js
Normal file
129
tools/local-acceptance/runtime.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
'use strict'
|
||||||
|
// This file is injected ONLY into the dedicated acceptance build, before app imports.
|
||||||
|
// No HTTP client, SDK, database or production credentials are used.
|
||||||
|
const ORIGIN = 'http://127.0.0.1:18765/go/api'
|
||||||
|
const IMAGE = '/images/tabBar/my.png'
|
||||||
|
const KEY = 'love.acceptance.v1'
|
||||||
|
const clone = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const ok = data => ({code: 0, message: '本地模拟数据', data})
|
||||||
|
const denied = message => ({code: 1, message: '本地模拟:' + message, data: null})
|
||||||
|
function initial() {
|
||||||
|
const user = {id: 900000001, nickname: '本地测试用户', name: '本地测试用户', photo: IMAGE,
|
||||||
|
mobile: '00000000000', sex: 1, type: 'single', rank_id: 0, is_rank: 0,
|
||||||
|
is_approved: 0, is_real_approved: 0, is_educate_approved: 0, hidden_profile: 'NONE',
|
||||||
|
create_time: '2026-10-30 00:00:00', city: '测试城市', resident_city: '测试城市',
|
||||||
|
year: '95年', birthday: '1995-01-01', stature: 170, weight: 60, degree: '待核实', industry_sub: '测试职业'}
|
||||||
|
return {user, profile: {introduction: '这是合成测试资料,可编辑保存。', ideal_mate: '仅供页面验收',
|
||||||
|
interest_hobby: '阅读', interest_label: ['阅读'], city_list_claim: '', mate_conditon: []},
|
||||||
|
complete: true, failure: false, nextId: 1002, follows: [],
|
||||||
|
moments: [{id: 1001, user_id: user.id, user, content: '本地测试动态:可查看、发布和点赞。',
|
||||||
|
images: [], photos: [], pics: [], type: 'moment', is_like: 0, like_count: 0, comment_count: 0,
|
||||||
|
create_time: '2026-10-30 00:00:00', created_at: '2026-10-30 00:00:00', topics: []}], comments: {}}
|
||||||
|
}
|
||||||
|
function createMock(storage) {
|
||||||
|
let state = storage.get(KEY) || initial()
|
||||||
|
const save = () => storage.set(KEY, clone(state))
|
||||||
|
const people = () => [2, 3, 4].map(n => Object.assign({}, state.user, {id: 900000000+n, nickname: '合成用户'+n, sex: n % 2 + 1}))
|
||||||
|
function page(rows, params) { return {data: Number(params.page || 1) > 1 ? [] : rows, total: rows.length, total_pages: 1, current_page: Number(params.page || 1), last_page: 1} }
|
||||||
|
function request(url, method, input) {
|
||||||
|
if (typeof url !== 'string' || !url.startsWith(ORIGIN + '/')) return denied('已阻断非本地接口')
|
||||||
|
const path = url.slice(ORIGIN.length).split('?')[0]
|
||||||
|
const params = Object.assign({}, input || {})
|
||||||
|
;(url.split('?')[1] || '').split('&').filter(Boolean).forEach(pair => {const i=pair.indexOf('='); params[decodeURIComponent(i<0?pair:pair.slice(0,i))]=decodeURIComponent(i<0?'':pair.slice(i+1))})
|
||||||
|
method = (method || 'GET').toUpperCase()
|
||||||
|
if (state.failure && path !== '/login') return denied('模拟请求失败;请在验收入口关闭失败场景')
|
||||||
|
let result
|
||||||
|
const profile = () => Object.assign({}, state.user, state.profile)
|
||||||
|
const detail = user => ({user, base_profile: user, other_profile: clone(state.profile), profile_photos: [IMAGE],
|
||||||
|
approve_profile: {is_approved: 0, is_real_approved: 0, is_educate_approved: 0},
|
||||||
|
rank_profile: {rank_id: 0, deadline: ''}, share_qrcode: IMAGE, self_other_info: 0, self_is_real_approved: 0,
|
||||||
|
is_follow: state.follows.includes(user.id), follow_count: state.follows.length, friend_count: 0})
|
||||||
|
if (path === '/login' && method === 'POST') result = {token: 'LOCAL-MOCK-NOT-A-REAL-TOKEN', openid: 'LOCAL-MOCK', user: state.user,
|
||||||
|
im_user: {accid: 'local-user', token: 'LOCAL-MOCK'}, adv: {id:0}, is_servicer: 0, is_moment_admin: 0,
|
||||||
|
is_base_profile: state.complete, is_compeleted_profile: state.complete}
|
||||||
|
else if (path === '/user/status') result = {is_base_profile: state.complete, is_complete_profile: state.complete, is_photo_audited: 0}
|
||||||
|
else if (path === '/user/center') result = detail(state.user)
|
||||||
|
else if (path === '/user/profile' && method === 'GET') result = profile()
|
||||||
|
else if (['/user/other/profie', '/user/register/profile', '/user/info', '/profile'].includes(path) && ['POST','PUT'].includes(method)) {
|
||||||
|
// Only editable fixture fields; never accept privilege, identity, payment or approval fields.
|
||||||
|
;['introduction','ideal_mate','interest_hobby','city_list_claim'].forEach(k => {if(typeof params[k]==='string') state.profile[k]=params[k]})
|
||||||
|
;['nickname','city','resident_city','birthday','industry_sub'].forEach(k => {if(typeof params[k]==='string') state.user[k]=params[k]})
|
||||||
|
if(path === '/user/register/profile') {if(params.code !== '000000') return denied('测试验证码为 000000'); state.complete=true}
|
||||||
|
result=profile()
|
||||||
|
} else if (path === '/register/sms/code' && method === 'POST') result={mock:true, message:'未发送短信;测试验证码 000000'}
|
||||||
|
else if (path === '/area/top/users') result=people()
|
||||||
|
else if (path === '/area/top/users/status') result={status:0}
|
||||||
|
else if (path === '/area/class') result=[]
|
||||||
|
else if (path === '/other/user/recommend') result=people()
|
||||||
|
else if (/^\/other\/user\/(rand\/)?square$/.test(path)) result=page(people(),params)
|
||||||
|
else if (/^\/other\/user\/\d+$/.test(path)) {const user=people().find(p=>p.id===Number(path.split('/').pop())); if(!user)return denied('测试用户不存在');result=detail(user)}
|
||||||
|
else if (/^\/follow\/user\/\d+$/.test(path) && method==='POST') {const id=Number(path.split('/').pop());state.follows=state.follows.includes(id)?state.follows.filter(x=>x!==id):state.follows.concat(id);result={is_follow:state.follows.includes(id)}}
|
||||||
|
else if (path === '/rank/purchase/status') result={enabled:false,products:[],single_service_enabled:false,message:'会员购买暂不可用'}
|
||||||
|
else if (path === '/rank/list') result={rank_info:{nickname:'本地测试用户',rank_id:0},ranks:{name:'合成验收会员',feature_arr:[],explain_arr:['本地合成数据;购买默认关闭,不发起真实支付。'],sub_ranks:[{id:900011,name:'合成验收月卡',month:1,price:0,discount_price:0}]},pay_type:'cash'}
|
||||||
|
else if (path === '/rank/service/list') result={rank_info:state.user,services:[]}
|
||||||
|
else if (method === 'POST' && /^\/order\/virtual\/LOCAL-ACCEPTANCE-(PENDING|FULFILLED)\/confirm$/.test(path)) result={state:path.includes('-FULFILLED/')?'FULFILLED':'PENDING'}
|
||||||
|
else if (path === '/dma/carousel/list') result=[]
|
||||||
|
else if (path === '/moment/list' || /^\/moment\/list\/other\/user\/\d+$/.test(path)) result=page(state.moments,params)
|
||||||
|
else if (path === '/moment/topic/list') result=page([],params)
|
||||||
|
else if (path === '/moment/add' && method==='POST') {
|
||||||
|
if(!String(params.content||'').trim())return denied('请输入测试动态内容')
|
||||||
|
const item={id:state.nextId++,user_id:state.user.id,user:clone(state.user),content:String(params.content),images:[],photos:[],pics:[],type:'moment',is_like:0,like_count:0,comment_count:0,topics:[],create_time:'2026-10-30 00:00:00',created_at:'2026-10-30 00:00:00'}
|
||||||
|
state.moments.unshift(item);result=item
|
||||||
|
} else if (/^\/moment\/\d+$/.test(path)) {result=state.moments.find(m=>m.id===Number(path.split('/')[2]));if(!result)return denied('测试动态不存在')}
|
||||||
|
else if (/^\/moment\/\d+\/like$/.test(path) && method==='POST') {const item=state.moments.find(m=>m.id===Number(path.split('/')[2]));if(!item)return denied('测试动态不存在');item.is_like=item.is_like?0:1;item.like_count=item.is_like;result=item}
|
||||||
|
else if (/^\/moment\/\d+\/comment\/list$/.test(path)) result=params.nopage?[]:page([],params)
|
||||||
|
else if (path === '/notice/count') result={system_count:0,friend_count:0,follow_count:0,visit_count:0}
|
||||||
|
else if (['/chat/linkmen','/order/rank/list','/friends','/followers','/followings'].includes(path)) result=page([],params)
|
||||||
|
else if (/^\/label\/user\/\d+\/list$/.test(path)) result=page([],params)
|
||||||
|
else if (/^\/label\/user\/\d+\/logs$/.test(path)) result=[]
|
||||||
|
else if (path === '/interest/labels/list') result=['阅读','运动']
|
||||||
|
else if (path === '/address/country/list') result=[]
|
||||||
|
else return denied('此操作尚未模拟:'+method+' '+path+';未调用真实服务')
|
||||||
|
save();return ok(clone(result))
|
||||||
|
}
|
||||||
|
return {request, reset(){state=initial();save()}, scenario(name,value){if(name==='failure'||name==='complete'){state[name]=!!value;save()}}, snapshot(){return clone(state)}}
|
||||||
|
}
|
||||||
|
function install(wx, config = {}) {
|
||||||
|
if(config.apiOrigin && config.apiOrigin !== 'http://127.0.0.1:18766') throw new Error('Only the local acceptance gateway is permitted')
|
||||||
|
const nativeRequest=wx.request.bind(wx)
|
||||||
|
const prefix=config.apiOrigin?'acceptance-api:':'acceptance:'
|
||||||
|
const done=(options,result,success=true)=>{setTimeout(()=>{if(success){if(options.success)options.success(result)}else if(options.fail)options.fail(result);if(options.complete)options.complete(result)},0);return {abort(){},onProgressUpdate(){},offProgressUpdate(){},onHeadersReceived(){},offHeadersReceived(){}}}
|
||||||
|
const override=(name,fn)=>{wx[name]=fn;if(wx[name]!==fn)throw new Error('Cannot isolate wx.'+name+'; abort acceptance build')}
|
||||||
|
// Namespace ALL storage so an acceptance run cannot reuse or modify real login state.
|
||||||
|
const nativeGet=wx.getStorageSync.bind(wx),nativeSet=wx.setStorageSync.bind(wx),nativeRemove=wx.removeStorageSync.bind(wx)
|
||||||
|
override('getStorageSync',k=>nativeGet(prefix+k))
|
||||||
|
override('setStorageSync',(k,v)=>nativeSet(prefix+k,k==='userInfo'?Object.assign({},v,{is_base_profile:namespaced.snapshot().complete}):v))
|
||||||
|
override('removeStorageSync',k=>nativeRemove(prefix+k))
|
||||||
|
const nativeInfo=wx.getStorageInfoSync && wx.getStorageInfoSync.bind(wx)
|
||||||
|
override('clearStorageSync',()=>{if(nativeInfo)nativeInfo().keys.filter(k=>k.startsWith(prefix)).forEach(nativeRemove)})
|
||||||
|
const namespaced=createMock({get:k=>wx.getStorageSync(k),set:(k,v)=>wx.setStorageSync(k,v)})
|
||||||
|
const fixture=namespaced.snapshot()
|
||||||
|
wx.setStorageSync('userInfo',Object.assign({},fixture.user,{name:fixture.user.nickname,avatar:IMAGE,showId:fixture.user.id}))
|
||||||
|
wx.setStorageSync('adv',{id:0})
|
||||||
|
override('request',options=>{
|
||||||
|
const suffix=typeof options.url==='string' && options.url.startsWith(ORIGIN+'/')?options.url.slice(ORIGIN.length):''
|
||||||
|
const route=(options.method||'GET').toUpperCase()+' '+suffix.split('?')[0]
|
||||||
|
const realRoutes=['POST /login','GET /user/profile','PUT /user/other/profie','GET /user/status','GET /dma/carousel/list','GET /rank/purchase/status']
|
||||||
|
if(config.apiOrigin && realRoutes.includes(route) && !namespaced.snapshot().failure) {
|
||||||
|
// Exact origin and method/path allowlist; no fallback to production or silent mock success.
|
||||||
|
return nativeRequest(Object.assign({},options,{url:config.apiOrigin+'/go/api'+suffix}))
|
||||||
|
}
|
||||||
|
let response;try{response=namespaced.request(options.url,options.method,options.data)}catch(e){response=denied('模拟请求参数无效')}
|
||||||
|
return done(options,{statusCode:200,header:{},data:response,errMsg:'request:ok'})
|
||||||
|
})
|
||||||
|
override('login',options=>done(options,{code:'LOCAL-MOCK-CODE',errMsg:'login:ok'}))
|
||||||
|
const blocked=['uploadFile','downloadFile','connectSocket','requestPayment','requestVirtualPayment','requestSubscribeMessage','navigateToMiniProgram','openCustomerServiceChat','makePhoneCall','startFacialRecognitionVerify','startFacialRecognitionVerifyAndUploadVideo','openUrl','openDocument','shareAppMessage']
|
||||||
|
blocked.forEach(name=>override(name,(options={})=>{if(wx.showToast)wx.showToast({title:'本地模拟:此第三方操作已禁用',icon:'none'});return done(options,{errMsg:name+':fail local mock blocked'},false)}))
|
||||||
|
override('getFuzzyLocation',options=>done(options,{latitude:0,longitude:0,errMsg:'getFuzzyLocation:ok'}))
|
||||||
|
override('chooseLocation',options=>done(options,{latitude:0,longitude:0,name:'测试地点',address:'合成地址',errMsg:'chooseLocation:ok'}))
|
||||||
|
override('getLogManager',()=>({log(){},info(){},debug(){},warn(){}}))
|
||||||
|
override('getRealtimeLogManager',()=>({info(){},warn(){},error(){}}))
|
||||||
|
override('getUpdateManager',()=>({onCheckForUpdate(){},onUpdateReady(){},onUpdateFailed(){},applyUpdate(){}}))
|
||||||
|
// Cloud APIs must never retain access to any previously configured real environment.
|
||||||
|
if(wx.cloud) Object.keys(wx.cloud).forEach(name=>{if(typeof wx.cloud[name]==='function'){wx.cloud[name]=()=>{throw new Error('Cloud disabled in local acceptance') }}})
|
||||||
|
const nativeImage=wx.getImageInfo && wx.getImageInfo.bind(wx)
|
||||||
|
if(nativeImage)override('getImageInfo',options=>nativeImage(Object.assign({},options,{src:/^(https?:)?\/\//i.test(options.src||'')?IMAGE:options.src})))
|
||||||
|
wx.__loveAcceptance=namespaced
|
||||||
|
return namespaced
|
||||||
|
}
|
||||||
|
module.exports={ORIGIN,createMock,install}
|
||||||
20
tools/local-acceptance/templates.test.cjs
Normal file
20
tools/local-acceptance/templates.test.cjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
'use strict'
|
||||||
|
const assert=require('assert'),fs=require('fs'),path=require('path'),cp=require('child_process')
|
||||||
|
const fix=require('./fix-template-expressions.cjs')
|
||||||
|
assert.equal(fix(String.raw`{{ modalName==\'Modal\' ? 'show' : '' }}`),`{{ modalName=='Modal' ? 'show' : '' }}`)
|
||||||
|
assert.equal(fix(String.raw`{{ 'height:' + (height + \'px\') }}`),`{{ 'height:' + (height + 'px') }}`)
|
||||||
|
const escaped=String.raw`{{ name === 'O\'Brien' ? 'show' : '' }}`
|
||||||
|
assert.equal(fix(escaped),escaped)
|
||||||
|
assert.throws(()=>fix(String.raw`{{ value + \'unterminated }}`),/Unparseable/)
|
||||||
|
const root=path.resolve(__dirname,'../../../.local-acceptance')
|
||||||
|
const build=path.join(root,'mp-mock-build/weapp')
|
||||||
|
const compiler=process.argv[2]
|
||||||
|
if(!compiler)throw Error('Pass the installed WeChat wcc.exe path for full template validation')
|
||||||
|
const files=[]
|
||||||
|
function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(p.endsWith('.wxml'))files.push(path.relative(build,p).replace(/\\/g,'/'))}}
|
||||||
|
walk(build)
|
||||||
|
const output=path.join(root,'logs/wxml-compiled.js')
|
||||||
|
const result=cp.spawnSync(compiler,['-o',output,...files],{cwd:build,encoding:'utf8',maxBuffer:4*1024*1024})
|
||||||
|
fs.writeFileSync(path.join(root,'logs/wxml-compile.log'),(result.stdout||'')+(result.stderr||''))
|
||||||
|
if(result.error || result.status!==0)throw Error('WXML compiler failed: '+(result.error?result.error.code:result.status)+'; inspect wxml-compile.log')
|
||||||
|
console.log('PASS: escaping regression checks and official WXML compilation of '+files.length+' templates')
|
||||||
46
tools/local-acceptance/test.cjs
Normal file
46
tools/local-acceptance/test.cjs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
'use strict'
|
||||||
|
const assert=require('assert'),fs=require('fs'),path=require('path'),vm=require('vm')
|
||||||
|
const {createMock,install,ORIGIN}=require('./runtime')
|
||||||
|
async function main(){
|
||||||
|
const data={};const storage={get:k=>data[k],set:(k,v)=>{data[k]=v}};const mock=createMock(storage)
|
||||||
|
const call=(p,method='GET',input={})=>mock.request(ORIGIN+p,method,input)
|
||||||
|
assert.equal(call('/login','POST').data.user.id,900000001)
|
||||||
|
assert.equal(call('/user/center').data.rank_profile.rank_id,0)
|
||||||
|
assert.equal(call('/other/user/rand/square').data.data.length,3)
|
||||||
|
assert.equal(call('/other/user/rand/square?page=2').data.data.length,0)
|
||||||
|
assert.equal(call('/other/user/999').code,1)
|
||||||
|
assert.equal(call('/user/other/profie','PUT',{introduction:'edited',rank_id:999,is_approved:1}).code,0)
|
||||||
|
assert.equal(call('/user/profile').data.introduction,'edited');assert.equal(call('/user/center').data.user.rank_id,0)
|
||||||
|
assert.equal(call('/moment/add','POST',{content:''}).code,1)
|
||||||
|
const added=call('/moment/add','POST',{content:'local test'}).data
|
||||||
|
assert.equal(call('/moment/list').data.data[0].id,added.id)
|
||||||
|
assert.equal(call('/moment/'+added.id+'/like','POST').data.like_count,1)
|
||||||
|
assert.equal(call('/moment/'+added.id+'/like','POST').data.like_count,0)
|
||||||
|
assert.equal(createMock(storage).snapshot().moments.length,2)
|
||||||
|
assert.equal(call('/register/sms/code','POST').data.mock,true)
|
||||||
|
assert.equal(call('/user/register/profile','PUT',{code:'wrong'}).code,1)
|
||||||
|
assert.equal(call('/user/register/profile','PUT',{code:'000000'}).code,0)
|
||||||
|
for(const p of ['/pay','/refund','/not-implemented','/upload','/certification'])assert.equal(call(p,'POST').code,1)
|
||||||
|
for(const url of ['https://example.invalid/login','http://127.0.0.1:18765.evil/go/api/login',ORIGIN+'evil/login','//example.invalid'])assert.equal(mock.request(url,'POST',{}).code,1)
|
||||||
|
mock.scenario('failure',true);assert.equal(call('/user/profile').code,1);mock.reset();assert.equal(call('/user/profile').code,0)
|
||||||
|
let external=0;const saved={token:'real-state-must-stay'}
|
||||||
|
const wx={getStorageSync:k=>saved[k],setStorageSync:(k,v)=>{saved[k]=v},removeStorageSync:k=>{delete saved[k]},getStorageInfoSync:()=>({keys:Object.keys(saved)}),showToast(){},request(){external++;throw Error('Network accessed')},login(){external++},getImageInfo(o){o.success({path:o.src})}}
|
||||||
|
const isolated=install(wx)
|
||||||
|
function invoke(name,opts={}){return new Promise(resolve=>wx[name]({...opts,success:r=>resolve({ok:true,r}),fail:r=>resolve({ok:false,r})}))}
|
||||||
|
assert.equal((await invoke('login')).r.code,'LOCAL-MOCK-CODE')
|
||||||
|
assert.equal((await invoke('request',{url:ORIGIN+'/login',method:'POST'})).r.data.code,0)
|
||||||
|
assert.equal((await invoke('request',{url:'https://example.invalid'})).r.data.code,1)
|
||||||
|
for(const name of ['uploadFile','downloadFile','connectSocket','requestPayment','requestVirtualPayment','navigateToMiniProgram','requestSubscribeMessage','openCustomerServiceChat','makePhoneCall'])assert.equal((await invoke(name)).ok,false,name)
|
||||||
|
wx.setStorageSync('token','local');assert.equal(saved.token,'real-state-must-stay');assert.equal(wx.getStorageSync('token'),'local')
|
||||||
|
wx.clearStorageSync();assert.equal(saved.token,'real-state-must-stay');assert.equal(wx.getStorageSync('token'),undefined)
|
||||||
|
assert.equal(external,0);isolated.reset()
|
||||||
|
console.log('PASS: login, pagination, profile persistence, privilege isolation, publish/like, SMS fixture, unsupported operations, failures, storage isolation and zero native transport calls')
|
||||||
|
const build=path.resolve(__dirname,'../../../.local-acceptance/mp-mock-build/weapp')
|
||||||
|
if(fs.existsSync(build)){
|
||||||
|
let count=0;function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(e.name.endsWith('.js')){new vm.Script(fs.readFileSync(p,'utf8'),{filename:p});count++}}}walk(build)
|
||||||
|
assert(fs.readFileSync(path.join(build,'app.js'),'utf8').startsWith("require('./acceptance-runtime').install(wx);"))
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(build,'app.json'))).pages[0],'local-acceptance/index')
|
||||||
|
console.log('PASS: '+count+' generated JavaScript files parse; isolation loads before application; acceptance launch page configured')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||||
94
tools/sharing/VALIDATION-20261102.md
Normal file
94
tools/sharing/VALIDATION-20261102.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# 邀请与个人卡片改版验证
|
||||||
|
|
||||||
|
分支:feature/invitation-card-experience。状态:本地实现及功能分支编译完成,待本次页面人工审核及微信真机验收;未合并 main,未上传。
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
- 菜单与页标题改为“我的邀请”“我的卡片”,原路由保持兼容。
|
||||||
|
- 邀请海报、保存、好友分享置于统计与分页列表之前;个人卡片使用大图预览和底部固定操作。
|
||||||
|
- 使用福恋 VI 米白、玫瑰配色及原版 Logo,邀请材料与个人介绍分别绘制。
|
||||||
|
- 页面 onReady 后使用 Canvas2D,进页自动生成;按账号、用途、资料指纹缓存海报、分享封面与二维码,24 小时过期,最多保留四组。
|
||||||
|
- 资料变化、图片丢失、缓存过期重新生成。请求、图片、画布、导出均有限时,整体生成限时 30 秒。缓存写失败仍允许使用已生成图片。
|
||||||
|
- 专用请求模块不改动旧登录、支付和 IM 请求逻辑。邀请列表与海报并行加载。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
测试目录 E:/pj/love/ufutx_love_mp,Node v24.19.0:
|
||||||
|
|
||||||
|
| 命令 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| node tools/sharing/check.cjs | 退出 0;生命周期、首次生成、缓存复用、用途区分、HTTP/认证/异常响应、请求无回调 |
|
||||||
|
| node tools/sharing/check-contact.cjs | 退出 0;6/7/8 人门槛、资格过期、查询失败重试、保存错误,不自动授予角色 |
|
||||||
|
| node tools/sharing/check-cache.cjs | 退出 0;资料更新、缓存缺失、账号隔离、过期、磁盘写失败、各生成阶段超时、分页独立、卸载取消 |
|
||||||
|
| node tools/sharing/regression.cjs | 退出 0;邀请归因、旧代理/购物链接、菜单顺序、原入口、专属客服、路由 |
|
||||||
|
| git diff --check | 退出 0;存在 Git CRLF 提示 |
|
||||||
|
|
||||||
|
隔离编译目录 E:/pj/love/.release-build/invitation-card-candidate,使用 Node v16.20.2 执行 tools/build/build.cjs,退出 0。编译 89 个模板,405 个文件;主包原始大小 2,027,427 字节,总大小 3,177,369 字节。原版图片分包复制校验通过。依赖提示 Browserslist 数据过旧,未修改锁文件或更新依赖。
|
||||||
|
|
||||||
|
在 E:/pj/love 执行 output/invitation-card-experience/preview.cjs:退出 0。使用生产绘图函数、页面模板和样式,通过浏览器适配检查 320/375/430 像素布局,未横向溢出,分享按钮先于列表。截图仅使用合成资料和示意码,网络请求全部拦截。人工查看两页 375 像素截图与海报。预览目录 output/invitation-card-experience。
|
||||||
|
|
||||||
|
output/profile-sharing/prepare-release.cjs verify 退出 0,既有 16 个无关修改文件哈希保持不变。后端未新增变更。初次子进程调用受沙箱 EPERM 限制,获准后在相同只读/隔离范围重试成功。
|
||||||
|
|
||||||
|
## 验证边界
|
||||||
|
|
||||||
|
测试使用模拟微信 API;浏览器截图不等于微信运行结果。未启动默认连接生产的小程序。微信真机 Canvas2D、相册权限、分享送达与扫码落地尚待验收。邀请订单的婚恋使者角色来源仍待核实,此次没有开放订单权限。须人工审核此次改版后合并 main,再从 main 编译发布制品。
|
||||||
|
|
||||||
|
## 布局参考
|
||||||
|
|
||||||
|
- 有赞海报分享流程:https://help.youzan.com/displaylist/detail_13_13-1-62075
|
||||||
|
- Canva 二维码名片:https://www.canva.com/business-cards/templates/qr-code/
|
||||||
|
|
||||||
|
参考分享操作集中、个人介绍与二维码分区的结构;品牌色、Logo 与口号以福恋 VI 为准。
|
||||||
|
|
||||||
|
## 同日 Tab 布局调整
|
||||||
|
|
||||||
|
邀请页新增“专属邀请 / 邀请成果”,默认 activeTab=poster;使用 v-show 保留海报与分页状态,Canvas 保持挂载。统计、使者申请、订单提示和用户列表归入成果 Tab,空态文案同步调整。
|
||||||
|
|
||||||
|
上述四组 Node 回归再次退出 0;内存页面检查默认 Tab、来回切换、海报及页码保留通过。三种屏宽预览检查默认成果隐藏、切换后海报隐藏均通过,并查看两个 375px 页面截图。隔离目录再次执行 Node16 tools/build/build.cjs 退出 0,89 个模板编译完成。git diff --check 通过。未合并、上传或执行生产请求。
|
||||||
|
|
||||||
|
### 大幅海报展示调整
|
||||||
|
|
||||||
|
专属邀请改为全宽纵向海报(375px 视口内约 323px 宽,原缩略图 110px),移除右侧重复文案,按原比例展示;下方保留查看大图、缓存状态和保存/分享按钮。320/375/430px 浏览器适配预览均无横向溢出,两个 Tab 显隐检查通过,已查看 375px 预览。仅展示样式调整,无生成、缓存或业务规则变更。预览文件新增 large-poster-v3 标识,避免复用旧图片路径。
|
||||||
|
|
||||||
|
## 生活照选择
|
||||||
|
|
||||||
|
个人名片改用 SharingProfile 返回的本人生活照相册 photos(最多最近 9 张,过滤软删除记录)。不使用头像或认证照;生活照模型没有逐张审核状态,因此沿用现有生活照展示范围,不虚构审核字段。
|
||||||
|
|
||||||
|
候选照片并发读取尺寸、限时 5 秒;短边不足 400px、宽高比不在 0.6–2.2 内、加载失败时跳过,按 686:470 海报裁切保留比例(80%)与分辨率(20%)选最高分。该规则为展示适配,不做人脸或外貌评分。候选均不可用或实际绘制解码失败时,使用原版福恋 Logo 品牌背景。缓存升级 v4,生活照数组纳入资料指纹。
|
||||||
|
|
||||||
|
Node v24.19.0:check-photo.cjs、check.cjs、check-cache.cjs、check-contact.cjs、regression.cjs 均退出 0。覆盖无相册、尺寸/比例不合适、下载失败、绘制解码失败、默认背景生成和相册变化缓存指纹。Node v16.20.2 隔离编译退出 0,406 文件,主包 2,033,341 字节,原版静态素材校验通过。仍待微信真机验收,未发布。
|
||||||
|
|
||||||
|
Go v1.22.4:E:/pj/love/.release-build/card-lifestyle-api 中执行 go test -mod=readonly ./controller/user -run '^TestSharing' -count=1,使用新建 MySQL 8.0.45 合成数据库,退出 0(1.953s)。首次因 Docker 内部网络未向 Windows 暴露端口而连接失败;加入独立、关闭 IP 伪装的测试网络后,本机端口核实可用,重试通过。覆盖相册仅本人、忽略软删除、新照片优先及原分享接口回归。无真实用户数据或第三方调用,测试容器与网络随后清理。既有 16 个无关修改文件哈希验证保持不变。
|
||||||
|
|
||||||
|
## 合并授权与验收制品
|
||||||
|
|
||||||
|
2026-11-02 用户已审核迭代结果并明确要求将所有相关 feature 合并 main、发布新版验收。相关范围为前端 feature/invitation-card-experience 与后端 feature/card-lifestyle-photo;早先 feature/profile-sharing 已合入两仓库 main。此次按授权提交并合并本地 main,从 main 独立导出编译验收制品;不包含工作区无关修改。完整发布状态、提交 SHA、制品校验及真机待验收项以 releases/20261102-invitation-card-main 内 manifest.json 和 RELEASE.txt 为准。此前各段“未合并”描述为当时验证状态。
|
||||||
|
|
||||||
|
## 真机图片失败修复与阶段进度(2026-11-02)
|
||||||
|
|
||||||
|
用户截图两页均为“图片读取失败”,好友分享退回原始 Logo。已在开发者工具 2.02.2609240 的独立 Canvas2D 探针复现:wx.getImageInfo('/pages/users/assets/fulian-logo.png') 返回 pages/users/assets/fulian-logo.png,直接赋给 Canvas Image.src 加载失败(originalLoads=false)。补齐包根路径后,邀请与个人海报均成功导出 750×1120,封面也正常导出。探针使用合成资料、Logo 代替测试照片和二维码,禁用 wx.request/uploadFile/downloadFile/connectSocket;未打开业务应用、未调用分享码服务,不等于实机扫码/相册权限验收。输出记录位于 output/invitation-card-experience/native-canvas-result.json 与 native-canvas-check.png。
|
||||||
|
|
||||||
|
修改:包内图片路径规范化,保留 wxfile/http://usr/绝对文件等路径;区分品牌图片、生活照、小程序码错误;缓存升级 v5;首轮加载顶部增加阶段进度(资料5、选图15、封面25、请求二维码45、准备二维码65、绘图75、缓存90、完成100),由真实阶段完成推进而非计时假进度,错误停止。封面用原版 Logo 与福恋 VI 绘制邀请信封、标题和行动文案;两种用途各有750×600静态品牌封面兜底,不再分享放大的原始 Logo。邀请页加载占位使用完整品牌封面。
|
||||||
|
|
||||||
|
Node v24.19.0 六组回归均通过:check-image-path、check、check-cache、check-photo、check-contact、regression。新增覆盖返回相对包路径、原生文件 URI 保持、进度递增、错误不显示100%、二维码接口失败时封面仍可用。前端隔离编译使用 Node v16.20.2,通过;320/375/430px 合成页面预览无横向溢出,并查看进度状态和分享封面。原有16个无关文件哈希未变化,后端没有本次修改。
|
||||||
|
|
||||||
|
开发者工具启动过程:自动化 SDK 在 Node24 启动 bat 失败,改用官方 CLI;游客 AppID 不被当前工具接受,改用仓库已有 AppID,仍仅加载独立禁网探针。探针分包注册失败后改成普通页面入口完成验证。测试后关闭探针项目。未上传、未部署、未更新 main 验收包;改动保留当前 feature,包含此前邀请列表详情入口。
|
||||||
|
|
||||||
|
2026-11-02 用户确认编译本轮修复进行验收。按既有授权合并本地 main 后编译,新制品和精确提交记录见 releases/20261102-poster-fix-main。未执行上传或部署。
|
||||||
|
|
||||||
|
## 2026-11-03 手动更新随机换生活照
|
||||||
|
|
||||||
|
分支 feature/card-photo-refresh。个人卡片点击“更新海报”或失败后的“重新生成”,重新读取本人生活照并筛选既有尺寸/比例条件,在合格候选中随机选择;多张时排除上一张,单张继续使用,无合格照片返回品牌默认背景。首次进入仍按适配度优选;将 selectedPhoto 写入本地缓存,正常重进保持上次随机照片,照片删除或不再合格时重新选择。邀请海报行为不变。
|
||||||
|
|
||||||
|
Node v24.19.0:六组分享回归退出0。check-photo 增加随机排除当前、单张、无照片、优先缓存照片、重进不重新导出、照片删除后替换;额外执行生成失败后点击重试换图检查,退出0。git diff --check通过,后端没有本次改动,未上传发布。
|
||||||
|
Node v16.20.2 在 .release-build/invitation-card-candidate 执行 tools/build/build.cjs,退出0,89模板编译完成。改动仍在功能分支,未更新 main 验收包。
|
||||||
|
|
||||||
|
2026-11-03 用户确认后端修复生效但线上换图未变化。核实上一交付前端 main f82dab2 仍固定优选,随机换图代码尚在功能分支。本轮补齐前端交付,合并后从 main 编译;多张合格照片排除当前项,缓存保留新选择。
|
||||||
|
|
||||||
|
## 2026-11-03 个人照片防裁头适配
|
||||||
|
|
||||||
|
用户反馈横向照片框居中裁剪导致头部出框。本轮采用完整照片等比例适配:保留全部源图,按海报/分享封面尺寸自动缩放居中,空余处填充福恋浅玫瑰背景;加载预览也改为 aspectFit。未引入人脸检测,不声称识别人脸;此方案直接避免照片框裁掉源图中的头部,无法恢复原图本身已缺失的内容。缓存升级v6使旧裁剪海报失效。
|
||||||
|
|
||||||
|
Node24几何检查覆盖竖图/横图/方图在686×470和264×364目标框内等比例完整显示,通过;check-photo/check-cache/check-image-path退出0,diff检查通过。后端无本轮修改。分支 feature/card-photo-framing,未上传发布。
|
||||||
|
|
||||||
|
2026-11-03 用户确认生成正式验收包。照片适配功能分支编译退出0,按授权合并本地 main 后重新编译。制品目录 releases/20261103-card-photo-framing,未上传部署。
|
||||||
25
tools/sharing/check-cache.cjs
Normal file
25
tools/sharing/check-cache.cjs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const assert=require('assert'),{createHarness,settle}=require('./harness.cjs')
|
||||||
|
async function render(h,name='shareCard'){const page=h.page(name);page.hook('onReady');await page.startSharing();return page}
|
||||||
|
;(async()=>{
|
||||||
|
const h=createHarness(),first=await render(h);assert(first.poster&&first.cached)
|
||||||
|
const original=first.poster
|
||||||
|
h.state.profile.nickname='修改后的合成昵称'
|
||||||
|
const changed=await render(h);assert(changed.cached&&changed.poster!==original);assert(!h.files.has(original),'obsolete cached image must be removed')
|
||||||
|
h.files.delete(changed.cover);const missing=await render(h);assert(missing.poster!==changed.poster,'missing file must regenerate')
|
||||||
|
h.storage.set('token','synthetic-other-token');h.storage.set('user_id',43);h.state.profile.id=43
|
||||||
|
const other=await render(h);assert(other.poster.includes('-43-personal-'));assert(!other.poster.includes('-42-personal-'))
|
||||||
|
const index=h.storage.get('fulian-sharing-cache-v6');index['43-personal'].savedAt=Date.now()-2*24*3600*1000;h.storage.set('fulian-sharing-cache-v6',index)
|
||||||
|
const expired=await render(h);assert(expired.poster!==other.poster)
|
||||||
|
h.flags.copyFail=true;const disk=await render(h,'myShare');assert(disk.poster&&!disk.cached);assert(!disk.generating)
|
||||||
|
for(const flag of ['imageInfoHang','imageHang','canvasHang','exportHang','requestHang']){
|
||||||
|
const broken=createHarness();broken.flags[flag]=true
|
||||||
|
const page=await render(broken)
|
||||||
|
await settle(()=>!page.generating&&!page.profileLoading,flag)
|
||||||
|
assert(page.posterError||page.profileError,'missing callbacks must end in an error: '+flag)
|
||||||
|
}
|
||||||
|
const independent=createHarness();independent.flags.route=options=>options.url.endsWith('/invitees')?'hang':null
|
||||||
|
const invitation=independent.page('myShare');invitation.hook('onLoad');invitation.hook('onReady')
|
||||||
|
await settle(()=>invitation.poster&&!invitation.generating,'independent invitation poster');assert(invitation.poster)
|
||||||
|
const cancel=createHarness(),leave=cancel.page('shareCard');leave.hook('onReady');const work=leave.startSharing();leave.hook('onUnload');await work;assert(!leave.poster)
|
||||||
|
console.log('PASS: profile invalidation, missing cache, account isolation, TTL, full-disk fallback, image/canvas/export/request timeouts, independent pagination and unload cancellation.')
|
||||||
|
})().catch(error=>{console.error(error.stack);process.exitCode=1})
|
||||||
12
tools/sharing/check-code-errors.cjs
Normal file
12
tools/sharing/check-code-errors.cjs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const assert=require('assert'),{createHarness}=require('./harness.cjs')
|
||||||
|
;(async()=>{
|
||||||
|
const h=createHarness(),{sharingRequest}=h.load('utils/sharing-runtime'),url='https://synthetic.invalid/user/sharing/code'
|
||||||
|
for(const [response,pattern] of [
|
||||||
|
[{statusCode:404,data:{}},/HTTP 404/],
|
||||||
|
[{statusCode:200,data:{code:1,error_key:'qr_wechat_rejected',upstream_code:41030,message:'secret'}},/微信码 41030/],
|
||||||
|
[{statusCode:200,data:{code:1,error_key:'qr_token_refresh'}},/凭证更新失败/],
|
||||||
|
[{statusCode:200,data:{code:1,message:'生成分享码失败,请稍后重试'}},/生成分享码失败/],
|
||||||
|
[{statusCode:200,data:{code:1,message:'SYNTHETIC_PRIVATE_SECRET'}},/核查接口版本/]
|
||||||
|
]){h.flags.response=response;await assert.rejects(sharingRequest(url,'POST',{kind:'invite'}),e=>pattern.test(e.message)&&!e.message.includes('secret')&&!e.message.includes('PRIVATE'))}
|
||||||
|
console.log('PASS: QR HTTP/WeChat/credential/legacy failures distinguishable without leaking raw responses.')
|
||||||
|
})().catch(e=>{console.error(e.stack);process.exitCode=1})
|
||||||
15
tools/sharing/check-contact.cjs
Normal file
15
tools/sharing/check-contact.cjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const assert=require('assert'),{createHarness}=require('./harness.cjs')
|
||||||
|
;(async()=>{
|
||||||
|
const h=createHarness(),page=h.page('myShare')
|
||||||
|
page.setOverview({total:100,verified:6,can_apply:false});await page.openApplication();assert(!page.showApplication);assert.equal(page.remaining,1)
|
||||||
|
page.setOverview({total:12,verified:7,can_apply:true});await page.openApplication();assert(page.showApplication);assert.equal(page.remaining,0)
|
||||||
|
page.keepApplicationOpen();assert(page.showApplication);page.closeApplication();assert(!page.showApplication)
|
||||||
|
h.state.overview={total:12,verified:6,can_apply:false};await page.openApplication();assert(!page.showApplication);assert(!page.canApply)
|
||||||
|
h.state.overview={total:12,verified:8,can_apply:true};await page.refreshEligibility();assert(page.canApply)
|
||||||
|
h.flags.requestFail=true;await page.openApplication();assert(!page.showApplication);assert(!page.canApply);assert(page.eligibilityError);assert(!page.checkingEligibility)
|
||||||
|
h.flags.requestFail=false;await page.refreshEligibility();assert(page.canApply)
|
||||||
|
page.setOverview({total:100,verified:6,can_apply:true});assert(!page.canApply)
|
||||||
|
await page.saveContact();assert(!page.savingContact);assert(h.calls.some(x=>x.toast&&x.toast.title==='二维码已保存'))
|
||||||
|
h.flags.saveFail=true;await page.saveContact();assert(!page.savingContact);assert(h.calls.some(x=>x.modal&&x.modal.title==='未能保存'))
|
||||||
|
console.log('PASS: 6/7/8 verified thresholds, stale eligibility, failure/retry and contact save errors; no automatic role grant.')
|
||||||
|
})().catch(error=>{console.error(error.stack);process.exitCode=1})
|
||||||
20
tools/sharing/check-image-path.cjs
Normal file
20
tools/sharing/check-image-path.cjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const assert=require('assert'),{createHarness}=require('./harness.cjs')
|
||||||
|
;(async()=>{
|
||||||
|
const h=createHarness(),{canvasImagePath}=h.load('utils/sharing')
|
||||||
|
assert.equal(canvasImagePath('pages/users/assets/fulian-logo.png','/pages/users/assets/fulian-logo.png'),'/pages/users/assets/fulian-logo.png')
|
||||||
|
for(const p of ['wxfile://tmp_test','http://usr/test.png','/synthetic-user/a.png','https://example.invalid/a.png'])assert.equal(canvasImagePath(p,p),p)
|
||||||
|
assert.throws(()=>canvasImagePath(''))
|
||||||
|
for(const name of ['myShare','shareCard']){
|
||||||
|
const test=createHarness();test.flags.packageRelative=true
|
||||||
|
const page=test.page(name),steps=[],update=page.setPosterProgress;page.setPosterProgress=function(n,s){steps.push(n);update.call(this,n,s)}
|
||||||
|
page.hook('onReady');await page.startSharing();assert(page.poster&&page.cover);assert.equal(page.posterProgress,100)
|
||||||
|
assert(steps.includes(45)&&steps.includes(65)&&steps.includes(75)&&steps.includes(90));assert(steps.every((n,i)=>!i||n>=steps[i-1]))
|
||||||
|
const share=page.definition.onShareAppMessage.call({...page,cover:''});assert(share.imageUrl.endsWith('-share-cover.png'))
|
||||||
|
}
|
||||||
|
const fail=createHarness();fail.flags.packageRelative=true;fail.flags.failImageSrc='/pages/users/assets/fulian-logo.png'
|
||||||
|
const page=fail.page('myShare');page.hook('onReady');await page.startSharing();assert(!page.generating);assert.equal(page.posterProgress,25);assert(page.posterError.includes('品牌图片'))
|
||||||
|
const qr=createHarness();qr.flags.route=o=>o.url.endsWith('/code')?{statusCode:500,data:{}}:null
|
||||||
|
const qrPage=qr.page('myShare');qrPage.hook('onReady');await qrPage.startSharing()
|
||||||
|
assert(!qrPage.poster&&qrPage.cover&&qrPage.posterError);assert.equal(qrPage.posterProgress,45)
|
||||||
|
console.log('PASS: package-relative path reproduction fixed for both pages; native file URI preserved; stage progress and error stop; designed share fallback.')
|
||||||
|
})().catch(e=>{console.error(e.stack);process.exitCode=1})
|
||||||
41
tools/sharing/check-photo.cjs
Normal file
41
tools/sharing/check-photo.cjs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const assert=require('assert'),{createHarness}=require('./harness.cjs')
|
||||||
|
;(async()=>{
|
||||||
|
const h=createHarness(),{photoScore,selectSharingPhoto}=h.load('utils/sharing-photo')
|
||||||
|
assert.equal(photoScore(200,200),-1);assert.equal(photoScore(2400,400),-1)
|
||||||
|
assert(photoScore(1372,940)>photoScore(600,900))
|
||||||
|
h.wx.getImageInfo=({src,success,fail})=>queueMicrotask(()=>{
|
||||||
|
const sizes={small:[200,200],portrait:[600,900],fit:[1372,940],panorama:[2400,400]}
|
||||||
|
if(!sizes[src])return fail({});success({path:src,width:sizes[src][0],height:sizes[src][1]})
|
||||||
|
})
|
||||||
|
assert.equal(await selectSharingPhoto(['portrait','bad','small','fit','panorama']),'fit')
|
||||||
|
assert.equal(await selectSharingPhoto(['small','bad','panorama']),'')
|
||||||
|
assert.equal(await selectSharingPhoto([]),'')
|
||||||
|
assert.equal(await selectSharingPhoto(['portrait','fit','small','bad'],{random:true,previous:'fit'}),'portrait')
|
||||||
|
assert.equal(await selectSharingPhoto(['fit','fit'],{random:true,previous:'fit'}),'fit')
|
||||||
|
assert.equal(await selectSharingPhoto(['bad','small'],{random:true}),'')
|
||||||
|
assert.equal(await selectSharingPhoto(['portrait','fit'],{preferred:'portrait'}),'portrait')
|
||||||
|
const refresh=createHarness();refresh.state.profile.photos=['first','second']
|
||||||
|
const refreshed=refresh.page('shareCard');refreshed.hook('onReady');await refreshed.startSharing()
|
||||||
|
const initial=refreshed.profile.photo;await refreshed.refreshPoster()
|
||||||
|
assert.notEqual(refreshed.profile.photo,initial);assert(refreshed.poster)
|
||||||
|
const chosen=refreshed.profile.photo,poster=refreshed.poster,exportsBefore=refresh.state.exportID
|
||||||
|
const revisit=refresh.page('shareCard');revisit.hook('onReady');await revisit.startSharing()
|
||||||
|
assert.equal(revisit.profile.photo,chosen);assert.equal(revisit.poster,poster);assert.equal(refresh.state.exportID,exportsBefore)
|
||||||
|
refresh.state.profile.photos=[initial];await revisit.loadSharingProfile();assert.equal(revisit.profile.photo,initial)
|
||||||
|
const runtime=h.load('utils/sharing-runtime')
|
||||||
|
assert.equal(runtime.displayProfile({id:42,photo:'avatar-only'}).photo,'')
|
||||||
|
const empty=createHarness();empty.state.profile.photos=[]
|
||||||
|
const page=empty.page('shareCard');page.hook('onReady');await page.startSharing()
|
||||||
|
assert(page.poster);assert.equal(page.profile.photo,'');assert(empty.draws.some(x=>x[1]==='期待认真相识'))
|
||||||
|
const broken=createHarness();broken.state.profile.photos=['broken-photo']
|
||||||
|
const original=broken.wx.getImageInfo
|
||||||
|
broken.wx.getImageInfo=opts=>opts.src==='broken-photo'?queueMicrotask(()=>opts.fail({})):original(opts)
|
||||||
|
const fallback=broken.page('shareCard');fallback.hook('onReady');await fallback.startSharing()
|
||||||
|
assert(fallback.poster);assert.equal(fallback.profile.photo,'')
|
||||||
|
const decode=createHarness();decode.flags.failImageSrc='synthetic-photo'
|
||||||
|
const damaged=decode.page('shareCard');damaged.hook('onReady');await damaged.startSharing()
|
||||||
|
assert(damaged.poster);assert(decode.draws.some(x=>x[1]==='期待认真相识'))
|
||||||
|
const fingerprint=h.load('utils/sharing-cache').sharingFingerprint
|
||||||
|
assert.notEqual(fingerprint({id:42,photos:['one']},'personal'),fingerprint({id:42,photos:['two']},'personal'))
|
||||||
|
console.log('PASS: resolution/crop ranking, failed/tiny/extreme photos skipped, no avatar substitution, logo fallback rendered.')
|
||||||
|
})().catch(e=>{console.error(e.stack);process.exitCode=1})
|
||||||
21
tools/sharing/check.cjs
Normal file
21
tools/sharing/check.cjs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const assert=require('assert'),{createHarness,settle}=require('./harness.cjs')
|
||||||
|
;(async()=>{
|
||||||
|
const h=createHarness(),sharing=h.load('utils/sharing'),runtime=h.load('utils/sharing-runtime')
|
||||||
|
assert.equal(sharing.sharingPath(42,false),'/pages/tabBar/welcome?from_user_id=42')
|
||||||
|
assert.equal(sharing.sharingPath(42,true),'/pages/home/information?id=42')
|
||||||
|
for(const id of [0,-1,'bad',1.5])assert.throws(()=>sharing.sharingPath(id,true))
|
||||||
|
await assert.rejects(sharing.renderSharingPoster({$wx:{}},{},false))
|
||||||
|
const personal=h.page('shareCard');await personal.startSharing()
|
||||||
|
assert.equal(personal.poster,'');assert(!h.calls.some(x=>x.url&&x.url.endsWith('/code')),'must wait for onReady')
|
||||||
|
personal.hook('onReady');await settle(()=>personal.poster&&!personal.generating,'initial render')
|
||||||
|
assert(h.draws.some(x=>x[1]==='扫码认识我'));assert(h.draws.some(x=>x[1]==='synthetic-photo'));assert(h.draws.some(x=>x[0]==='text'&&x[1].endsWith('…')))
|
||||||
|
assert(personal.cached);assert(personal.poster.startsWith('/synthetic-user/'))
|
||||||
|
const requestCount=h.calls.filter(x=>x.url&&x.url.endsWith('/code')).length,exportCount=h.state.exportID
|
||||||
|
const revisit=h.page('shareCard');revisit.hook('onReady');await revisit.startSharing()
|
||||||
|
assert(revisit.poster);assert(revisit.cached);assert.equal(h.state.exportID,exportCount);assert.equal(h.calls.filter(x=>x.url&&x.url.endsWith('/code')).length,requestCount)
|
||||||
|
h.draws.length=0;const invite=h.page('myShare');invite.hook('onReady');await invite.startSharing()
|
||||||
|
assert(invite.poster);assert(h.draws.some(x=>x[1]==='扫码加入福恋'));assert(!h.draws.some(x=>x[1]==='synthetic-photo'))
|
||||||
|
for(const response of [{statusCode:404,data:{}},{statusCode:500,data:'html'},{statusCode:200,data:{code:9}},{statusCode:200,data:{code:2}}]){h.flags.response=response;await assert.rejects(runtime.sharingRequest('https://synthetic.invalid/user/profile'))}
|
||||||
|
delete h.flags.response;h.flags.requestHang=true;await assert.rejects(runtime.sharingRequest('https://synthetic.invalid/user/profile'));assert(h.state.abort>0)
|
||||||
|
console.log('PASS: Canvas2D after onReady, first render and persistent cache reuse, purpose separation, truncation, HTTP/auth/unknown-code errors and missing callback timeout.')
|
||||||
|
})().catch(error=>{console.error(error.stack);process.exitCode=1})
|
||||||
63
tools/sharing/harness.cjs
Normal file
63
tools/sharing/harness.cjs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
const fs=require('fs'),path=require('path'),vm=require('vm')
|
||||||
|
const ROOT=path.resolve(__dirname,'../..')
|
||||||
|
function createHarness(){
|
||||||
|
const storage=new Map([['token','synthetic-token'],['user_id',42],['userInfo',{id:42}]]),files=new Map(),calls=[],draws=[]
|
||||||
|
const flags={},state={profile:{id:42,nickname:'合成小林',photo:'avatar-not-for-card',photos:['synthetic-photo'],city:'合成城市',degree:'本科',industry:'设计',introduction:'喜欢阅读和认真生活。'.repeat(20),ideal_mate:'真诚沟通'},overview:{total:12,verified:7,can_apply:true},abort:0,exportID:0}
|
||||||
|
const copy=value=>value&&typeof value==='object'?JSON.parse(JSON.stringify(value)):value
|
||||||
|
const later=fn=>queueMicrotask(fn)
|
||||||
|
const ctx={fillRect(){},beginPath(){},moveTo(){},lineTo(){},arcTo(){},closePath(){},fill(){},save(){},restore(){},clip(){},arc(){},stroke(){},drawImage(image){draws.push(['image',image.src])},fillText(text){draws.push(['text',text])},measureText(text){return {width:Array.from(text).length*25}}}
|
||||||
|
const canvases={}
|
||||||
|
function canvas(id){if(!canvases[id])canvases[id]={id,width:750,height:1120,getContext(){return ctx},createImage(){const image={width:500,height:500};Object.defineProperty(image,'src',{set(value){this.value=value;if(!flags.imageHang)later(()=>(flags.imageFail||flags.failImageSrc===value||(flags.packageRelative&&value.startsWith('pages/')))?this.onerror():this.onload())},get(){return this.value}});return image}};return canvases[id]}
|
||||||
|
const fileSystem={
|
||||||
|
access({path,success,fail}){later(()=>files.has(path)?success():fail({}))},
|
||||||
|
writeFile({filePath,data,success,fail}){later(()=>{if(flags.writeFail)return fail({});files.set(filePath,data);success()})},
|
||||||
|
copyFile({srcPath,destPath,success,fail}){later(()=>{if(flags.copyFail||!files.has(srcPath))return fail({});files.set(destPath,files.get(srcPath));success()})},
|
||||||
|
unlink({filePath}){files.delete(filePath)}
|
||||||
|
}
|
||||||
|
const wx={env:{USER_DATA_PATH:'/synthetic-user'},getStorageSync(k){return copy(storage.get(k))},setStorageSync(k,v){if(flags.storageFail)throw Error('synthetic');storage.set(k,copy(v))},removeStorageSync(k){storage.delete(k)},getFileSystemManager(){return fileSystem},
|
||||||
|
request(options){calls.push(options);const task={abort(){state.abort++}};if(flags.requestHang)return task;later(()=>{
|
||||||
|
if(flags.requestFail)return options.fail({})
|
||||||
|
if(flags.response)return options.success(flags.response)
|
||||||
|
if(flags.route){const result=flags.route(options);if(result==='hang')return;if(result)return options.success(result)}
|
||||||
|
let data
|
||||||
|
if(options.url.endsWith('/profile'))data=state.profile
|
||||||
|
else if(options.url.endsWith('/overview'))data=state.overview
|
||||||
|
else if(options.url.endsWith('/invitees'))data={page:options.data.page,has_more:options.data.page===1,data:options.data.page===1?[{id:71,nickname:'合成用户',verified:true}]:[]}
|
||||||
|
else if(options.url.endsWith('/code'))data={mime:'image/png',image:'c3ludGhldGljLXFy'}
|
||||||
|
else throw Error('Unexpected request in test')
|
||||||
|
options.success({statusCode:200,data:{code:0,data:copy(data)}})
|
||||||
|
});return task},
|
||||||
|
createSelectorQuery(){let id;const q={in(){return q},select(value){id=value;return q},fields(){return q},exec(cb){if(!flags.canvasHang)later(()=>cb(flags.canvasMissing?[]:[{node:canvas(id)}]))}};return q},
|
||||||
|
getImageInfo({src,success,fail}){if(!flags.imageInfoHang)later(()=>flags.imageInfoFail?fail({}):success({path:flags.packageRelative&&src.startsWith('/pages/')?src.slice(1):src,width:500,height:500}))},
|
||||||
|
canvasToTempFilePath({canvas,success,fail}){if(!flags.exportHang)later(()=>{if(flags.exportFail)return fail({});const file='/tmp/export-'+(++state.exportID)+'.png';files.set(file,'rendered-'+canvas.id);success({tempFilePath:file})})},
|
||||||
|
saveImageToPhotosAlbum({success,fail}){later(()=>flags.saveFail?fail({}):success({}))},showToast(value){calls.push({toast:value})},showModal(value){calls.push({modal:value})},previewImage(value){calls.push({preview:value})}
|
||||||
|
}
|
||||||
|
const modules=new Map()
|
||||||
|
const context=vm.createContext({wx,console,setTimeout:(fn,ms)=>setTimeout(fn,Math.min(ms,50)),clearTimeout,queueMicrotask})
|
||||||
|
function load(filename){
|
||||||
|
filename=path.resolve(filename)
|
||||||
|
if(filename===path.join(ROOT,'src/config.js'))return {service:{user:'https://synthetic.invalid/user'}}
|
||||||
|
if(modules.has(filename))return modules.get(filename)
|
||||||
|
let source=fs.readFileSync(filename,'utf8'),names=[]
|
||||||
|
source=source.replace(/^import\s+(.+?)\s+from\s+['"](.+?)['"];?$/gm,(_,binding,ref)=>{const target=path.resolve(path.dirname(filename),ref)+'.js';return binding.startsWith('{')?`const ${binding}=__load(${JSON.stringify(target)})`:`const ${binding}=__load(${JSON.stringify(target)}).default`})
|
||||||
|
source=source.replace(/export default function (\w+)/g,'exports.default = function $1').replace(/export (async )?function (\w+)/g,(_,async,name)=>{names.push(name);return (async||'')+'function '+name})
|
||||||
|
source+='\n'+names.map(name=>`exports.${name}=${name}`).join(';')
|
||||||
|
const exports={};modules.set(filename,exports)
|
||||||
|
vm.runInContext('(function(exports,__load){'+source+'\n})',context,{filename})(exports,load)
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
function page(name){
|
||||||
|
const filename=path.join(ROOT,'src/pages/users',name+'.wpy')
|
||||||
|
let source=fs.readFileSync(filename,'utf8').match(/<script>([\s\S]*?)<\/script>/)[1],definition
|
||||||
|
source=source.replace(/^import\s+(.+?)\s+from\s+['"](.+?)['"];?$/gm,(_,binding,ref)=>{if(ref==='@wepy/core')return 'const wepy=__wepy';const target=path.resolve(path.dirname(filename),ref)+'.js';return binding.startsWith('{')?`const ${binding}=__load(${JSON.stringify(target)})`:`const ${binding}=__load(${JSON.stringify(target)}).default`})
|
||||||
|
vm.runInContext('(function(__load,__wepy){'+source+'\n})',context,{filename})(load,{page(value){definition=value}})
|
||||||
|
const entries=[...(definition.mixins||[]),definition],instance={$wx:{}}
|
||||||
|
for(const entry of entries){Object.assign(instance,copy(entry.data||{}),entry.methods||{})}
|
||||||
|
instance.hook=name=>{for(const entry of entries)if(entry[name])entry[name].call(instance)}
|
||||||
|
instance.definition=definition
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
return {wx,state,flags,storage,files,calls,draws,load:name=>load(path.join(ROOT,'src',name+'.js')),page}
|
||||||
|
}
|
||||||
|
async function settle(test,message='condition'){for(let i=0;i<80;i++){if(test())return;await new Promise(r=>setTimeout(r,5))}throw Error('Timed out waiting for '+message)}
|
||||||
|
module.exports={createHarness,settle,ROOT}
|
||||||
36
tools/sharing/regression.cjs
Normal file
36
tools/sharing/regression.cjs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const fs=require('fs'),path=require('path'),vm=require('vm'),assert=require('assert')
|
||||||
|
const root=path.resolve(__dirname,'../..')
|
||||||
|
function script(name){return fs.readFileSync(path.join(root,'src',name),'utf8').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm,'')}
|
||||||
|
const invitation=fs.readFileSync(path.join(root,'src/utils/invitation.js'),'utf8').replace(/export /g,'')
|
||||||
|
const s={};vm.createContext(s);vm.runInContext(invitation,s)
|
||||||
|
assert.equal(s.invitationFromOptions({query:{scene:'from_user_id%3D42'}}),'42')
|
||||||
|
assert.equal(s.invitationFromOptions({query:{from_user_id:43}}),'43')
|
||||||
|
for(const query of [{scene:'%'},{scene:'id=42'},{scene:'from_user_id=-1'},{from_user_id:'invalid'},{}])assert.equal(s.invitationFromOptions({query}),'')
|
||||||
|
const storage=new Map(),nav=[],previews=[]
|
||||||
|
let appDef,pageDef
|
||||||
|
const wx={setStorageSync(k,v){storage.set(k,v)},getStorageSync(k){return storage.get(k)},removeStorageSync(k){storage.delete(k)},switchTab(x){nav.push(x)},getSystemInfo({success}){success({screenHeight:800,safeArea:{bottom:780}})},getSystemInfoSync(){return {screenWidth:375,windowHeight:700,statusBarHeight:20}},getMenuButtonBoundingClientRect(){return {left:280,top:24,height:32,bottom:56}},navigateTo(x){nav.push(x)},previewImage(x){previews.push(x)},showToast(){}}
|
||||||
|
const context={...s,wx,console:{log(){}},wepy:{use(){},app(x){appDef=x},page(x){pageDef=x}},vuex:{},promisify:{},initEid(){},https:{},service:{host:'synthetic-host'},base:{},getApp(){return {$wepy:{$options:{globalData:{systemInfo:{system:'synthetic OS'}}}}}}}
|
||||||
|
vm.createContext(context);vm.runInContext(script('app.wpy'),context)
|
||||||
|
const app={$options:{globalData:{}},appLogin(){assert.equal(storage.get('from_user_id'),'42')}}
|
||||||
|
appDef.onLaunch.call(app,{query:{scene:'from_user_id%3D42'}})
|
||||||
|
appDef.onShow.call(app,{path:'pages/tabBar/welcome',query:{scene:'from_user_id%3D42'}});assert.equal(storage.get('from_user_id'),'42')
|
||||||
|
appDef.onShow.call(app,{path:'pages/sub_mall/confirmOrder',query:{}});assert.equal(storage.get('from_user_id'),'42')
|
||||||
|
appDef.onShow.call(app,{path:'pages/tabBar/home',query:{}});assert.equal(storage.has('from_user_id'),false)
|
||||||
|
appDef.onShow.call(app,{path:'pages/tabBar/welcome',query:{scene:'%'}})
|
||||||
|
appDef.onShow.call(app,{path:'pages/tabBar/welcome',query:{scene:'userId=88'}});assert.equal(storage.get('agent_user_id'),'88')
|
||||||
|
vm.runInContext(script('pages/tabBar/user.wpy'),context)
|
||||||
|
assert.deepEqual(Array.from(pageDef.data.list,x=>x.title),['会员中心','我的发布','我的邀请','我的卡片','更多','设置'])
|
||||||
|
assert.equal(pageDef.data.list[0].path,'/pages/users/upgradeVIP');assert.equal(pageDef.data.list[1].path,'/pages/users/myDynamic');assert.equal(pageDef.data.list[5].path,'/pages/users/setTing')
|
||||||
|
vm.runInContext(script('pages/users/more.wpy'),context)
|
||||||
|
const more={...pageDef.data,...pageDef.methods,$get:async()=>({data:{qrcode:'synthetic-assigned-code'}})}
|
||||||
|
;(async()=>{
|
||||||
|
await more.openService();assert.equal(previews[0].current,'synthetic-assigned-code')
|
||||||
|
more.$get=async()=>({data:{}});await more.openService();assert.equal(nav.pop().url,'/pages/users/exclusiveService')
|
||||||
|
more.$get=async()=>{throw Error('synthetic')};await more.openService();assert.equal(more.serviceLoading,false)
|
||||||
|
more.guide();assert(nav.pop().url.includes(encodeURIComponent('https://love.ufutx.com/h5/#/guide')))
|
||||||
|
const configText=fs.readFileSync(path.join(root,'src/app.wpy'),'utf8').match(/<config>([\s\S]*?)<\/config>/)[1]
|
||||||
|
const config=vm.runInNewContext('('+configText+')')
|
||||||
|
const users=config.subPackages.find(x=>x.root==='pages/users')
|
||||||
|
for(const name of ['more','myShare','shareCard','myDynamic','exclusiveService','setTing'])assert.equal(users.pages.filter(x=>x===name).length,1)
|
||||||
|
console.log('PASS: cold-start attribution, malformed QR, old agent/shopping paths, menu order, preserved member/publish/settings routes, assigned customer service, route registration.')
|
||||||
|
})().catch(e=>{console.error(e.stack);process.exitCode=1})
|
||||||
@ -6,7 +6,7 @@ module.exports = {
|
|||||||
wpyExt: '.wpy',
|
wpyExt: '.wpy',
|
||||||
// eslint: true,
|
// eslint: true,
|
||||||
cliLogs: !prod,
|
cliLogs: !prod,
|
||||||
static: ['./src/images'],
|
static: ['./src/images', './src/pages/users/assets'],
|
||||||
build: {},
|
build: {},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user