feat: redesign invitation tabs and cache personal sharing posters

This commit is contained in:
Glore Zeng 2026-11-02 21:42:02 +08:00
parent 77cb04964a
commit 1321aa4391
16 changed files with 715 additions and 373 deletions

View File

@ -0,0 +1,77 @@
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:'正在准备海报',cacheNote:'',cached:false,saving:false},
onReady(){this.canvasReady=true;this.ensurePoster()},
onUnload(){this._sharingDisposed=true;if(this._sharingJob)this._sharingJob.cancelled=true},
methods:{
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.cacheNote='已缓存,下次打开可直接使用'},
async loadSharingProfile(force=false){
if(this._sharingDisposed)return
this.profileLoading=true;this.profileError=''
try{
const data=await sharingRequest(`${service.user}/sharing/profile`)
if(this._sharingDisposed)return
const profile=displayProfile(data)
if(kind==='personal')profile.photo=await selectSharingPhoto(profile.photos)
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
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.posterStage='正在准备分享图片'
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.posterStage='正在生成专属海报'
const data=await sharingRequest(`${service.user}/sharing/code`,'POST',{kind})
active();profile.qrcode=await storeSharingCode(data,kind,profile.id)
active();const poster=await renderSharingPoster(this,profile,kind==='personal',job)
active();const assets={poster,cover,qrcode:profile.qrcode}
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.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}
}
}
}
}

View File

@ -366,13 +366,13 @@ wepy.page({
}, },
{ {
icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png', icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png',
title: '我的分享', title: '我的邀请',
type: 'navigateTo', type: 'navigateTo',
path: '/pages/users/myShare' path: '/pages/users/myShare'
}, },
{ {
icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png', icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png',
title: '分享我的卡片', title: '我的卡片',
type: 'navigateTo', type: 'navigateTo',
path: '/pages/users/shareCard' path: '/pages/users/shareCard'
}, },
@ -769,7 +769,7 @@ wepy.page({
vm.getServiceCode(url) vm.getServiceCode(url)
return return
} }
if (text === '分享我的卡片') { if (text === '我的卡片') {
wx.navigateTo({url: '/pages/users/shareCard'}) wx.navigateTo({url: '/pages/users/shareCard'})
return return
} }

View File

