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