feat(h5): remember profile selections and add finger-following skin swipe

This commit is contained in:
xsl
2026-10-12 21:10:00 +08:00
parent c9352fe4ca
commit ce6dbc20d4
14 changed files with 2230 additions and 900 deletions
+94 -26
View File
@@ -96,6 +96,7 @@
}
function showScreen(id, nav) { // nav: 'push' 新增历史 | 'replace' 替换当前历史 | 缺省不动历史
if (id !== 's-login' && !AccountClient.user() && !(/[?&]mock=1/.test(location.search))) id = 's-login';
if (id === 's-profile' || profileOwner !== ProfileStore.key(AccountClient.user())) restoreProfile();
if (['s-profile', 's-cam', 's-report'].includes(id)) loadAppFont();
if (id === 's-profile' || id === 's-cam') prepareScanArtwork();
document.querySelectorAll('.screen').forEach(function (s) {
@@ -265,7 +266,7 @@
var item = document.createElement('div');
item.className = 'skin-item';
item.innerHTML =
'<img src="' + s.img + '"' + (i === 0 ? '' : ' loading="lazy"') + ' alt="' + s.name + '">' +
'<img src="' + s.img + '" draggable="false"' + (i === 0 ? '' : ' loading="lazy"') + ' alt="' + s.name + '">' +
'<span class="skin-name">' + s.name + '</span>' +
'<p class="skin-desc">' + s.desc + '</p>';
track.appendChild(item);
@@ -275,67 +276,129 @@
dotsBox.appendChild(dot);
});
function goSkin(i) {
function goSkin(i, persist) {
skinIndex = Math.max(0, Math.min(SKINS.length - 1, i));
track.classList.remove('dragging');
track.style.transform = 'translateX(-' + skinIndex * 100 + '%)';
dotsBox.querySelectorAll('i').forEach(function (d, k) {
d.classList.toggle('on', k === skinIndex);
});
$('.skin-arrow.prev').disabled = skinIndex === 0;
$('.skin-arrow.next').disabled = skinIndex === SKINS.length - 1;
$('#skin-carousel').setAttribute('aria-label', '日常肤质:' + SKINS[skinIndex].name);
if (persist !== false) saveProfile();
}
document.querySelectorAll('[data-skin-step]').forEach(function (b) {
b.addEventListener('click', function () { goSkin(skinIndex + (+b.dataset.skinStep)); });
});
goSkin(0);
// 拖拽切换(Pointer Events 统一鼠标 + 触摸)
var drag = { id: null, startX: 0, startY: 0, horizontal: false, suppress: false };
// Follow the finger immediately, then snap with distance/flick intent.
// pan-y and direction locking preserve normal vertical page scrolling.
var drag = { id: null, horizontal: false, vertical: false, suppressUntil: 0 };
var carousel = $('#skin-carousel');
function cancelSkinDrag() {
var id = drag.id;
drag.id = null; drag.horizontal = false; drag.vertical = false;
try { if (id != null && carousel.hasPointerCapture(id)) carousel.releasePointerCapture(id); } catch (_) {}
goSkin(skinIndex, false);
}
carousel.addEventListener('pointerdown', function (e) {
if (e.cancelable) e.preventDefault(); // 阻止原生图片拖拽/文字选中引发 pointercancel(click 仍会正常触发)
if (drag.id != null || e.isPrimary === false || e.button !== 0 || !carousel.clientWidth) return;
try { carousel.setPointerCapture(e.pointerId); } catch (err) { /* 非活动指针时忽略,退化为普通监听 */ }
// 捕获指针:斜拖出卡片、卡外松手也能收到 pointerup
drag.id = e.pointerId;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.horizontal = false;
drag.suppress = false;
drag.offset = track.getBoundingClientRect().left - carousel.getBoundingClientRect().left;
drag.width = carousel.clientWidth;
drag.lastX = e.clientX; drag.lastAt = e.timeStamp; drag.velocity = 0;
drag.horizontal = false; drag.vertical = false;
});
carousel.addEventListener('pointermove', function (e) {
if (e.pointerId !== drag.id) return;
if (e.pointerId !== drag.id || drag.vertical) return;
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
// 横向位移明显大于纵向时才判定为拖拽,避免吞掉纵向滚动
if (!drag.horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.horizontal = true;
if (drag.horizontal && e.cancelable) e.preventDefault();
if (!drag.horizontal) {
if (Math.abs(dy) > 8 && Math.abs(dy) >= Math.abs(dx)) { drag.vertical = true; return; }
if (Math.abs(dx) <= 8 || Math.abs(dx) <= Math.abs(dy)) return;
drag.horizontal = true; track.classList.add('dragging');
}
if (e.cancelable) e.preventDefault();
var elapsed = e.timeStamp - drag.lastAt;
if (elapsed > 0) drag.velocity = (e.clientX - drag.lastX) / elapsed;
drag.lastX = e.clientX; drag.lastAt = e.timeStamp;
var offset = drag.offset + dx, min = -(SKINS.length - 1) * drag.width;
if (offset > 0) offset *= .25;
else if (offset < min) offset = min + (offset - min) * .25;
track.style.transform = 'translate3d(' + offset + 'px,0,0)';
});
carousel.addEventListener('pointerup', function (e) {
if (e.pointerId !== drag.id) return;
var dx = e.clientX - drag.startX;
if (drag.horizontal) {
if (Math.abs(dx) > 30) goSkin(skinIndex + (dx < 0 ? 1 : -1));
drag.suppress = true; // 拖拽后吞掉紧随的 click,防止点击穿透
var start = Math.round(-drag.offset / drag.width), target = start;
var speed = e.timeStamp - drag.lastAt > 100 ? 0 : drag.velocity;
if (Math.abs(dx) >= Math.min(50, drag.width * .2) || (Math.abs(dx) > 12 && Math.abs(speed) > .4)) {
target = Math.round(-(drag.offset + dx) / drag.width);
if (target === start) target += (Math.abs(speed) > .4 ? speed : dx) < 0 ? 1 : -1;
}
goSkin(target);
drag.suppressUntil = Date.now() + 350;
}
drag.id = null;
drag.horizontal = false;
drag.horizontal = false; drag.vertical = false;
});
carousel.addEventListener('pointercancel', function (e) {
if (e.pointerId !== drag.id) return; // 其它指针的 cancel 不打断当前拖拽
drag.id = null; // 拖拽被系统取消(滚轮/新指针等):干净结束,不残留状态
drag.horizontal = false;
if (e.pointerId === drag.id) cancelSkinDrag();
});
carousel.addEventListener('lostpointercapture', function (e) {
if (e.pointerId === drag.id) cancelSkinDrag();
});
carousel.addEventListener('dragstart', function (e) { e.preventDefault(); });
carousel.addEventListener('click', function (e) {
if (drag.suppress) { e.preventDefault(); e.stopPropagation(); drag.suppress = false; }
if (Date.now() < drag.suppressUntil) { e.preventDefault(); e.stopPropagation(); }
}, true);
carousel.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); goSkin(skinIndex + (e.key === 'ArrowRight' ? 1 : -1)); }
});
window.addEventListener('resize', cancelSkinDrag);
/* ─── 档案选择控件:出生年月 / 性别 / 省份城市,统一用 mobile-select 底部滚轮(选中值写入同名 hidden input) ─── */
var NOW = new Date(), NOW_Y = NOW.getFullYear(), NOW_M = NOW.getMonth() + 1;
var NOW_Y = new Date().getFullYear();
var YEARS = [], MONTHS = [];
for (var yy = NOW_Y - 99; yy <= NOW_Y - 1; yy++) YEARS.push(yy + '年');
for (var mm = 1; mm <= 12; mm++) MONTHS.push(mm + '月');
var regionSel = $('#f-region');
var regionSel = $('#f-region'), regionSelection = null;
var profileOwner = null, profileStore = ProfileStore.create(function () { return window.localStorage; });
function setPick(sel, text) { $(sel + ' .pick-value').textContent = text; }
function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未过生日月份按少一岁
function ageFromBirth(y, m) { return ProfileStore.age(y + '-' + (m < 10 ? '0' : '') + m); }
function saveProfile() {
var user = AccountClient.user();
if (!profileStore || !profileOwner || profileOwner !== ProfileStore.key(user)) return;
profileStore.write(user, ProfileStore.clean({ birth: $('#f-birth').value, gender: $('#f-gender').value,
region: regionSelection, skinIndex: skinIndex }, window.REGION_DATA, SKINS.length));
}
function restoreProfile() {
var user = AccountClient.user();
profileOwner = ProfileStore.key(user);
var saved = ProfileStore.clean(profileStore.read(user), window.REGION_DATA, SKINS.length);
var y = Number(saved.birth.slice(0, 4)), m = Number(saved.birth.slice(5));
if (saved.birth && YEARS.indexOf(y + '年') < 0) saved.birth = '';
$('#f-birth').value = saved.birth;
$('#f-age').value = saved.birth ? ProfileStore.age(saved.birth) : '';
$('#f-gender').value = saved.gender;
regionSelection = saved.region;
var region = saved.region, text = region ? region.province + (region.city === region.province ? '' : ' ' + region.city) : '';
regionSel.value = text;
setPick('#pick-birth', saved.birth ? y + '年' + m + '月' : '');
setPick('#pick-gender', saved.gender); setPick('#pick-region', text);
if (birthPicker) birthPicker.setValue(saved.birth ? [y + '年', m + '月'] : [(NOW_Y - 30) + '年', '1月']);
if (genderPicker) genderPicker.setValue([saved.gender || '女']);
var initial = (window.REGION_DATA || [])[0];
if (regionPicker && (region || initial)) regionPicker.setValue([
{ value: region ? region.province : initial[0] }, { value: region ? region.city : initial[1][0] }
]);
cancelSkinDrag(); goSkin(saved.skinIndex, false);
}
function makePicker(trigger, opts) {
if (typeof MobileSelect === 'undefined') return null;
return new MobileSelect(Object.assign({
@@ -343,7 +406,7 @@
ensureBtnText: '确定', cancelBtnText: '取消'
}, opts));
}
makePicker('#pick-birth', {
var birthPicker = makePicker('#pick-birth', {
title: '出生年月',
wheels: [{ data: YEARS }, { data: MONTHS }],
position: [YEARS.indexOf((NOW_Y - 30) + '年'), 0],
@@ -352,14 +415,15 @@
$('#f-birth').value = y + '-' + (m < 10 ? '0' : '') + m;
$('#f-age').value = ageFromBirth(y, m);
setPick('#pick-birth', y + '年' + m + '月');
saveProfile();
}
});
makePicker('#pick-gender', {
var genderPicker = makePicker('#pick-gender', {
title: '性别',
wheels: [{ data: ['女', '男'] }],
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); }
onChange: function (val) { $('#f-gender').value = val[0]; setPick('#pick-gender', val[0]); saveProfile(); }
});
makePicker('#pick-region', {
var regionPicker = makePicker('#pick-region', {
title: '所在地区',
wheels: [{ data: (window.REGION_DATA || []).map(function (p) {
return { id: p[0], value: p[0], childs: p[1].map(function (c) { return { id: c, value: c }; }) };
@@ -368,7 +432,9 @@
var prov = val[0] && val[0].value, city = val[1] && val[1].value;
var text = !city || city === prov ? prov : prov + ' ' + city; // 直辖市/港澳台只显示一级
regionSel.value = text;
regionSelection = { province: prov, city: city || prov };
setPick('#pick-region', text);
saveProfile();
}
});
@@ -1549,10 +1615,12 @@
showScreen('s-profile', 'push');
},
'submit-profile': function () {
$('#f-age').value = ProfileStore.age($('#f-birth').value) || '';
var age = +$('#f-age').value;
if (!$('#f-birth').value || !(age >= 1 && age <= 99)) { toast('请选择出生年月'); return; }
if (!$('#f-gender').value) { toast('请选择性别'); return; }
if (!regionSel.value) { toast('请选择所在地区'); return; }
saveProfile();
openOverlay('p-photoguide');
},
'close-photoguide': function () { closeOverlay('p-photoguide'); },
+49
View File
@@ -0,0 +1,49 @@
/* Account-scoped, local-only form preferences. No credentials or report data. */
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.ProfileStore = factory();
})(typeof window !== 'undefined' ? window : this, function() {
'use strict';
function key(user) {
return user && ['browser', 'wechat'].indexOf(user.kind) >= 0 && typeof user.id === 'string' && user.id.length <= 128 && user.id
? 'juzi.profile.v1.' + user.kind + '.' + encodeURIComponent(user.id) : null;
}
function age(birth, now) {
if (typeof birth !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(birth)) return null;
now = now || new Date();
var y = Number(birth.slice(0, 4)), m = Number(birth.slice(5));
var value = now.getFullYear() - y - (now.getMonth() + 1 < m ? 1 : 0);
return value >= 1 && value <= 99 ? value : null;
}
function clean(input, regions, skinCount, now) {
input = input && typeof input === 'object' ? input : {};
var region = input.region, validRegion = null;
if (region && typeof region === 'object') (regions || []).some(function(p) {
if (p[0] !== region.province || p[1].indexOf(region.city) < 0) return false;
validRegion = { province: p[0], city: region.city }; return true;
});
return { birth: age(input.birth, now) == null ? '' : input.birth,
gender: ['女', '男'].indexOf(input.gender) < 0 ? '' : input.gender,
region: validRegion, skinIndex: Number.isInteger(input.skinIndex) && input.skinIndex >= 0 && input.skinIndex < skinCount ? input.skinIndex : 0 };
}
function create(getStorage) {
var memory = Object.create(null);
return {
read: function(user) {
var name = key(user), raw;
if (!name) return null;
if (Object.prototype.hasOwnProperty.call(memory, name)) return memory[name];
try { raw = getStorage().getItem(name); } catch (_) { /* Private/restricted storage. */ }
if (raw == null) return null;
try { return raw.length <= 4096 ? JSON.parse(raw) : null; } catch (_) { return null; }
},
write: function(user, value) {
var name = key(user);
if (!name) return;
memory[name] = value;
try { getStorage().setItem(name, JSON.stringify(value)); } catch (_) { /* Keep this page usable without persistence. */ }
}
};
}
return { key: key, age: age, clean: clean, create: create };
});