diff --git a/src/mixins/sharing-page.js b/src/mixins/sharing-page.js
new file mode 100644
index 0000000..703d7b3
--- /dev/null
+++ b/src/mixins/sharing-page.js
@@ -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}
+ }
+ }
+ }
+}
diff --git a/src/pages/tabBar/user.wpy b/src/pages/tabBar/user.wpy
index 7cc2114..feddc7f 100644
--- a/src/pages/tabBar/user.wpy
+++ b/src/pages/tabBar/user.wpy
@@ -366,13 +366,13 @@ wepy.page({
},
{
icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png',
- title: '我的分享',
+ title: '我的邀请',
type: 'navigateTo',
path: '/pages/users/myShare'
},
{
icon: 'https://image.fulllinkai.com/202302/06/4092bc34b48ef2b8b95e9d3a5954f803.png',
- title: '分享我的卡片',
+ title: '我的卡片',
type: 'navigateTo',
path: '/pages/users/shareCard'
},
@@ -769,7 +769,7 @@ wepy.page({
vm.getServiceCode(url)
return
}
- if (text === '分享我的卡片') {
+ if (text === '我的卡片') {
wx.navigateTo({url: '/pages/users/shareCard'})
return
}
diff --git a/src/pages/users/myShare.wpy b/src/pages/users/myShare.wpy
index 7624599..11d24af 100644
--- a/src/pages/users/myShare.wpy
+++ b/src/pages/users/myShare.wpy
@@ -1,139 +1,93 @@
-
- 把美好的相遇分享出去我的分享
- 正在加载邀请数据…
- {{error}}
-
- {{total}}邀请用户总数{{verified}}真人认证人数
- 申请婚恋使者
- 邀请的用户中有7位通过真人认证,即可联系荣耀老师申请婚恋使者。
- 已完成 {{verified}} 位,还差 {{remaining}} 位
-
- 申请资格查询失败,请稍后重试
- 邀请用户
- 还没有邀请用户,分享福恋给身边的朋友吧。
- {{item.nickname || '福恋用户'}}{{item.verified ? '真人认证已通过' : '未完成真人认证'}}
- 列表加载失败,请重试
-
- 邀请订单仅婚恋使者可查看
- 邀请朋友加入福恋
- 把福恋分享给认真寻找爱情的人。
-
- {{posterError}}
-
-
-
-
- 联系荣耀老师
- 添加微信时请备注“申请婚恋使者”,与老师沟通申请事宜。
-
- 点击放大二维码,或保存后在微信扫一扫中选择相册识别。
-
-
+
+ 分享一份美好的可能邀请朋友,认真相遇
+
+
+
+
+
+ 我的专属邀请
+
+
+
+ 邀请你加入福恋上福恋认真找对象
+
+
+ 点击海报 · 查看大图
+ {{profileLoading ? '正在准备你的邀请' : posterStage}}
+ {{posterError || profileError}}
+ {{cacheNote}}
+
+
+
+
+
+ {{overviewReady ? total : '—'}}累计邀请 / 人{{overviewReady ? verified : '—'}}通过真人认证 / 人
+ 邀请数据暂未加载成功
+
+ 成为婚恋使者{{overviewReady ? verified : '—'}} / 7
+
+ 已满足申请条件还差 {{remaining}} 位真人认证邀请7位完成真人认证后可申请添加荣耀老师微信,沟通申请
+ 申请资格查询失败
-
+ 邀请订单婚恋使者专属 · 暂未开放
+ 邀请用户每一次分享,都可能促成相遇
+
+ {{item.initial}}{{item.nickname || '福恋用户'}}{{item.verified ? '真人认证已通过' : '待完成真人认证'}}
+ 第一份邀请,从你开始在“专属邀请”分享海报给朋友,邀请记录会展示在这里。
+ 列表加载失败,请重试
+ 正在加载邀请用户…
+
+ 已展示全部邀请
+
+
+
+ 联系荣耀老师添加微信时请备注“申请婚恋使者”,与老师沟通申请事宜。点击放大二维码,或保存后在微信扫一扫中选择相册识别。
+
-{navigationBarTitleText:'我的分享',navigationBarBackgroundColor:'#FFF9F5'}
+{navigationBarTitleText:'我的邀请',navigationBarBackgroundColor:'#FFF9F5'}
diff --git a/src/pages/users/shareCard.wpy b/src/pages/users/shareCard.wpy
index a3177b8..46995d1 100644
--- a/src/pages/users/shareCard.wpy
+++ b/src/pages/users/shareCard.wpy
@@ -1,74 +1,28 @@
-
- 我的个人名片
- 让合适的人,认识你
- 分享你的照片与介绍,开启一次认真相识。
- 正在准备你的卡片…
- {{error}}
-
-
-
-
-
- {{profile.nickname}}{{profile.details}}
- {{profile.introduction}}
- {{posterError || '正在生成海报…'}}
-
-
-
-
-
+
+ 属于你的认真介绍让合适的人,认识真实的你一张卡片,开启一次真诚的相识。
+ 我的个人海报
+
+
+ 福恋期待认真相识{{profile.nickname || '我的卡片'}}{{profile.details}}{{profile.introduction || '上福恋认真找对象'}}
+
+ {{profileLoading ? '正在读取个人资料' : posterStage}}
+ {{posterError || profileError}}
+ {{cacheNote}} · 点击查看大图
+ 资料变更后,点击“更新海报”即可重新生成。
+
+
-{navigationBarTitleText:'分享我的卡片',navigationBarBackgroundColor:'#FFF9F5'}
+{navigationBarTitleText:'我的卡片',navigationBarBackgroundColor:'#FFF9F5'}
diff --git a/src/styles/sharing.less b/src/styles/sharing.less
index 8114e33..3aa5c0f 100644
--- a/src/styles/sharing.less
+++ b/src/styles/sharing.less
@@ -1,29 +1,88 @@
-.sharing-page { min-height:100vh; background:#FFF9F5; color:#4E4145; padding:36rpx 32rpx 64rpx; box-sizing:border-box; }
-.eyebrow { color:#D92554; font-size:24rpx; margin-bottom:16rpx; }
-.sharing-title { color:#A3294D; font-size:40rpx; font-weight:600; margin-bottom:16rpx; }
-.sharing-subtitle { font-size:26rpx; color:#97717D; line-height:1.7; margin-bottom:32rpx; }
-.sharing-poster { display:block; width:100%; border-radius:20rpx; }
-.sharing-preview { border-radius:24rpx; background:#FFF1F4; padding:28rpx; }
-.sharing-photo { width:100%; height:400rpx; border-radius:16rpx; margin:24rpx 0; }
-.sharing-logo { width:64rpx; height:64rpx; }
-.sharing-intro { margin:28rpx 0; font-size:28rpx; line-height:1.8; }
-.sharing-actions { display:flex; flex-wrap:wrap; gap:16rpx; margin-top:28rpx; }
-.sharing-page button { background:#D92554; color:#FFF9F5; border-radius:16rpx; font-size:28rpx; margin:0; }
-.sharing-page button[disabled] { background:#EED3DB; color:#4E4145; }
-.sharing-actions button { flex:1; min-width:240rpx; }
-.sharing-state { font-size:26rpx; color:#97717D; padding:32rpx 0; line-height:1.8; }
-.sharing-state button { margin-top:20rpx; }
-.sharing-section { margin-top:36rpx; font-size:32rpx; color:#A3294D; font-weight:600; }
-.sharing-stats { display:flex; gap:20rpx; padding:30rpx; background:#FFF1F4; border-radius:24rpx; }
-.sharing-stat { flex:1; font-size:26rpx; }
-.sharing-number { display:block; font-size:48rpx; color:#A3294D; margin-bottom:12rpx; }
-.sharing-row { display:flex; justify-content:space-between; gap:20rpx; padding:28rpx 0; border-bottom:1rpx solid #EED3DB; font-size:28rpx; }
-.sharing-row text:first-child { overflow-wrap:anywhere; }
-.sharing-status { color:#97717D; flex-shrink: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; }
+.sharing-page { min-height:100vh; padding:32rpx 28rpx 52rpx; box-sizing:border-box; background:#FFF9F5; color:#4E4145; font-size:28rpx; }
+.sharing-page button { box-sizing:border-box; margin:0; font-size:28rpx; line-height:1.4; font-weight:500; border-radius:18rpx; padding:24rpx 16rpx; }
+.sharing-page button::after { border:none; }
+.sharing-page button[disabled] { opacity:.48; }
+.page-intro { margin:4rpx 4rpx 28rpx; }
+.intro-label { color:#97717D; font-size:24rpx; letter-spacing:2rpx; margin-bottom:12rpx; }
+.intro-title { color:#A3294D; font-size:40rpx; line-height:1.45; font-weight:600; }
+.intro-note { color:#97717D; font-size:26rpx; line-height:1.6; margin-top:12rpx; }
+.share-panel { background:#FFF1F4; border-radius:28rpx; padding:24rpx; }
+.section-top { display:flex; align-items:center; justify-content:space-between; gap:12rpx; }
+.panel-caption { color:#A3294D; font-size:25rpx; font-weight:500; }
+.sharing-page .text-button { padding:14rpx 8rpx; background:transparent; color:#A3294D; font-size:24rpx; flex-shrink:0; }
+.invite-stage { margin:14rpx 0 18rpx; }
+.invite-poster-frame { width:100%; background:#FFF9F5; border-radius:18rpx; overflow:hidden; box-shadow:0 10rpx 28rpx rgba(163,41,77,.12); }
+.invite-poster-image { width:100%; display:block; }
+.poster-placeholder { height:960rpx; display:flex; flex-direction:column; justify-content:center; align-items:center; color:#A3294D; gap:20rpx; font-size:22rpx; }
+.poster-placeholder image { width:74rpx; height:74rpx; }
+.poster-placeholder .placeholder-small { font-size:18rpx; color:#97717D; }
+.preview-caption { color:#97717D; font-size:23rpx; text-align:center; margin:18rpx 0 4rpx; }
+.asset-status { color:#97717D; font-size:23rpx; line-height:1.6; padding:10rpx 0 16rpx; }
+.ready-status { color:#97717D; }
+.asset-error { color:#A3294D; display:flex; align-items:center; justify-content:space-between; gap:12rpx; font-size:24rpx; line-height:1.6; padding:10rpx 0; }
+.share-actions { display:flex; gap:16rpx; }
+.share-actions button { flex:1; min-width:0; }
+.sharing-page .primary-button { background:#D92554; color:#FFF9F5; }
+.sharing-page .secondary-button { background:#FFF9F5; color:#A3294D; border:1rpx solid #EED3DB; }
+.section-heading { font-size:32rpx; color:#A3294D; font-weight:600; margin:34rpx 4rpx 18rpx; }
+.results-panel { border:1rpx solid #EED3DB; border-radius:26rpx; padding:26rpx; }
+.stat-grid { display:flex; padding:2rpx 0 28rpx; }
+.stat-item { flex:1; display:flex; flex-direction:column; gap:8rpx; padding-left:14rpx; }
+.stat-item + .stat-item { border-left:1rpx solid #EED3DB; padding-left:40rpx; }
+.stat-number { font-size:52rpx; color:#A3294D; line-height:1.2; font-weight:600; }
+.stat-label { font-size:24rpx; color:#97717D; }
+.ambassador-block { border-top:1rpx solid #EED3DB; padding-top:24rpx; }
+.ambassador-title { display:flex; justify-content:space-between; align-items:center; color:#A3294D; font-size:28rpx; font-weight:500; }
+.progress-count { font-size:25rpx; }
+.progress-track { height:10rpx; margin:22rpx 0; border-radius:10rpx; background:#EED3DB; overflow:hidden; }
+.progress-fill { height:100%; background:#D92554; border-radius:10rpx; }
+.ambassador-bottom { display:flex; align-items:center; justify-content:space-between; gap:16rpx; }
+.ambassador-note { font-size:25rpx; line-height:1.5; flex:1; }
+.ambassador-subnote { display:block; color:#97717D; font-size:22rpx; margin-top:8rpx; }
+.sharing-page .apply-button { padding:20rpx; background:#D92554; color:#FFF9F5; font-size:25rpx; flex-shrink:0; }
+.orders-notice { display:flex; justify-content:space-between; gap:16rpx; padding:28rpx 6rpx 0; font-size:25rpx; }
+.orders-notice text:last-child { color:#97717D; font-size:23rpx; }
+.list-heading { display:flex; align-items:baseline; justify-content:space-between; flex-wrap:wrap; gap:10rpx; }
+.section-meta { font-size:22rpx; font-weight:400; color:#97717D; }
+.invite-list { border-top:1rpx solid #EED3DB; }
+.invite-row { display:flex; align-items:center; gap:18rpx; padding:26rpx 0; border-bottom:1rpx solid #EED3DB; }
+.invite-avatar { width:66rpx; height:66rpx; border-radius:50%; background:#FFF1F4; color:#A3294D; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
+.invite-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:28rpx; }
+.verified-badge,.pending-badge { font-size:21rpx; flex-shrink:0; border-radius:8rpx; padding:8rpx 10rpx; }
+.verified-badge { color:#A3294D; background:#FFF1F4; }
+.pending-badge { color:#97717D; }
+.empty-state { text-align:center; padding:50rpx 24rpx; color:#97717D; font-size:25rpx; line-height:1.8; }
+.empty-title { color:#A3294D; font-size:29rpx; margin-bottom:10rpx; }
+.list-status { padding:30rpx 0; text-align:center; color:#97717D; font-size:24rpx; }
+.sharing-page .load-more { background:transparent; color:#A3294D; width:100%; padding:30rpx 0; font-size:25rpx; }
+.personal-page { padding-bottom:calc(172rpx + env(safe-area-inset-bottom)); }
+.personal-page .intro-title { font-size:36rpx; }
+.card-tools { margin:0 4rpx 12rpx; }
+.personal-stage { padding:24rpx; border-radius:28rpx; background:#FFF1F4; }
+.personal-poster { display:block; width:100%; border-radius:18rpx; box-shadow:0 12rpx 32rpx rgba(163,41,77,.12); }
+.personal-fallback { background:#FFF9F5; border-radius:18rpx; padding:28rpx; }
+.fallback-brand { display:flex; align-items:center; gap:12rpx; color:#A3294D; font-size:26rpx; font-weight:600; margin-bottom:24rpx; }
+.fallback-brand image { width:44rpx; height:44rpx; }
+.fallback-photo { width:100%; height:360rpx; border-radius:16rpx; display:block; background:#FFF1F4; }
+.fallback-blank { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:24rpx; color:#97717D; }
+.fallback-blank image { width:96rpx; height:96rpx; }
+.fallback-name { color:#A3294D; font-size:34rpx; font-weight:600; margin:24rpx 0 12rpx; }
+.fallback-details { color:#97717D; font-size:23rpx; }
+.fallback-intro { font-size:25rpx; line-height:1.7; margin:18rpx 0; max-height:130rpx; overflow:hidden; }
+.fallback-footer { border-top:1rpx solid #EED3DB; color:#97717D; padding-top:20rpx; font-size:23rpx; }
+.card-status { padding:22rpx 8rpx 0; }
+.card-tip { font-size:23rpx; color:#97717D; text-align:center; padding:20rpx 0; line-height:1.7; }
+.card-dock { position:fixed; z-index:20; bottom:0; left:0; right:0; display:flex; gap:18rpx; padding:22rpx 28rpx; padding-bottom:calc(22rpx + env(safe-area-inset-bottom)); background:#FFF9F5; border-top:1rpx solid #EED3DB; }
+.card-dock button { flex:1; }
+.drawing-canvas { position:fixed; left:-2000px; top:0; width:750px; height:1120px; pointer-events:none; }
+.application-mask { position:fixed; top:0; right:0; bottom:0; left:0; z-index:1000; background:rgba(78,65,69,.6); display:flex; align-items:center; justify-content:center; padding:24rpx; box-sizing:border-box; }
.application-dialog { width:100%; max-width:640rpx; max-height:90vh; overflow-y:auto; background:#FFF9F5; border-radius:28rpx; padding:28rpx; box-sizing:border-box; }
.application-title { color:#A3294D; font-size:34rpx; font-weight:600; text-align:center; }
-.application-note { 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-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; }
diff --git a/src/utils/sharing-cache.js b/src/utils/sharing-cache.js
new file mode 100644
index 0000000..fd59509
--- /dev/null
+++ b/src/utils/sharing-cache.js
@@ -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>> 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 }
+}
diff --git a/src/utils/sharing-photo.js b/src/utils/sharing-photo.js
new file mode 100644
index 0000000..50e4c1d
--- /dev/null
+++ b/src/utils/sharing-photo.js
@@ -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||''
+}
diff --git a/src/utils/sharing-runtime.js b/src/utils/sharing-runtime.js
new file mode 100644
index 0000000..5c2c8c1
--- /dev/null
+++ b/src/utils/sharing-runtime.js
@@ -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 || ''}
+}
diff --git a/src/utils/sharing.js b/src/utils/sharing.js
index a0d5845..b7265ae 100644
--- a/src/utils/sharing.js
+++ b/src/utils/sharing.js
@@ -1,116 +1,117 @@
-// Sharing destinations stay separate: inviting someone never advertises the
-// 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}`
-}
+import {boundedCall} from './sharing-runtime'
-export function storeSharingCode(data, kind) {
- const extension = data.mime === 'image/png' ? 'png' : data.mime === 'image/jpeg' ? 'jpg' : ''
- if (!extension || !data.image) return Promise.reject(new Error('分享码格式无效'))
- const filePath = `${wx.env.USER_DATA_PATH}/fulian-${kind}.${extension}`
- return new Promise((resolve,reject) => wx.getFileSystemManager().writeFile({filePath,data:data.image,encoding:'base64',success:()=>resolve(filePath),fail:reject}))
+const LOGO='/pages/users/assets/fulian-logo.png'
+export function sharingPath(id,personal) {
+ const value=Number(id)
+ if (!Number.isSafeInteger(value)||value<=0) throw new Error('请先登录后分享')
+ return personal ? `/pages/home/information?id=${value}` : `/pages/tabBar/welcome?from_user_id=${value}`
}
-
-function imageInfo(src) {
- return new Promise((resolve, reject) => wx.getImageInfo({src, success: resolve, fail: reject}))
+export function storeSharingCode(data,kind,id) {
+ const extension=data.mime==='image/png'?'png':data.mime==='image/jpeg'?'jpg':''
+ if (!extension||!data.image) return Promise.reject(new Error('分享码暂不可用,请重试'))
+ const path=`${wx.env.USER_DATA_PATH}/fulian-sharing-qr-${Number(id)}-${kind}.${extension}`
+ return boundedCall((resolve,reject)=>wx.getFileSystemManager().writeFile({filePath:path,data:data.image,encoding:'base64',success:()=>resolve(path),fail:reject}), '分享码保存失败',3000)
}
-
-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) {
- return new Promise((resolve, reject) => wx.saveImageToPhotosAlbum({filePath, success:resolve, fail:reject}))
+ return boundedCall((resolve,reject)=>wx.saveImageToPhotosAlbum({filePath,success:resolve,fail:reject}), '图片未能保存,请检查相册权限',15000)
}
-
-export async function renderSharingCover(page, profile, personal) {
- const logo=await imageInfo('/pages/users/assets/fulian-logo.png')
- const photo=personal && profile.photo ? await imageInfo(profile.photo) : null
- const ctx=wx.createCanvasContext('fulianPoster',page.$wx)
- ctx.setFillStyle('#FFF9F5'); ctx.fillRect(0,0,750,600)
- ctx.drawImage(logo.path,40,32,64,64)
- ctx.setFillStyle('#A3294D'); ctx.setFontSize(28); ctx.fillText('上福恋认真找对象',124,74)
- if (photo) {
- const ratio=Math.max(290/photo.width,360/photo.height),sw=290/ratio,sh=360/ratio
- ctx.drawImage(photo.path,(photo.width-sw)/2,(photo.height-sh)/2,sw,sh,420,150,290,360)
+function check(job) { if (job && job.cancelled) throw new Error('已停止生成,请重试') }
+function font(ctx,size,weight=400) { ctx.font=`${weight} ${size}px sans-serif` }
+function roundRect(ctx,x,y,w,h,r,fill) {
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath()
+ if(fill){ctx.fillStyle=fill;ctx.fill()}
+}
+function textLines(ctx,text,x,y,width,lineHeight,limit) {
+ let line='',row=0
+ for(const char of Array.from(String(text||'').replace(/\s+/g,' ').trim())){
+ if(ctx.measureText(line+char).width>width){
+ if(row===limit-1){while(line&&ctx.measureText(line+'…').width>width)line=line.slice(0,-1);ctx.fillText(line+'…',x,y+row*lineHeight);return}
+ const carry=/[,。!?;:、)】》」』]/.test(char)?line.slice(-1):''
+ if(carry)line=line.slice(0,-1)
+ ctx.fillText(line,x,y+row*lineHeight);row++;line=carry
+ }
+ line+=char
}
- ctx.setFillStyle('#A3294D');ctx.setFontSize(42)
- 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))
+ ctx.fillText(line,x,y+row*lineHeight)
}
+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)}
diff --git a/tools/sharing/VALIDATION-20261102.md b/tools/sharing/VALIDATION-20261102.md
new file mode 100644
index 0000000..dc0eaf6
--- /dev/null
+++ b/tools/sharing/VALIDATION-20261102.md
@@ -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 为准。此前各段“未合并”描述为当时验证状态。
diff --git a/tools/sharing/check-cache.cjs b/tools/sharing/check-cache.cjs
new file mode 100644
index 0000000..00161bf
--- /dev/null
+++ b/tools/sharing/check-cache.cjs
@@ -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})
diff --git a/tools/sharing/check-contact.cjs b/tools/sharing/check-contact.cjs
index c7d4999..6d42a61 100644
--- a/tools/sharing/check-contact.cjs
+++ b/tools/sharing/check-contact.cjs
@@ -1,27 +1,15 @@
-const fs=require('fs'),path=require('path'),vm=require('vm'),assert=require('assert')
-const source=fs.readFileSync(path.join(__dirname,'../../src/pages/users/myShare.wpy'),'utf8').match(/