@ -1,139 +1,93 @@
<template> <template>
<view class="sharing-page"> <view class="sharing-page invitation-page">
<view class="eyebrow">把美好的相遇分享出去</view><view class="sharing-title">我的分享</view> <view class="page-intro"><view class="intro-label">分享一份美好的可能</view><view class="intro-title">邀请朋友,认真相遇</view></view>
<view v-if="loading" class="sharing-state">正在加载邀请数据…</view> <view class="invitation-tabs">
<view v-else-if="error" class="sharing-state"><view>{{error}}</view><button @tap="load">重试</button></view> <button :class="{'invitation-tab':true,'is-active':activeTab === 'poster'}" @tap="showPosterTab">专属邀请</button>
<block v-else> <button :class="{'invitation-tab':true,'is-active':activeTab === 'results'}" @tap="showResultsTab">邀请成果</button>
<view class="sharing-stats"><view class="sharing-stat"><text class="sharing-number">{{total}}</text>邀请用户总数</view><view class="sharing-stat"><text class="sharing-number">{{verified}}</text>真人认证人数</view></view> </view>
<view class="sharing-section">申请婚恋使者</view> <view v-show="activeTab === 'poster'" class="share-panel">
<view class="sharing-state">邀请的用户中有7位通过真人认证,即可联系荣耀老师申请婚恋使者。</view> <view class="section-top"><text class="panel-caption">我的专属邀请</text><button class="text-button" :disabled="generating || profileLoading" @tap="refreshPoster">更新海报</button></view>
<view class="sharing-subtitle">已完成 {{verified}} 位<text v-if="remaining > 0">,还差 {{remaining}} 位</text></view> <view class="invite-stage">
<button :disabled="!canApply || checkingEligibility" @tap="openApplication">{{checkingEligibility ? '正在确认资格…' : '申请婚恋使者'}}</button> <view class="invite-poster-frame" @tap="preview">
<view v-if="eligibilityError" class="sharing-state">申请资格查询失败,请稍后重试<button @tap="refreshEligibility">重新查询</button></view> <image v-if="poster" class="invite-poster-image" src="{{poster}}" mode="widthFix" @error="posterImageFailed"></image>
<view class="sharing-section">邀请用户</view> <view v-else class="poster-placeholder"><image src="/pages/users/assets/fulian-logo.png" mode="aspectFit"></image><text>邀请你加入福恋</text><text class="placeholder-small">上福恋认真找对象</text></view>
<view v-if="!invitees.length && !listError" class="sharing-state">还没有邀请用户,分享福恋给身边的朋友吧。</view> </view>
<view class="sharing-row" v-for="item in invitees" :key="item.id"><text>{{item.nickname || '福恋用户'}}</text><text class="sharing-status">{{item.verified ? '真人认证已通过' : '未完成真人认证'}}</text></view> </view>
<view v-if="listError" class="sharing-state">列表加载失败,请重试</view> <view class="preview-caption">点击海报 · 查看大图</view>
<button v-if="hasMore || listError" :disabled="listLoading" @tap="loadMore">{{listLoading ? '正在加载…' : listError ? '重试加载' : '加载更多'}}</button> <view v-if="generating || (profileLoading && !poster)" class="asset-status">{{profileLoading ? '正在准备你的邀请' : posterStage}}</view>
<view class="sharing-section">邀请订单</view><view class="sharing-state">仅婚恋使者可查看</view> <view v-else-if="posterError || profileError" class="asset-error"><text>{{posterError || profileError}}</text><button class="text-button" @tap="refreshPoster">重新生成</button></view>
<view class="sharing-section">邀请朋友加入福恋</view> <view v-else-if="cacheNote" class="asset-status ready-status">{{cacheNote}}</view>
<view class="sharing-subtitle">把福恋分享给认真寻找爱情的人。</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>
<image v-if="poster" class="sharing-poster" src="{{poster}}" mode="widthFix" @tap="preview"></image> </view>
<view v-if="posterError" class="sharing-state">{{posterError}}</view>
<view class="sharing-actions"><button :disabled="generating || saving" @tap="posterAction">{{generating ? '正在生成…' : saving ? '正在保存…' : poster ? '保存邀请海报' : '生成邀请海报'}}</button><button open-type="share" :disabled="!profile.id">分享小程序卡片</button></view> <view v-show="activeTab === 'results'" class="results-tab">
</block> <view class="results-panel">
<view v-if="showApplication" class="application-mask" @tap="closeApplication"> <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 class="application-dialog" @tap.stop="keepApplicationOpen"> <view v-if="overviewError" class="asset-error"><text>邀请数据暂未加载成功</text><button class="text-button" @tap="loadOverview">重试</button></view>
<view class="application-title">联系荣耀老师</view> <view class="ambassador-block">
<view class="application-note">添加微信时请备注“申请婚恋使者”,与老师沟通申请事宜。</view> <view class="ambassador-title"><text>成为婚恋使者</text><text class="progress-count">{{overviewReady ? verified : '—'}} / 7</text></view>
<image class="application-code" src="{{contactImage}}" mode="widthFix" show-menu-by-longpress="true" @tap="previewContact"></image> <view class="progress-track"><view class="progress-fill" style="width: {{progressPercent}}%;"></view></view>
<view class="application-hint">点击放大二维码,或保存后在微信扫一扫中选择相册识别。</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>
<button :disabled="savingContact" @tap="saveContact">{{savingContact ? '正在保存…' : '保存二维码'}}</button> <view v-if="eligibilityError" class="asset-error"><text>申请资格查询失败</text><button class="text-button" @tap="refreshEligibility">重新查询</button></view>
<button class="application-close" @tap="closeApplication">关闭</button>
</view> </view>
</view> </view>
<canvas canvas-id="fulianPoster" style="position:fixed;left:-2000px;top:0;width:750px;height:1120px;"></canvas> <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"><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></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> </view>
</template> </template>
<script> <script>
import wepy from '@wepy/core' import wepy from '@wepy/core'
import https from '../../mixins/https'
import {service} from '../../config' import {service} from '../../config'
import {sharingPath, renderSharingPoster, renderSharingCover, saveSharingPoster, storeSharingCode} from '../../utils/sharing' import sharingPage from '../../mixins/sharing-page'
import {sharingRequest, boundedCall} from '../../utils/sharing-runtime'
import {sharingPath, saveSharingPoster} from '../../utils/sharing'
wepy.page({ wepy.page({
mixins:[https], mixins:[sharingPage('invite')],
data:{loading:true,error:'',total:0,verified:0,remaining:7,invitees:[],page:0,hasMore:false,listLoading:false,listError:false,profile:{id:0,nickname:'',qrcode:''},poster:'',cover:'',posterError:'',generating:false,saving:false,canApply:false,checkingEligibility:false,eligibilityError:false,showApplication:false,savingContact:false,contactImage:'/pages/users/assets/ambassador-contact.jpg'}, 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.load() }, onLoad(){this.startSharing();this.loadOverview();this.loadMore()},
onShow() { if (!this.loading && !this.error) this.refreshEligibility() }, onShow(){if(this.overviewReady)this.refreshEligibility()},
onShareAppMessage() { onShareAppMessage(){return {title:'邀请你加入福恋,上福恋认真找对象',path:this.profile.id?sharingPath(this.profile.id,false):'/pages/tabBar/welcome',imageUrl:this.cover||'/pages/users/assets/fulian-logo.png'}},
return {title:'邀请你加入福恋,上福恋认真找对象',path:this.profile.id ? sharingPath(this.profile.id,false) : '/pages/tabBar/welcome',imageUrl:this.cover || '/pages/users/assets/fulian-logo.png'}
},
methods:{ methods:{
setOverview(data) { showPosterTab(){this.activeTab='poster'},
this.total=Number(data.total) || 0 showResultsTab(){this.activeTab='results'},
this.verified=Number(data.verified) || 0 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},
this.remaining=Math.max(0,7-this.verified) 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}},
this.canApply=data.can_apply === true && this.verified >= 7 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 refreshEligibility() { async openApplication(){if(!this.canApply||this.checkingEligibility)return;if(await this.refreshEligibility())this.showApplication=true},
if (this.checkingEligibility) return false closeApplication(){this.showApplication=false},keepApplicationOpen(){},
this.checkingEligibility=true; this.eligibilityError=false previewContact(){wx.previewImage({urls:[this.contactImage],current:this.contactImage})},
try { async saveContact(){
const result=await this.$get({url:`${service.user}/sharing/overview`}) if(this.savingContact)return;this.savingContact=true
this.setOverview(result.data) 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'})}
return this.canApply catch(_){wx.showModal({title:'未能保存',content:'请检查相册权限后重试,也可以点击二维码放大查看。',showCancel:false})}
} catch (_) { finally{if(!this._sharingDisposed)this.savingContact=false}
this.canApply=false; this.showApplication=false; this.eligibilityError=true
return false
} finally { this.checkingEligibility=false }
}, },
async openApplication() { async loadMore(){
if (!this.canApply || this.checkingEligibility) return if(this.listLoading)return;this.listLoading=true;this.listError=false
if (await this.refreshEligibility()) this.showApplication=true 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}
closeApplication() { this.showApplication=false }, finally{if(!this._sharingDisposed)this.listLoading=false}
keepApplicationOpen() {}, }
previewContact() { wx.previewImage({urls:[this.contactImage],current:this.contactImage}) },
async saveContact() {
if (this.savingContact) return
this.savingContact=true
try {
const image=await new Promise((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 { this.savingContact=false }
},
async load() {
this.loading=true; this.error=''; this.invitees=[]; this.page=0; this.canApply=false; this.showApplication=false; this.eligibilityError=false
try {
const results=await Promise.all([this.$get({url:`${service.user}/sharing/overview`}),this.$get({url:`${service.user}/sharing/profile`})])
this.setOverview(results[0].data)
this.profile={id:results[1].data.id,nickname:results[1].data.nickname || '一位朋友',qrcode:''}
this.hasMore=true
await this.loadMore()
} catch (_) { this.error='邀请信息加载失败,请重试' }
finally {
this.loading=false
if (this.profile.id) this.$nextTick(async()=>{
this.generating=true
try { this.cover=await renderSharingCover(this,this.profile,false) } catch (_) {}
finally { this.generating=false }
})
}
},
async loadMore() {
if (this.listLoading) return
this.listLoading=true; this.listError=false
try {
const result=await this.$get({url:`${service.user}/sharing/invitees`,data:{page:this.page+1}})
const known=new Set(this.invitees.map(item=>item.id))
this.invitees=this.invitees.concat(result.data.data.filter(item=>!known.has(item.id)))
this.page=result.data.page; this.hasMore=result.data.has_more
} catch (_) { this.listError=true }
finally { this.listLoading=false }
},
async posterAction() {
if (this.generating || this.saving) return
if (this.poster) {
this.saving=true
try { await saveSharingPoster(this.poster); wx.showToast({title:'已保存到相册'}) }
catch (_) { wx.showModal({title:'未能保存',content:'请在微信设置中允许保存到相册后重试。',showCancel:false}) }
finally { this.saving=false }
return
}
this.generating=true; this.posterError=''
try {
const result=await this.$post({url:`${service.user}/sharing/code`,data:{kind:'invite'}})
this.profile.qrcode=await storeSharingCode(result.data,'invite')
this.poster=await renderSharingPoster(this,this.profile,false)
} catch (_) { this.posterError='邀请海报生成失败,请稍后重试' }
finally { this.generating=false }
},
preview() { wx.previewImage({urls:[this.poster],current:this.poster}) }
} }
}) })
</script> </script>
<style lang="less" src="../../styles/sharing.less"></style> <style lang="less" src="../../styles/sharing.less"></style>
<config>{navigationBarTitleText:'我的分享',navigationBarBackgroundColor:'#FFF9F5'}</config> <config>{navigationBarTitleText:'我的邀请',navigationBarBackgroundColor:'#FFF9F5'}</config>

