diff --git a/package.json b/package.json
index fcf742b..01b1967 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"scripts": {
"start": "npm run dev",
"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",
"lint": "eslint --ext .js,.wpy src/",
"lint:fix": "eslint --ext .js,.wpy src/ --fix",
diff --git a/project.config.json b/project.config.json
index 1598d35..780adca 100644
--- a/project.config.json
+++ b/project.config.json
@@ -14,7 +14,7 @@
"enhance": true
},
"compileType": "miniprogram",
- "appid": "wxe486777f4379507e",
+ "appid": "wxc41491431733671e",
"projectname": "myproj",
"miniprogramRoot": "weapp/",
"srcMiniprogramRoot": "weapp/",
diff --git a/src/mixins/https.js b/src/mixins/https.js
index ccbee49..a68e418 100644
--- a/src/mixins/https.js
+++ b/src/mixins/https.js
@@ -97,7 +97,6 @@ export default {
wx.removeStorageSync('message')
wx.stopPullDownRefresh()
// 控制台调试日志
- console.log('[SUCCESS]', statusCode, typeof data === 'object' ? data : data.toString().substring(0, 100))
// 状态码正常 & 确认有数据
if (data.code === 0) {
// 成功回调
@@ -122,14 +121,13 @@ export default {
})
}
let errorMessage = {message: '错误,请寻找开发人员协助'}
- throw errorMessage
+ wx.hideLoading()
+ return
wx.hideLoading()
} else if (data.code === 2) {
- console.error(data.message)
vm.errorTwo += 1
if (vm.errorTwo <= 2) {
utils.wx_login().then((data) => {
- console.log(data, '444')
let route = `/${currentPage.route}?${url}`
if (!data.data.token) {
wx.removeStorageSync('information')
@@ -142,7 +140,6 @@ export default {
route = route.split('?')[0]
wx.switchTab({url: route})
} else {
- console.log('222')
wx.redirectTo({url: route})
}
}
@@ -155,7 +152,6 @@ export default {
throw errorMessage
} else if (code === 3) {
utils.wx_login().then((data) => {
- console.log(data, '444')
let route = `/${currentPage.route}?${url}`
if (!data.data.token) {
wx.removeStorageSync('information')
@@ -168,7 +164,6 @@ export default {
route = route.split('?')[0]
wx.switchTab({url: route})
} else {
- console.log('222')
wx.redirectTo({url: route})
}
}
@@ -191,7 +186,6 @@ export default {
fail: (err) => {
// eslint-disable-next-line prefer-promise-reject-errors
reject(err)
- console.error('[ERROR]', err)
},
complete: (res) => {
setTimeout(() => {
@@ -221,7 +215,6 @@ export default {
}
})
}
- console.log('当前网络不稳定!请重试...')
let errorMessage = {message: '当前网络不稳定!请重试'}
throw errorMessage
}
@@ -231,8 +224,6 @@ export default {
}
},
created() {
- console.log(this.i18nL)
- console.log('created in mixin', '插件打印')
let vm = this
let cacheBodyData = wx.getStorageSync('cacheBodyData')
if (cacheBodyData && vm.$app.$options.globalData.resubmit == 1) {
@@ -240,7 +231,6 @@ export default {
data: cacheBodyData
}
vm.$app.$options.globalData.resubmit = 2
- console.log(data, '提交数组数据***********')
vm.$post({url: `${service.host}/save/weak/network/fat`, data}).then(({code, data}) => {
if (code === 0) {
vm.app.globalData.bodyFatData = null
@@ -249,7 +239,6 @@ export default {
}
wx.hideLoading()
}).catch(err => {
- console.log(err)
})
}
}
diff --git a/src/pages/tabBar/home.wpy b/src/pages/tabBar/home.wpy
index 3bd4a9c..51e7618 100644
--- a/src/pages/tabBar/home.wpy
+++ b/src/pages/tabBar/home.wpy
@@ -397,10 +397,6 @@ page {
-
-
-
-
置顶推荐
diff --git a/src/pages/users/upgradeVIP.wpy b/src/pages/users/upgradeVIP.wpy
index 50de8b8..6817d54 100644
--- a/src/pages/users/upgradeVIP.wpy
+++ b/src/pages/users/upgradeVIP.wpy
@@ -27,7 +27,7 @@
-
+ {{item.name}}{{purchaseEnabled ? '购买前确认价格' : '会员购买暂不可用'}}
@@ -70,16 +70,17 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
+ 查询最近会员订单
- 合计: ¥{{ payData.discount_price }}
+ {{purchaseEnabled ? '点击查看当前套餐价格' : '会员购买暂不可用'}}
- 立即购买
+ {{purchasing ? '正在确认订单' : (purchaseEnabled ? '购买会员' : '暂不可购买')}}
@@ -114,11 +115,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
{{ deductionPrice > 0 ? deductionPrice : '0.00' }}
-
- 原价:¥{{
- payData.price
- }}
-
+
{{ payData.title }}
@@ -127,7 +124,7 @@ background: linear-gradient(270deg, #E6C7AB 0%, #F5E6D8 100%);">
- 微信支付
+ {{system === 'iOS' ? 'Apple 支付' : '微信支付'}}
@@ -168,6 +165,7 @@ import wepy from '@wepy/core'
import https from '../../mixins/https'
import base from '../../mixins/base'
import {service} from '../../config.js'
+import {buyMembership} from '../../utils/virtual-payment'
const app = getApp().$wepy.$options
@@ -198,6 +196,9 @@ wepy.page({
modalName: '',
chat_user_id: '',
trade_no: '',
+ purchaseEnabled: false,
+ purchasing: false,
+ virtualProducts: [],
system: '', // 系统类型 iOS/Android
share_type: '',
shareId: '',
@@ -230,6 +231,7 @@ wepy.page({
this.rankList = []
// 获取系统类型
this.system = app.globalData.systemInfo.system.split(' ')[0]
+ this.refreshPurchase()
this.getPageData()
},
@@ -263,6 +265,24 @@ wepy.page({
},
methods: {
+ 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:'支付结果仍在确认中,请勿重复购买;如已扣款请联系客服核对。',showCancel:false})}
+ } catch (e) {wx.showModal({title:'会员订单',content:e.message || '查询失败,请稍后重试',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 = []}
+ },
gotoApp(item) {
let URL = `https://love.ufutx.com/h5/#/serviceAgreementV3?title=${item}`
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
@@ -278,6 +298,7 @@ wepy.page({
data.ranks.sub_ranks.forEach((item) => {
vm.rankList.push(item)
})
+ if (!vm.rankList.length) {wx.hideLoading();return}
vm.payData.title = `超级会员(${vm.rankList[0].name})`
vm.payData.price = `${vm.rankList[0].price}`
vm.payData.discount_price = `${vm.rankList[0].discount_price}`
@@ -312,28 +333,19 @@ wepy.page({
}
}, 800)
},
- showFn(index) {
- if (!this.$signInVerify()) {
- this.loginShow = true
- return
- }
- // 移除原来的iOS禁止支付逻辑
+ async showFn(index) {
+ if (this.purchasing) return
+ if (!this.$signInVerify()) {this.loginShow = true; return}
+ await this.refreshPurchase()
+ if (!this.purchaseEnabled) {wx.showToast({title:'会员购买暂不可用',icon:'none'});return}
+ 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.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) {
- 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'
- }
+ this.payData.payId = item.id
+ this.payData.discount_price = (product.price_fen / 100).toFixed(2)
+ this.payData.title = item.name
+ this.modalName = 'DialogModal'
},
hideModal() {
this.modalName = ''
@@ -356,143 +368,17 @@ wepy.page({
showTost() {
this.$showToast('请同意《福恋服务协议》')
},
- // ======================================
-// 核心支付方法(Android原接口,iOS新虚拟支付接口)
-// ======================================
- conversion(item) {
- let that = this
- that.modalName = ''
- that.$showLoading('支付中...')
-
- // 根据系统类型选择不同的接口和参数
- if (that.system === 'iOS') {
- // 先获取微信登录 code
- 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'})
- }
- })
- })
- }
+ async conversion() {
+ if (this.purchasing) return
+ this.purchasing = true
+ this.modalName = ''
+ try {
+ 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) })
+ this.getPageData()
+ wx.removeStorageSync(this.pendingStorageKey())
+ wx.showToast({title:'会员权益已到账',icon:'success'})
+ } catch (e) {wx.showModal({title:'会员支付',content:e.message || '支付暂不可用,请稍后查询订单',showCancel:false})}
+ finally {this.purchasing = false;wx.hideLoading()}
},
payChoice(e) {
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
diff --git a/src/pages/users/upgradeVIP2.wpy b/src/pages/users/upgradeVIP2.wpy
index 6e0fee6..81079b5 100644
--- a/src/pages/users/upgradeVIP2.wpy
+++ b/src/pages/users/upgradeVIP2.wpy
@@ -53,19 +53,7 @@
-
-
-
- ¥{{specialVIP.discount_price}}
-
-
-
-
-
- 立即购买
-
-
-
+ 单项服务暂不可购买
@@ -116,7 +104,7 @@
- 立即支付
+ 暂不可购买
@@ -196,15 +184,12 @@
}
if (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) {
this.chat_user_id = e.chat_user_id
}
- console.log(this.chat_user_id, '99999')
this.rankList = []
this.system = wx.getStorageSync('system')
- console.log(this.system)
this.initPageData()
},
@@ -215,7 +200,6 @@
let openid = wx.getStorageSync('openid')
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}`
- console.log(url)
return {
title: that.shareText,
path: url,
@@ -239,9 +223,7 @@
watch: {
cardCur() {
- console.log('payData', this.cardCur)
// if (this.active == 1) {
- console.log(this.rankList[this.cardCur])
this.payData.title = this.rankList[this.cardCur].title
this.payData.price = this.rankList[this.cardCur].price
this.payData.discount_price = this.rankList[this.cardCur].discount_price
@@ -271,8 +253,7 @@
// rank_id: data.rank_id
// }
// wx.setStorageSync('userInfo', userInfo)
- console.log(data)
- })
+ })
},
// 判断是否json
isValidJSON(str) {
@@ -296,12 +277,17 @@
}
})
} catch (e) {}
- that.rankList = data.services.map(item => {
+ that.rankList = (Array.isArray(data.services) ? data.services : []).map(item => {
if (that.isValidJSON(item.feature)) {
item.feature = JSON.parse(item.feature)
}
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.payData.title = `${that.rankList[that.cardCur_1].title}`
that.payData.price = `${that.rankList[that.cardCur_1].price}`
@@ -312,10 +298,10 @@
if (wx.getStorageSync('from_user_id')) {
// that.worthShare()
}
- wx.hideLoading()
- console.log(that.payData, 'rankList')
- console.log(that.rankList, 'rankList')
- })
+ }).catch(() => {
+ that.rankList = []
+ that.specialVIP = {feature: [], discount_desc: '服务套餐加载失败,请稍后重试;单项服务暂不可购买'}
+ }).finally(() => wx.hideLoading())
},
worthShare() {
let vm = this,
@@ -333,48 +319,21 @@
wx.navigateTo({url: '/pages/books/bookDetail?url=' + encodeURIComponent(URL)})
},
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.sharePic = this.rankList[e.$wx.detail.current].pic
// if (this.active == 1) {
- console.log(this.cardCur)
this.specialVIP = this.rankList[this.cardCur]
// }
clearTimeout(this.timeID)
this.timeID = setTimeout(() => {
this.shareId = this.rankList[this.cardCur].id
- console.log(this.shareId)
if (wx.getStorageSync('from_user_id')) {
this.worthShare()
}
}, 800)
},
- showFn (index) { // 弹框支付
- 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, '===')
- },
+ showFn() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
hideModal () {
this.modalName = ''
},
@@ -397,75 +356,7 @@
showTost() {
this.$showToast('请同意《福恋服务协议》')
},
- conversion(item) {
- 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)
- })
- },
+ conversion() {wx.showToast({title:'单项服务暂不可购买',icon:'none'})},
payChoice(e) {
this.wxPayShow = this.wxPayShow == 'coin' ? 'cash' : e
},
@@ -506,6 +397,7 @@
._bc-user{
width: 100%;
background: url('https://images.ufutx.com/202004/09/5fd9264a4da7c310d1e086a92be7195a.png');
+ background-color: #191919;
background-size: cover;
background-repeat: no-repeat;
position: relative;
@@ -635,29 +527,20 @@
letter-spacing: 8rpx;
}
.payBtn{
- position: relative;
position: fixed;
bottom: 0;
- left: 50%;
- transform: translate(-50%);
- width: 480rpx;
- height: 60px;
- background-image: url("https://images.ufutx.com/202007/17/fb7b8b1cd0d1b289d1e029257a97e949.png");
- background-size: contain;
- background-repeat: no-repeat;
- .l_text{
- position: absolute;
- left: 60rpx;
- top: 12rpx;
- }
- .r_text{
- color: #f8e8b2;
- position: absolute;
- right: 56rpx;
- top: 12rpx;
- }
+ left: 0;
+ width: 100%;
+ padding: 28rpx 24rpx calc(28rpx + env(safe-area-inset-bottom));
+ box-sizing: border-box;
+ background: #fff;
+ color: #666;
+ text-align: center;
+ font-size: 30rpx;
+ z-index: 10;
}
}
+
.bg-vip{
background: #333333;
color: white;
diff --git a/src/utils/virtual-payment.js b/src/utils/virtual-payment.js
new file mode 100644
index 0000000..7597e3d
--- /dev/null
+++ b/src/utils/virtual-payment.js
@@ -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('支付结果确认中,请稍后查询订单,勿重复购买')
+}
diff --git a/tools/build/README.txt b/tools/build/README.txt
new file mode 100644
index 0000000..1ab4092
--- /dev/null
+++ b/tools/build/README.txt
@@ -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。
diff --git a/tools/build/build.cjs b/tools/build/build.cjs
new file mode 100644
index 0000000..13cd221
--- /dev/null
+++ b/tools/build/build.cjs
@@ -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')
diff --git a/tools/build/fix-template-expressions.cjs b/tools/build/fix-template-expressions.cjs
new file mode 100644
index 0000000..5ab8473
--- /dev/null
+++ b/tools/build/fix-template-expressions.cjs
@@ -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 + '}}'
+ })
+}
diff --git a/tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt b/tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt
new file mode 100644
index 0000000..6a4c080
--- /dev/null
+++ b/tools/local-acceptance/MEMBERSHIP-ACCEPTANCE.txt
@@ -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 分支的已有变更,未提交、合并或发布。
diff --git a/tools/local-acceptance/Open-DevTools.ps1 b/tools/local-acceptance/Open-DevTools.ps1
new file mode 100644
index 0000000..00eac8d
--- /dev/null
+++ b/tools/local-acceptance/Open-DevTools.ps1
@@ -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.
diff --git a/tools/local-acceptance/README.txt b/tools/local-acceptance/README.txt
new file mode 100644
index 0000000..5903ffc
--- /dev/null
+++ b/tools/local-acceptance/README.txt
@@ -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 语法检查及隔离测试通过。
+未执行预览上传;预览码生成结果仍由用户重新点击预览验证。本次未修改业务源码或生产配置。
diff --git a/tools/local-acceptance/api.test.cjs b/tools/local-acceptance/api.test.cjs
new file mode 100644
index 0000000..65c94de
--- /dev/null
+++ b/tools/local-acceptance/api.test.cjs
@@ -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})
diff --git a/tools/local-acceptance/build.cjs b/tools/local-acceptance/build.cjs
new file mode 100644
index 0000000..8aeae1d
--- /dev/null
+++ b/tools/local-acceptance/build.cjs
@@ -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>)?/g,'本地模拟:外部网页已禁用');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'),'福恋 · 本地模拟验收合成数据,不访问后端或第三方服务。远程图片已替换,外部字体可能与正式版不同。支付、认证、上传不属于本轮可验收范围。重新编译回到此入口。测试注册验证码:000000。不要输入真实个人资料。')
+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本地模拟 · 合成数据\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}))
diff --git a/tools/local-acceptance/devtools-membership.test.cjs b/tools/local-acceptance/devtools-membership.test.cjs
new file mode 100644
index 0000000..2269cc1
--- /dev/null
+++ b/tools/local-acceptance/devtools-membership.test.cjs
@@ -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});
diff --git a/tools/local-acceptance/fix-template-expressions.cjs b/tools/local-acceptance/fix-template-expressions.cjs
new file mode 100644
index 0000000..ee34fe9
--- /dev/null
+++ b/tools/local-acceptance/fix-template-expressions.cjs
@@ -0,0 +1,2 @@
+'use strict'
+module.exports = require('../build/fix-template-expressions.cjs')
diff --git a/tools/local-acceptance/im.js b/tools/local-acceptance/im.js
new file mode 100644
index 0000000..fa2e56e
--- /dev/null
+++ b/tools/local-acceptance/im.js
@@ -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()}}
+}
diff --git a/tools/local-acceptance/pages.test.cjs b/tools/local-acceptance/pages.test.cjs
new file mode 100644
index 0000000..7260c18
--- /dev/null
+++ b/tools/local-acceptance/pages.test.cjs
@@ -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(/