View File

@ -1,74 +1,28 @@
<template> <template>
<view class="sharing-page"> <view class="sharing-page personal-page">
<view class="eyebrow">我的个人名片</view> <view class="page-intro"><view class="intro-label">属于你的认真介绍</view><view class="intro-title">让合适的人,认识真实的你</view><view class="intro-note">一张卡片,开启一次真诚的相识。</view></view>
<view class="sharing-title">让合适的人,认识你</view> <view class="section-top card-tools"><text class="panel-caption">我的个人海报</text><button class="text-button" :disabled="generating || profileLoading" @tap="refreshPoster">更新海报</button></view>
<view class="sharing-subtitle">分享你的照片与介绍,开启一次认真相识。</view> <view class="personal-stage">
<view v-if="loading" class="sharing-state">正在准备你的卡片…</view> <image v-if="poster" class="personal-poster" src="{{poster}}" mode="widthFix" @tap="preview" @error="posterImageFailed"></image>
<view v-else-if="error" class="sharing-state"><view>{{error}}</view><button @tap="load">重试</button></view> <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="aspectFill"></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>
<block v-else> </view>
<image v-if="poster" class="sharing-poster" src="{{poster}}" mode="widthFix" @tap="preview"></image> <view v-if="generating || (profileLoading && !poster)" class="asset-status card-status">{{profileLoading ? '正在读取个人资料' : posterStage}}</view>
<view v-else class="sharing-preview"> <view v-else-if="posterError || profileError" class="asset-error card-status"><text>{{posterError || profileError}}</text><button class="text-button" @tap="refreshPoster">重新生成</button></view>
<image class="sharing-logo" src="/pages/users/assets/fulian-logo.png" mode="aspectFit"></image> <view v-else-if="cacheNote" class="asset-status ready-status card-status">{{cacheNote}} · 点击查看大图</view>
<image v-if="profile.photo" class="sharing-photo" src="{{profile.photo}}" mode="aspectFill"></image> <view class="card-tip">资料变更后,点击“更新海报”即可重新生成。</view>
<view class="sharing-title">{{profile.nickname}}</view><view>{{profile.details}}</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>
<view class="sharing-intro">{{profile.introduction}}</view> <canvas type="2d" id="fulianPoster" class="drawing-canvas"></canvas><canvas type="2d" id="fulianCover" class="drawing-canvas"></canvas>
<view class="sharing-state">{{posterError || '正在生成海报…'}}</view>
<button v-if="posterError" @tap="generate">重新生成</button>
</view>
<view class="sharing-actions"><button :disabled="!poster || saving" @tap="save">{{saving ? '正在保存…' : '保存个人海报'}}</button><button open-type="share">分享我的卡片</button></view>
</block>
<canvas canvas-id="fulianPoster" style="position:fixed;left:-2000px;top:0;width:750px;height:1120px;"></canvas>
</view> </view>
</template> </template>
<script> <script>
import wepy from '@wepy/core' import wepy from '@wepy/core'
import https from '../../mixins/https' import sharingPage from '../../mixins/sharing-page'
import {service} from '../../config' import {sharingPath} from '../../utils/sharing'
import {sharingPath, renderSharingPoster, renderSharingCover, saveSharingPoster, storeSharingCode} from '../../utils/sharing'
wepy.page({ wepy.page({
mixins:[https], mixins:[sharingPage('personal')],
data:{loading:true,error:'',poster:'',cover:'',posterError:'',saving:false,generating:false,profile:{id:0,nickname:'',photo:'',details:'',introduction:'',qrcode:''}}, onLoad(){this.startSharing()},
onLoad() { this.load() }, onShareAppMessage(){return {title:this.profile.nickname?`我是${this.profile.nickname},期待与你相识`:'我的福恋卡片,期待与你相识',path:this.profile.id?sharingPath(this.profile.id,true):'/pages/tabBar/welcome',imageUrl:this.cover||this.profile.photo||'/pages/users/assets/fulian-logo.png'}}
onShareAppMessage() {
if (!this.profile.id) return {title:'上福恋认真找对象',path:'/pages/tabBar/welcome'}
return {title:`我是${this.profile.nickname},期待与你相识`,path:sharingPath(this.profile.id,true),imageUrl:this.cover || this.profile.photo || '/pages/users/assets/fulian-logo.png'}
},
methods:{
async load() {
this.loading=true; this.error=''; this.poster=''
try {
const result=await this.$get({url:`${service.user}/sharing/profile`})
const data=result.data
this.profile={id:data.id,nickname:data.nickname || '福恋用户',photo:data.photo || '',details:[data.city,data.degree,data.industry].filter(Boolean).join(' · '),introduction:data.introduction || '',idealMate:data.ideal_mate || '',qrcode:''}
this.loading=false
this.$nextTick(() => this.generate())
} catch (_) { this.error='卡片加载失败,请重试'; this.loading=false }
},
async generate() {
if (this.generating) return
this.generating=true
this.posterError=''
try {
this.cover=await renderSharingCover(this,this.profile,true)
if (!this.profile.qrcode) {
const result=await this.$post({url:`${service.user}/sharing/code`,data:{kind:'personal'}})
this.profile.qrcode=await storeSharingCode(result.data,'personal')
}
this.poster=await renderSharingPoster(this,this.profile,true)
}
catch (_) { this.posterError='海报暂未生成,请检查分享码及图片后重试' }
finally { this.generating=false }
},
preview() { wx.previewImage({urls:[this.poster],current:this.poster}) },
async save() {
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 { this.saving=false }
}
}
}) })
</script> </script>
<style lang="less" src="../../styles/sharing.less"></style> <style lang="less" src="../../styles/sharing.less"></style>
<config>{navigationBarTitleText:'分享我的卡片',navigationBarBackgroundColor:'#FFF9F5'}</config> <config>{navigationBarTitleText:'我的卡片',navigationBarBackgroundColor:'#FFF9F5'}</config>

View File

@ -1,29 +1,88 @@
.sharing-page { min-height:100vh; background:#FFF9F5; color:#4E4145; padding:36rpx 32rpx 64rpx; box-sizing:border-box; } .sharing-page { min-height:100vh; padding:32rpx 28rpx 52rpx; box-sizing:border-box; background:#FFF9F5; color:#4E4145; font-size:28rpx; }
.eyebrow { color:#D92554; font-size:24rpx; margin-bottom:16rpx; } .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-title { color:#A3294D; font-size:40rpx; font-weight:600; margin-bottom:16rpx; } .sharing-page button::after { border:none; }
.sharing-subtitle { font-size:26rpx; color:#97717D; line-height:1.7; margin-bottom:32rpx; } .sharing-page button[disabled] { opacity:.48; }
.sharing-poster { display:block; width:100%; border-radius:20rpx; } .page-intro { margin:4rpx 4rpx 28rpx; }
.sharing-preview { border-radius:24rpx; background:#FFF1F4; padding:28rpx; } .intro-label { color:#97717D; font-size:24rpx; letter-spacing:2rpx; margin-bottom:12rpx; }
.sharing-photo { width:100%; height:400rpx; border-radius:16rpx; margin:24rpx 0; } .intro-title { color:#A3294D; font-size:40rpx; line-height:1.45; font-weight:600; }
.sharing-logo { width:64rpx; height:64rpx; } .intro-note { color:#97717D; font-size:26rpx; line-height:1.6; margin-top:12rpx; }
.sharing-intro { margin:28rpx 0; font-size:28rpx; line-height:1.8; } .share-panel { background:#FFF1F4; border-radius:28rpx; padding:24rpx; }
.sharing-actions { display:flex; flex-wrap:wrap; gap:16rpx; margin-top:28rpx; } .section-top { display:flex; align-items:center; justify-content:space-between; gap:12rpx; }
.sharing-page button { background:#D92554; color:#FFF9F5; border-radius:16rpx; font-size:28rpx; margin:0; } .panel-caption { color:#A3294D; font-size:25rpx; font-weight:500; }
.sharing-page button[disabled] { background:#EED3DB; color:#4E4145; } .sharing-page .text-button { padding:14rpx 8rpx; background:transparent; color:#A3294D; font-size:24rpx; flex-shrink:0; }
.sharing-actions button { flex:1; min-width:240rpx; } .invite-stage { margin:14rpx 0 18rpx; }
.sharing-state { font-size:26rpx; color:#97717D; padding:32rpx 0; line-height:1.8; } .invite-poster-frame { width:100%; background:#FFF9F5; border-radius:18rpx; overflow:hidden; box-shadow:0 10rpx 28rpx rgba(163,41,77,.12); }
.sharing-state button { margin-top:20rpx; } .invite-poster-image { width:100%; display:block; }
.sharing-section { margin-top:36rpx; font-size:32rpx; color:#A3294D; font-weight:600; } .poster-placeholder { height:960rpx; display:flex; flex-direction:column; justify-content:center; align-items:center; color:#A3294D; gap:20rpx; font-size:22rpx; }
.sharing-stats { display:flex; gap:20rpx; padding:30rpx; background:#FFF1F4; border-radius:24rpx; } .poster-placeholder image { width:74rpx; height:74rpx; }
.sharing-stat { flex:1; font-size:26rpx; } .poster-placeholder .placeholder-small { font-size:18rpx; color:#97717D; }
.sharing-number { display:block; font-size:48rpx; color:#A3294D; margin-bottom:12rpx; } .preview-caption { color:#97717D; font-size:23rpx; text-align:center; margin:18rpx 0 4rpx; }
.sharing-row { display:flex; justify-content:space-between; gap:20rpx; padding:28rpx 0; border-bottom:1rpx solid #EED3DB; font-size:28rpx; } .asset-status { color:#97717D; font-size:23rpx; line-height:1.6; padding:10rpx 0 16rpx; }
.sharing-row text:first-child { overflow-wrap:anywhere; } .ready-status { color:#97717D; }
.sharing-status { color:#97717D; flex-shrink:0; } .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; }
.application-mask { position:fixed; inset:0; z-index:1000; background:rgba(78,65,69,.6); display:flex; align-items:center; justify-content:center; padding:24rpx; box-sizing:border-box; } .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-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-title { color:#A3294D; font-size:34rpx; font-weight:600; text-align:center; }
.application-note { color:#4E4145; font-size:28rpx; line-height:1.7; margin:18rpx 0; } .application-note { font-size:28rpx; line-height:1.7; margin:18rpx 0; }
.application-code { display:block; width:100%; } .application-code { display:block; width:100%; }
.application-hint { font-size:24rpx; color:#97717D; line-height:1.6; margin:20rpx 0; } .application-hint { font-size:24rpx; color:#97717D; line-height:1.6; margin:20rpx 0; }
.sharing-page .application-close { margin-top:16rpx; color:#A3294D; background:#FFF1F4; } .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; }

View File

@ -0,0 +1,54 @@
import {boundedCall, fileAccess} from './sharing-runtime'
const VERSION = 'v4'
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,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 }
}

View File

@ -0,0 +1,22 @@
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) {
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}}
}))
return checked.filter(item=>item.score>=0).sort((a,b)=>b.score-a.score)[0]?.url||''
}

View File

@ -0,0 +1,52 @@
// 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('登录状态已失效,请重新登录后再试'))
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 || ''}
}

View File

@ -1,116 +1,117 @@
// Sharing destinations stay separate: inviting someone never advertises the import {boundedCall} from './sharing-runtime'
// inviter as a person seeking a relationship.
export function sharingPath(id, personal) {
const userID = Number(id)
if (!Number.isSafeInteger(userID) || userID <= 0) throw new Error('请先登录后分享')
return personal
? `/pages/home/information?id=${userID}`
: `/pages/tabBar/welcome?from_user_id=${userID}`
}
export function storeSharingCode(data, kind) { const LOGO='/pages/users/assets/fulian-logo.png'
const extension = data.mime === 'image/png' ? 'png' : data.mime === 'image/jpeg' ? 'jpg' : '' export function sharingPath(id,personal) {
if (!extension || !data.image) return Promise.reject(new Error('分享码格式无效')) const value=Number(id)
const filePath = `${wx.env.USER_DATA_PATH}/fulian-${kind}.${extension}` if (!Number.isSafeInteger(value)||value<=0) throw new Error('请先登录后分享')
return new Promise((resolve,reject) => wx.getFileSystemManager().writeFile({filePath,data:data.image,encoding:'base64',success:()=>resolve(filePath),fail:reject})) return personal ? `/pages/home/information?id=${value}` : `/pages/tabBar/welcome?from_user_id=${value}`
} }
export function storeSharingCode(data,kind,id) {
function imageInfo(src) { const extension=data.mime==='image/png'?'png':data.mime==='image/jpeg'?'jpg':''
return new Promise((resolve, reject) => wx.getImageInfo({src, success: resolve, fail: reject})) 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)
} }
function textLines(ctx, text, x, y, width, lineHeight, maxLines) {
let line = '', row = 0
const chars = Array.from(String(text || '').replace(/\s+/g, ' ').trim())
for (let i = 0; i < chars.length; i++) {
if (ctx.measureText(line + chars[i]).width > width) {
if (row === maxLines - 1) {
while (line && ctx.measureText(line + '…').width > width) line = line.slice(0, -1)
ctx.fillText(line + '…', x, y + row * lineHeight)
return
}
// Keep closing punctuation with the preceding character on the next line.
const carry = /[,。!?;:、)】》」』]/.test(chars[i]) ? line.slice(-1) : ''
if (carry) line = line.slice(0, -1)
ctx.fillText(line, x, y + row * lineHeight)
row++
line = carry
}
line += chars[i]
}
ctx.fillText(line, x, y + row * lineHeight)
}
export async function renderSharingPoster(page, profile, personal) {
if (!profile.qrcode) throw new Error('分享码暂未准备好,请稍后重试')
const sources = ['/pages/users/assets/fulian-logo.png', profile.qrcode]
if (personal && profile.photo) sources.push(profile.photo)
const images = await Promise.all(sources.map(imageInfo))
const ctx = wx.createCanvasContext('fulianPoster', page.$wx)
ctx.setFillStyle('#FFF9F5'); ctx.fillRect(0, 0, 750, 1120)
ctx.drawImage(images[0].path, 48, 42, 64, 64)
ctx.setFillStyle('#A3294D'); ctx.setFontSize(30); ctx.fillText('福恋', 126, 84)
ctx.setFontSize(22); ctx.fillText('上福恋认真找对象', 440, 82)
if (personal) {
ctx.setFillStyle('#FFF1F4'); ctx.fillRect(48, 138, 654, 430)
if (images[2]) {
const im = images[2], ratio = Math.max(654 / im.width, 430 / im.height)
const sw = 654 / ratio, sh = 430 / ratio
ctx.drawImage(im.path, (im.width - sw) / 2, (im.height - sh) / 2, sw, sh, 48, 138, 654, 430)
}
ctx.setFillStyle('#A3294D'); ctx.setFontSize(40)
textLines(ctx, profile.nickname || '我的个人卡片', 48, 628, 654, 48, 1)
ctx.setFillStyle('#97717D'); ctx.setFontSize(24)
textLines(ctx, profile.details, 48, 674, 654, 32, 1)
ctx.setFillStyle('#4E4145'); ctx.setFontSize(28)
textLines(ctx, profile.introduction || '很高兴在福恋与你相遇。', 48, 730, 654, 38, 2)
if (profile.idealMate) {
ctx.setFontSize(24)
textLines(ctx, '期待的你:' + profile.idealMate, 48, 824, 654, 32, 1)
}
} else {
ctx.setFillStyle('#FFF1F4'); ctx.fillRect(48, 160, 654, 640)
ctx.setFillStyle('#A3294D'); ctx.setFontSize(52)
ctx.fillText('把福恋分享给', 88, 300); ctx.fillText('认真寻找爱情的人', 88, 380)
ctx.setFillStyle('#4E4145'); ctx.setFontSize(28)
textLines(ctx, '认识新朋友,开启认真交往的第一步。', 88, 470, 560, 46, 2)
ctx.setFontSize(25)
textLines(ctx, `${profile.nickname || '一位朋友'} 邀请你加入福恋`, 88, 690, 560, 38, 2)
}
ctx.setFillStyle('#EED3DB'); ctx.fillRect(48, 860, 654, 1)
ctx.drawImage(images[1].path, 48, 906, 164, 164)
ctx.setFillStyle('#A3294D'); ctx.setFontSize(28)
ctx.fillText(personal ? '扫码认识我' : '扫码加入福恋', 244, 965)
ctx.setFillStyle('#97717D'); ctx.setFontSize(23)
ctx.fillText(personal ? '查看我的完整个人资料' : '上福恋认真找对象', 244, 1010)
await new Promise(resolve => ctx.draw(false, resolve))
return new Promise((resolve, reject) => wx.canvasToTempFilePath({
canvasId: 'fulianPoster', width:750, height:1120, destWidth:750, destHeight:1120,
success: result => resolve(result.tempFilePath), fail: reject
}, page.$wx))
}
export function saveSharingPoster(filePath) { export function saveSharingPoster(filePath) {
return new Promise((resolve, reject) => wx.saveImageToPhotosAlbum({filePath, success:resolve, fail:reject})) return boundedCall((resolve,reject)=>wx.saveImageToPhotosAlbum({filePath,success:resolve,fail:reject}), '图片未能保存,请检查相册权限',15000)
} }
function check(job) { if (job && job.cancelled) throw new Error('已停止生成,请重试') }
export async function renderSharingCover(page, profile, personal) { function font(ctx,size,weight=400) { ctx.font=`${weight} ${size}px sans-serif` }
const logo=await imageInfo('/pages/users/assets/fulian-logo.png') function roundRect(ctx,x,y,w,h,r,fill) {
const photo=personal && profile.photo ? await imageInfo(profile.photo) : null 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()
const ctx=wx.createCanvasContext('fulianPoster',page.$wx) if(fill){ctx.fillStyle=fill;ctx.fill()}
ctx.setFillStyle('#FFF9F5'); ctx.fillRect(0,0,750,600) }
ctx.drawImage(logo.path,40,32,64,64) function textLines(ctx,text,x,y,width,lineHeight,limit) {
ctx.setFillStyle('#A3294D'); ctx.setFontSize(28); ctx.fillText('上福恋认真找对象',124,74) let line='',row=0
if (photo) { for(const char of Array.from(String(text||'').replace(/\s+/g,' ').trim())){
const ratio=Math.max(290/photo.width,360/photo.height),sw=290/ratio,sh=360/ratio if(ctx.measureText(line+char).width>width){
ctx.drawImage(photo.path,(photo.width-sw)/2,(photo.height-sh)/2,sw,sh,420,150,290,360) 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.setFillStyle('#A3294D');ctx.setFontSize(42) ctx.fillText(line,x,y+row*lineHeight)
textLines(ctx,personal ? profile.nickname || '我的个人卡片' : '邀请你加入福恋',40,210,personal ? 350 : 670,56,2)
ctx.setFillStyle('#4E4145');ctx.setFontSize(28)
textLines(ctx,personal ? profile.introduction || '期待与你认真相识' : '把福恋分享给认真寻找爱情的人',40,350,personal ? 350 : 670,44,3)
ctx.setFillStyle('#97717D');ctx.setFontSize(25)
ctx.fillText(personal ? '点击认识我' : '点击开启认真相识',40,550)
await new Promise(resolve=>ctx.draw(false,resolve))
return new Promise((resolve,reject)=>wx.canvasToTempFilePath({canvasId:'fulianPoster',width:750,height:600,destWidth:750,destHeight:600,success:res=>resolve(res.tempFilePath),fail:reject},page.$wx))
} }
function photoFill(ctx,image,x,y,w,h,r=0){
ctx.save();if(r){roundRect(ctx,x,y,w,h,r);ctx.clip()}
const scale=Math.max(w/image.width,h/image.height),sw=w/scale,sh=h/scale
ctx.drawImage(image,(image.width-sw)/2,(image.height-sh)/2,sw,sh,x,y,w,h);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
}
async function loadImage(canvas,src,job){
const info=await boundedCall((resolve,reject)=>wx.getImageInfo({src,success:resolve,fail:reject}),'图片加载失败,请检查网络后重试',8000)
check(job)
return boundedCall((resolve,reject)=>{
const image=canvas.createImage()
image.onload=()=>resolve(image);image.onerror=()=>reject(new Error('图片读取失败,请重试'));image.src=info.path
},'图片读取超时,请重试',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'
}
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)
ctx.lineWidth=20;ctx.strokeStyle='#EED3DB';ctx.beginPath();ctx.arc(310,568,74,0,Math.PI*2);ctx.stroke()
ctx.strokeStyle='#D92554';ctx.beginPath();ctx.arc(428,568,74,0,Math.PI*2);ctx.stroke()
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)
roundRect(ctx,32,120,686,438,28,'#FFF1F4')
if(personal&&images.photo)photoFill(ctx,images.photo,424,148,264,364,20)
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)}

View File

@ -0,0 +1,65 @@
# 邀请与个人卡片改版验证
分支: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 为准。此前各段“未合并”描述为当时验证状态。

View 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-v4');index['43-personal'].savedAt=Date.now()-2*24*3600*1000;h.storage.set('fulian-sharing-cache-v4',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})

View File

@ -1,27 +1,15 @@
const fs=require('fs'),path=require('path'),vm=require('vm'),assert=require('assert') const assert=require('assert'),{createHarness}=require('./harness.cjs')
const source=fs.readFileSync(path.join(__dirname,'../../src/pages/users/myShare.wpy'),'utf8').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm,'')
let definition,failed=false,saves=0,overview={total:12,verified:7,can_apply:true},requestFailed=false
const notices=[]
const sandbox={wepy:{page(value){definition=value}},https:{},service:{user:'synthetic-api'},wx:{getImageInfo({success}){success({path:'synthetic-contact.jpg'})},showToast(value){notices.push(value)},showModal(value){notices.push(value)},previewImage(value){notices.push(value)}},saveSharingPoster:async()=>{saves++;if(failed)throw Error('synthetic denial')}}
vm.createContext(sandbox);vm.runInContext(source,sandbox)
const page={...definition.data,...definition.methods,$get:async()=>{if(requestFailed)throw Error('synthetic');return {data:overview}}}
;(async()=>{ ;(async()=>{
page.setOverview({total:100,verified:6,can_apply:false}) const h=createHarness(),page=h.page('myShare')
await page.openApplication();assert.equal(page.showApplication,false);assert.equal(page.remaining,1) 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}) page.setOverview({total:12,verified:7,can_apply:true});await page.openApplication();assert(page.showApplication);assert.equal(page.remaining,0)
await page.openApplication();assert.equal(page.showApplication,true);assert.equal(page.remaining,0) page.keepApplicationOpen();assert(page.showApplication);page.closeApplication();assert(!page.showApplication)
page.keepApplicationOpen();assert.equal(page.showApplication,true) h.state.overview={total:12,verified:6,can_apply:false};await page.openApplication();assert(!page.showApplication);assert(!page.canApply)
page.closeApplication();assert.equal(page.showApplication,false) h.state.overview={total:12,verified:8,can_apply:true};await page.refreshEligibility();assert(page.canApply)
overview={total:12,verified:6,can_apply:false} h.flags.requestFail=true;await page.openApplication();assert(!page.showApplication);assert(!page.canApply);assert(page.eligibilityError);assert(!page.checkingEligibility)
await page.openApplication();assert.equal(page.showApplication,false);assert.equal(page.canApply,false) h.flags.requestFail=false;await page.refreshEligibility();assert(page.canApply)
overview={total:12,verified:8,can_apply:true};await page.refreshEligibility();assert.equal(page.canApply,true) page.setOverview({total:100,verified:6,can_apply:true});assert(!page.canApply)
requestFailed=true;await page.openApplication();assert.equal(page.showApplication,false);assert.equal(page.canApply,false);assert.equal(page.eligibilityError,true);assert.equal(page.checkingEligibility,false) await page.saveContact();assert(!page.savingContact);assert(h.calls.some(x=>x.toast&&x.toast.title==='二维码已保存'))
requestFailed=false;await page.refreshEligibility();assert.equal(page.canApply,true) h.flags.saveFail=true;await page.saveContact();assert(!page.savingContact);assert(h.calls.some(x=>x.modal&&x.modal.title==='未能保存'))
page.setOverview({total:100,verified:6,can_apply:true});assert.equal(page.canApply,false) console.log('PASS: 6/7/8 verified thresholds, stale eligibility, failure/retry and contact save errors; no automatic role grant.')
await page.saveContact();assert.equal(saves,1);assert.equal(page.savingContact,false) })().catch(error=>{console.error(error.stack);process.exitCode=1})
assert.equal(notices.pop().title,'二维码已保存')
failed=true;await page.saveContact();assert.equal(page.savingContact,false);assert.equal(notices.pop().title,'未能保存')
page.savingContact=true;await page.saveContact();assert.equal(saves,2)
assert(!notices.some(x=>/申请成功|审核通过/.test(x.title||'')))
console.log('PASS: 6/7/8 verified eligibility, total not used, stale eligibility, request failure/retry, modal, image save failure/success and duplicate-save guard; no application submission.')
})().catch(e=>{console.error(e.message);process.exitCode=1})

View File

@ -0,0 +1,29 @@
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([]),'')
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})

View File

@ -1,22 +1,21 @@
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path') const assert=require('assert'),{createHarness,settle}=require('./harness.cjs')
const source=fs.readFileSync(path.join(__dirname,'../../src/utils/sharing.js'),'utf8').replace(/export /g,'')
const calls=[]
const context={fillRect(){},drawImage(...a){calls.push(['image',...a])},setFillStyle(){},setFontSize(){},fillText(t){calls.push(['text',t])},measureText(t){return {width:t.length*28}},draw(_,cb){cb()}}
const sandbox={wx:{getImageInfo({src,success}){success({path:src,width:500,height:500})},createCanvasContext(){return context},canvasToTempFilePath({success}){success({tempFilePath:'synthetic.png'})}}}
vm.createContext(sandbox);vm.runInContext(source,sandbox)
assert.equal(sandbox.sharingPath(42,false),'/pages/tabBar/welcome?from_user_id=42')
assert.equal(sandbox.sharingPath(42,true),'/pages/home/information?id=42')
for(const id of [0,-1,'bad',1.5]) assert.throws(()=>sandbox.sharingPath(id,true))
;(async()=>{ ;(async()=>{
await assert.rejects(sandbox.renderSharingPoster({$wx:{}},{},false)) const h=createHarness(),sharing=h.load('utils/sharing'),runtime=h.load('utils/sharing-runtime')
const profile={nickname:'合成用户',photo:'synthetic-photo.png',qrcode:'synthetic-code.png',introduction:'这是用于离线验证的合成介绍。'.repeat(30),details:'合成城市 · 合成职业'} assert.equal(sharing.sharingPath(42,false),'/pages/tabBar/welcome?from_user_id=42')
await sandbox.renderSharingPoster({$wx:{}},profile,true) assert.equal(sharing.sharingPath(42,true),'/pages/home/information?id=42')
assert(calls.some(c=>c[1]==='扫码认识我')) for(const id of [0,-1,'bad',1.5])assert.throws(()=>sharing.sharingPath(id,true))
assert(calls.some(c=>c[0]==='image'&&c[1]==='synthetic-photo.png')) await assert.rejects(sharing.renderSharingPoster({$wx:{}},{},false))
assert(calls.some(c=>c[0]==='text'&&c[1].endsWith('…'))) const personal=h.page('shareCard');await personal.startSharing()
calls.length=0 assert.equal(personal.poster,'');assert(!h.calls.some(x=>x.url&&x.url.endsWith('/code')),'must wait for onReady')
await sandbox.renderSharingPoster({$wx:{}},profile,false) personal.hook('onReady');await settle(()=>personal.poster&&!personal.generating,'initial render')
assert(calls.some(c=>c[1]==='扫码加入福恋')) 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(!calls.some(c=>c[0]==='image'&&c[1]==='synthetic-photo.png')) assert(personal.cached);assert(personal.poster.startsWith('/synthetic-user/'))
console.log('PASS: separate destinations, invalid IDs, missing QR, personal/invite content and long-text truncation (offline mocks).') const requestCount=h.calls.filter(x=>x.url&&x.url.endsWith('/code')).length,exportCount=h.state.exportID
})().catch(e=>{console.error(e.message);process.exitCode=1}) 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
View 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(){},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)?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: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}

View File

@ -19,7 +19,7 @@ appDef.onShow.call(app,{path:'pages/tabBar/home',query:{}});assert.equal(storage
appDef.onShow.call(app,{path:'pages/tabBar/welcome',query:{scene:'%'}}) 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') 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) vm.runInContext(script('pages/tabBar/user.wpy'),context)
assert.deepEqual(Array.from(pageDef.data.list,x=>x.title),['会员中心','我的发布','我的分享','分享我的卡片','更多','设置']) 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') 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) vm.runInContext(script('pages/users/more.wpy'),context)
const more={...pageDef.data,...pageDef.methods,$get:async()=>({data:{qrcode:'synthetic-assigned-code'}})} const more={...pageDef.data,...pageDef.methods,$get:async()=>({data:{qrcode:'synthetic-assigned-code'}})}