diff --git a/taobao/app.acss b/taobao/app.acss index 6dc5c28..0081627 100644 --- a/taobao/app.acss +++ b/taobao/app.acss @@ -1,83 +1,113 @@ -/* ═════ 全局样式(对齐 H5 设计变量:--yellow #F5F078 / --ink #282828 / --bg #EFEFEF) ═════ */ +/* ═════ 全局样式 —— 对齐 H5 h5/css/style.css 的 1'me 设计变量 ═════ + 单位换算:H5 的 calc(N * var(--u))(375 设计宽基准)→ 2N rpx(750rpx = 屏宽)。 + H5 里固定 px 的字号保持 px 不变。 + 注意:淘宝小程序 acss 不支持 @font-face,字体退化为系统字体栈。 */ page { - background: #101823; - font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + background: #EFEFEF; color: #282828; - font-size: 28rpx; + font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif; + font-size: 14px; + -webkit-font-smoothing: antialiased; } -/* ─── 主按钮(品牌黄) ─── */ +/* ─── 主按钮:长方形 + 小倒角(H5 设计稿 181×37,圆角 5) ─── */ .btn-primary { display: flex; align-items: center; justify-content: center; - height: 96rpx; - border-radius: 48rpx; + width: 362rpx; + height: 74rpx; background: #F5F078; + border-radius: 10rpx; color: #282828; - font-size: 32rpx; - font-weight: 600; + font-size: 14.5px; + font-weight: 500; + letter-spacing: 1rpx; border: none; + padding: 0; + line-height: 1; } .btn-primary::after { border: none; } -.btn-primary.btn-disabled { opacity: 0.5; } +.btn-primary.btn-disabled { opacity: .5; } -/* ─── 白色卡片(报告页通用) ─── */ -.card { - background: #FFFFFF; - border-radius: 24rpx; - margin: 24rpx 32rpx 0; - padding: 32rpx; +/* ─── 折线箭头(H5 .chev) ─── */ +.chev { + width: 9px; + height: 9px; + border: solid #2C2C2C; + border-width: 0 2px 2px 0; + display: inline-block; + padding: 2px; box-sizing: border-box; } -.card-heading { - font-size: 32rpx; - font-weight: 600; - margin-bottom: 24rpx; +.chev.left { transform: rotate(135deg); } +.chev.right { transform: rotate(-45deg); } +.chev.down { transform: rotate(45deg); } + +/* ─── 圆形返回按钮(H5 .nav-back) ─── */ +.nav-back { + width: 27px; + height: 27px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #E5E5E5; + border-radius: 50%; + background: #fff; + box-sizing: border-box; } -/* ─── 弹窗遮罩 ─── */ +/* ─── 弹窗(H5 隐私协议/政策层) ─── */ .overlay { position: fixed; left: 0; top: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, .6); z-index: 100; display: flex; align-items: center; justify-content: center; } .popup { - width: 600rpx; - background: #FFFFFF; - border-radius: 24rpx; - padding: 48rpx 40rpx 40rpx; - box-sizing: border-box; position: relative; + width: 640rpx; + max-height: 78vh; + background: #FFFFFF; + border-radius: 14rpx; + padding: 44rpx 40rpx 36rpx; + box-sizing: border-box; } +.popup-scroll { max-height: 52vh; } .popup-title { - font-size: 34rpx; - font-weight: 600; + font-size: 19px; + font-weight: 700; text-align: center; - line-height: 1.5; + line-height: 1.4; + color: #282828; margin-bottom: 24rpx; } .popup-body { - font-size: 26rpx; + font-size: 13px; color: #4A4A4A; - line-height: 1.7; - margin-bottom: 32rpx; + line-height: 1.75; } .popup-close { position: absolute; - top: 24rpx; - right: 24rpx; - width: 48rpx; - height: 48rpx; - line-height: 48rpx; + top: 16rpx; + right: 16rpx; + width: 56rpx; + height: 56rpx; + line-height: 56rpx; text-align: center; - font-size: 36rpx; + font-size: 18px; color: #868686; background: none; padding: 0; } .popup-close::after { border: none; } + +/* ─── 卡片(H5 .card) ─── */ +.card { + background: #FFFFFF; + border-radius: 14rpx; + box-sizing: border-box; +} diff --git a/taobao/app.js b/taobao/app.js index b8985b6..a4f9291 100644 --- a/taobao/app.js +++ b/taobao/app.js @@ -16,5 +16,14 @@ App({ }, onHide: function () { require('./utils/voice.js').stop(true); + }, + // 真机上看不到 console:开发版里把未捕获异常弹出到屏幕上,方便截图定位。 + onError: function (error) { + try { + var config = require('./config'); + if (config.environment === 'production') return; + var text = String((error && (error.message || error.stack)) || error); + my.showToast({ content: 'JS错误: ' + text.slice(0, 120), type: 'none', duration: 8000 }); + } catch (_) {} } }); diff --git a/taobao/app.json b/taobao/app.json index f8d8462..9fc70bd 100644 --- a/taobao/app.json +++ b/taobao/app.json @@ -5,7 +5,8 @@ "pages/camera/camera", "pages/report/report", "pages/history/history", - "pages/mvp/mvp" + "pages/mvp/mvp", + "pages/camtest/camtest" ], "window": { "defaultTitle": "1'me在线肌肤测试", diff --git a/taobao/config.js b/taobao/config.js index eabb880..e0f097e 100644 --- a/taobao/config.js +++ b/taobao/config.js @@ -1,16 +1,27 @@ // Public configuration only. AppSecret and Face++ keys stay on the server. +// +// Cloud test for 聚石塔 SAE: +// cloudType 'tower' = 聚石塔云应用(不向网关传 domain) +// cloudType 'empty' = 空应用/外部服务(传 domain,baseUrl 必须 HTTPS) +// To go back to local direct: +// transport: 'direct', baseUrl: 'http://127.0.0.1:18180', mvpLocal: true module.exports = { environment: 'development', - cameraDebug: true, // Offline camera acceptance only; disabled outside local development. - transport: 'direct', // Local development. Use 'cloud' with real gateway configuration. - baseUrl: 'http://127.0.0.1:18180', - assetBase: 'http://127.0.0.1:18180/assets/', - cloudAppId: '', - cloudEnv: 'online', // External/empty cloud applications use online - basePath: '', // E.g. '/state' behind an existing reverse proxy - // SAE/Java MVP page only: force the local Java backend without changing the main app config. - // Set false and edit transport/baseUrl/cloudAppId above when testing the cloud gateway. - mvpLocal: true, + cameraDebug: false, + transport: 'cloud', + baseUrl: 'https://miniappcloud.taobao.com', // 'tower' 模式不参与请求;仅占位 + // 真机可通过公网 HTTPS 加载的静态资源(启动页底图、肤质卡、语音、商品图)。 + // 127.0.0.1 在手机上指向手机自己,会全部加载失败。 + assetBase: 'https://1me.xajuzi.com/state/assets/', + cloudType: 'tower', + cloudAppId: '', // 聚石塔默认云应用可留空;多个云应用时填关联页的 cloudAppId + cloudEnv: 'test', + basePath: '', + // 淘宝 组件在真机(SDK 2.9.4)上会整页崩溃,且 canIUse('camera') 是假阳性。 + // 关掉后拍照页改为「系统相机 / 相册」两条 my.chooseImage 通道(权限 scope.camera 已授予)。 + // 等确认组件可用后把这里改回 true 即可恢复页内取景。 + nativeCamera: false, + mvpLocal: false, mvpBaseUrl: 'http://127.0.0.1:8080', mock: false, productItemIds: {} // Optional real Taobao item IDs keyed by tier 1/2/3 diff --git a/taobao/docs/readme.txt b/taobao/docs/readme.txt new file mode 100644 index 0000000..236df8c --- /dev/null +++ b/taobao/docs/readme.txt @@ -0,0 +1,3 @@ +https://miniapp.open.taobao.com/docV3.htm?docId=119114&docType=1&tag=dev +https://open.taobao.com/doc.htm?docId=121164&docType=1 +https://miniapp.open.taobao.com/docV3.htm?spm=a219a.13922336.0.0.6e99286cfVnRmI&docId=118541&docType=1&tag=dev \ No newline at end of file diff --git a/taobao/mini.project.json b/taobao/mini.project.json index f4441e8..a95477a 100644 --- a/taobao/mini.project.json +++ b/taobao/mini.project.json @@ -3,11 +3,24 @@ "compileType": "mini", "miniprogramRoot": "./", "appid": "", - "exclude": ["scripts/**", "test/**", "docs/**", "README.md", "package-lock.json"], - "uploadExclude": ["scripts/**", "test/**", "docs/**", "README.md", "package-lock.json"], + "exclude": [ + "scripts/**", + "test/**", + "docs/**", + "README.md", + "package-lock.json" + ], + "uploadExclude": [ + "scripts/**", + "test/**", + "docs/**", + "README.md", + "package-lock.json" + ], "enableAppxNg": true, "component2": true, "transpile": { "es6": true - } + }, + "miniappCanary": false } diff --git a/taobao/pages/camera/camera.acss b/taobao/pages/camera/camera.acss index 09dd848..da8c111 100644 --- a/taobao/pages/camera/camera.acss +++ b/taobao/pages/camera/camera.acss @@ -1,183 +1,259 @@ -/* ═════ 拍照页样式 ═════ */ +/* ═════ 05-07 拍照页 ═════ + 对齐 H5 h5/css/style.css 的 #s-cam 静态态: + .cam-step top 7.1%,21.5px/500,letter-spacing 1u,text-shadow + .cam-guide bottom 22.4%,14.5px,分隔线 15u 高 + .cam-controls bottom 5.2%,高 62u,内边距 56u,快门 62u + 单位换算:calc(N * var(--u)) → 2N rpx */ .cam { - position: relative; - width: 100%; - height: 100vh; - background: #101823; + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + overflow: hidden; + background: #000; } -.cam-device { - width: 100%; - height: 100vh; +/* H5 静态态:白底 + 85% 黑蒙版 ≈ #262626 */ +.cam-bg { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + background: #262626; } -/* ─── 步骤文案 ─── */ +/* ─── 顶部步骤文案 ─── */ .cam-step { position: absolute; - top: 80rpx; - left: 0; - right: 0; + left: 0; right: 0; + top: 7.1%; text-align: center; + color: #fff; + font-size: 21.5px; + font-weight: 500; + line-height: 1.5; + letter-spacing: 2rpx; + text-shadow: 0 1px 10px rgba(0, 0, 0, .4); + z-index: 2; } -.cam-step-no { - color: #FFFFFF; - font-size: 48rpx; - font-weight: 600; -} -.cam-step-tip { - color: #FFFFFF; - font-size: 40rpx; - font-weight: 600; - margin-top: 8rpx; +.cam-step-tip { font-size: 21.5px; } +.cam-step-no { font-size: 21.5px; } + +/* ─── 真机取景相机(铺满,不能有子节点) ─── */ +.cam-device { + position: absolute; + left: 0; top: 0; + width: 100%; + height: 100%; + z-index: 0; } -/* ─── 取景蒙版(椭圆线框) ─── */ +/* ─── 取景蒙版:H5 用 SVG 镂空出人脸轮廓;这里用「椭圆开窗 + 巨大 box-shadow」实现同样的暗框 + 中间透亮 ─── */ .cam-mask { position: absolute; left: 0; top: 0; right: 0; bottom: 0; - display: flex; - align-items: center; - justify-content: center; + z-index: 1; + pointer-events: none; } -.cam-oval { - width: 560rpx; - height: 720rpx; - border: 4rpx solid rgba(255, 255, 255, 0.9); +.cam-hole { + position: absolute; + left: 50%; + top: 50%; + width: 440rpx; + height: 580rpx; + margin-left: -220rpx; + margin-top: -290rpx; border-radius: 50%; - box-sizing: border-box; + box-shadow: 0 0 0 3000rpx rgba(0, 0, 0, .82); } -/* 侧脸步轮廓偏移提示(骨架版:向左/右微移示意朝向) */ -.mask-1 { transform: translateX(-60rpx); } /* 左脸出镜:耳在画面左 */ -.mask-2 { transform: translateX(60rpx); } /* 右脸出镜:耳在画面右 */ +.mask-1 { transform: translateX(-26rpx) rotate(-4deg); } +.mask-2 { transform: translateX(26rpx) rotate(4deg); } -/* ─── 底部引导要点 ─── */ +/* ─── 底部引导条 ─── */ .cam-guide { position: absolute; - bottom: 260rpx; - left: 0; - right: 0; + left: 0; right: 0; + bottom: 22.4%; display: flex; justify-content: center; + align-items: center; + z-index: 2; } .cam-guide-item { - color: #FFFFFF; - font-size: 26rpx; - padding: 0 24rpx; - border-right: 2rpx solid rgba(255, 255, 255, 0.4); + color: #fff; + font-size: 14.5px; + font-weight: 400; +} +.cam-guide-sep { + width: 0; + height: 30rpx; + border-left: 1px solid rgba(255, 255, 255, .7); + margin: 0 52rpx; } -.cam-guide-item:last-child { border-right: none; } -/* ─── 控制条 ─── */ +/* ─── 控制条:相册 / 快门 / 翻转 ─── */ .cam-controls { position: absolute; - bottom: 80rpx; - left: 0; - right: 0; + left: 0; right: 0; + bottom: 5.2%; + height: 124rpx; display: flex; align-items: center; - justify-content: space-around; + justify-content: space-between; + padding: 0 112rpx; + box-sizing: border-box; + margin-bottom: env(safe-area-inset-bottom); + z-index: 2; } .cam-side { width: 120rpx; - height: 88rpx; - line-height: 88rpx; - text-align: center; - color: #FFFFFF; - font-size: 28rpx; -} -.cam-shutter { - width: 144rpx; - height: 144rpx; - border: 6rpx solid #FFFFFF; - border-radius: 50%; + height: 124rpx; display: flex; align-items: center; justify-content: center; + color: #fff; + font-size: 14.5px; +} +.cam-shutter { + width: 124rpx; + height: 124rpx; + border-radius: 50%; + border: 4rpx solid #fff; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; } .cam-shutter-dot { - width: 108rpx; - height: 108rpx; + width: 96rpx; + height: 96rpx; border-radius: 50%; - background: #F5F078; + background: #fff; } -/* ─── 停顿卡 ─── */ -.cam-done { +/* ─── 提示/错误 ─── */ +.cam-hint { position: absolute; - top: 45%; - left: 60rpx; - right: 60rpx; - background: rgba(0, 0, 0, 0.65); - color: #FFFFFF; - font-size: 28rpx; + left: 64rpx; + right: 64rpx; + bottom: 34%; text-align: center; - border-radius: 16rpx; - padding: 24rpx; + color: rgba(255, 255, 255, .9); + font-size: 13px; + line-height: 1.6; + z-index: 3; } /* ─── 拍后预览 ─── */ .cam-preview { position: absolute; left: 0; top: 0; right: 0; bottom: 0; - background: #000000; + background: #000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; } .cam-preview-img { width: 100%; - height: 78%; + height: 68%; } .cam-preview-btns { - position: absolute; - bottom: 80rpx; - left: 0; - right: 0; display: flex; - justify-content: space-around; + gap: 40rpx; + margin-top: 40rpx; } .cam-preview-btn { - width: 280rpx; - height: 88rpx; - line-height: 88rpx; + width: 220rpx; + height: 72rpx; + line-height: 72rpx; text-align: center; - border-radius: 44rpx; - border: 2rpx solid #FFFFFF; - color: #FFFFFF; - font-size: 30rpx; + border-radius: 10rpx; + border: 1px solid rgba(255, 255, 255, .6); + color: #fff; + font-size: 14.5px; } .cam-preview-btn.ok { background: #F5F078; border-color: #F5F078; color: #282828; - font-weight: 600; } /* ─── 分析中 ─── */ .cam-analyzing { position: absolute; left: 0; top: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.75); + background: rgba(0, 0, 0, .78); display: flex; + flex-direction: column; align-items: center; justify-content: center; + z-index: 20; } .cam-analyzing-text { - color: #FFFFFF; - font-size: 30rpx; + color: #fff; + font-size: 15px; + letter-spacing: 1rpx; +} +.cam-cancel { + margin-top: 32rpx; + color: rgba(255, 255, 255, .7); + font-size: 13px; + text-decoration: underline; } -/* ─── 隐藏画布(压缩降级用) ─── */ -.compress-canvas { - position: fixed; - left: -9999rpx; - top: -9999rpx; - width: 640px; - height: 640px; +/* ─── 权限诊断(开发版) ─── */ +.cam-diag-entry { + position: absolute; + top: 88rpx; + right: 32rpx; + z-index: 30; + padding: 12rpx 20rpx; + border-radius: 12rpx; + background: rgba(0, 0, 0, .55); + color: #fff; + font-size: 12px; } -.cam-analyzing { flex-direction: column; } -.cam-cancel { margin-top: 48rpx; color: #fff; font-size: 26rpx; padding: 24rpx; } -.camera-permission { padding: 56rpx 40rpx; color: #fff; line-height: 1.8; } -.camera-permission button { margin-top: 24rpx; } -.debug-banner { padding: 24rpx; background: #F5F078; color: #282828; font-size: 24rpx; } -.debug-results { padding: 32rpx; background: #101823; color: white; line-height: 1.8; } -.debug-title { font-size: 32rpx; font-weight: 600; } -.debug-result { margin: 24rpx 0; } -.debug-result image { display: block; width: 100%; height: 320rpx; } -.debug-results button { margin-top: 24rpx; } +.cam-diag { + position: absolute; + left: 32rpx; right: 32rpx; + top: 160rpx; bottom: 160rpx; + z-index: 40; + background: rgba(6, 16, 26, .96); + border: 1rpx solid rgba(120, 200, 255, .35); + border-radius: 20rpx; + padding: 20rpx; + display: flex; + flex-direction: column; +} +.cam-diag-scroll { flex: 1; } +.cam-diag-text { + color: #9fd0ff; + font-size: 11px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-all; +} +.cam-diag-close { + margin-top: 16rpx; + text-align: center; + background: #F5F078; + color: #282828; + border-radius: 12rpx; + padding: 14rpx 0; + font-size: 13px; +} + +/* ─── 调试模式结果 ─── */ +.cam-debug { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + background: #fff; + padding: 88rpx 40rpx 40rpx; + box-sizing: border-box; + overflow-y: auto; + z-index: 25; +} +.cam-debug-title { font-size: 17px; font-weight: 600; margin-bottom: 16rpx; } +.cam-debug-note { font-size: 12px; color: #707070; line-height: 1.6; margin-bottom: 24rpx; } +.cam-debug-item { margin-bottom: 32rpx; } +.cam-debug-img { width: 100%; height: 320rpx; background: #f2f2f2; } +.cam-debug-meta { font-size: 12px; color: #4A4A4A; margin-top: 12rpx; } +.cam-debug-btn { width: 100%; margin-top: 8rpx; } +.cam-debug-exit { margin-top: 24rpx; text-align: center; font-size: 13px; color: #707070; text-decoration: underline; } diff --git a/taobao/pages/camera/camera.axml b/taobao/pages/camera/camera.axml index 700390e..dd2f13a 100644 --- a/taobao/pages/camera/camera.axml +++ b/taobao/pages/camera/camera.axml @@ -1,77 +1,80 @@ + 两种形态,由 config.nativeCamera 决定: + ① cameraEnabled=true → 真机取景: 铺满 + 椭圆镂空蒙版 + 快门直接 takePhoto(对齐 H5 #s-cam) + ② cameraEnabled=false → 系统相机:静态版面 + 快门调 my.chooseImage + 关键: 不能嵌 (微信专有组件,淘宝上会导致整页崩溃), + 所以相机是兄弟节点,覆盖层用普通 view + box-shadow 镂空。 --> - 相机验收 · 免登录 · 照片只在本页处理,不上传 - - 三步拍摄与 JPEG 压缩已完成 - 请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。 - - - {{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB - - - + + + + + + + + + - - {{stepText}} · {{stepTip}} - {{permissionMessage}} - - - - - - - - - {{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}} + + + {{stepText}} + {{stepTip}} - - - - 开发调试 · 不上传照片 - {{stepText}} - {{stepTip}} - - - - - + + + + {{item}} + + - - - {{item}} - + + + + + 重拍 + 使用照片 + + - - - 相册 - - - - 翻转 - + + + {{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}} + {{debugMode ? '退出验收' : '返回,稍后查看历史报告'}} + - - {{doneTip}} + + + 相册 + + + + {{cameraEnabled ? '翻转' : '授权设置'}} + - - - - - 重拍 - 使用照片 - - + + {{permissionMessage}} - - - {{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}} - {{debugMode ? '退出验收' : '返回,稍后查看历史报告'}} - - + + 权限诊断 + + + {{diagText}} + + 关闭 + - - + + + 三步拍摄与 JPEG 压缩已完成 + 请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。 + + + {{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB + + + 退出并清空本页照片 + diff --git a/taobao/pages/camera/camera.js b/taobao/pages/camera/camera.js index 2d683c2..5076346 100644 --- a/taobao/pages/camera/camera.js +++ b/taobao/pages/camera/camera.js @@ -1,6 +1,7 @@ var api = require('../../utils/api'), image = require('../../utils/image'), voice = require('../../utils/voice'); var permissions = require('../../utils/permissions'); var development = require('../../utils/development'); +var config = require('../../config'); var STEPS = [ { target: 'front', tip: '请单人正脸出镜', turn: '正脸居中', mask: 0 }, { target: 'right', tip: '请右脸出镜', turn: '请向左转头', mask: 2 }, @@ -9,14 +10,24 @@ var STEPS = [ Page({ data: { stepIndex: 0, stepText: '1/3', stepTip: STEPS[0].tip, guides: ['正脸居中', '取下眼镜', '光线充足'], maskStep: 0, devicePosition: 'front', camError: false, previewUrl: '', doneTip: '', analyzing: false, - compressWidth: 1, compressHeight: 1, cameraEnabled: false, permissionMessage: '拍摄面部照片用于肌肤检测,请开启相机权限。', - debugMode: false, debugComplete: false, debugPhotos: [] }, + compressWidth: 1, compressHeight: 1, cameraEnabled: false, permissionMessage: '', + debugMode: false, debugComplete: false, debugPhotos: [], + showDiagnose: config.environment !== 'production', diagText: '', cameraSupported: false }, onLoad: function (query) { this.alive = true; this.visible = true; this.photos = []; this.busy = false; this.seq = 0; + var supported = config.nativeCamera === true && + (typeof permissions.cameraSupported === 'function' ? permissions.cameraSupported() : true); + this.cameraSupported = supported; this.debugMode = !!(query && query.debug === 'camera' && development.cameraDebugEnabled()); - this.setData({ debugMode: this.debugMode }); + this.setData({ debugMode: this.debugMode, cameraSupported: supported }); if (this.debugMode) return; - if (!api.user() || !getApp().globalData.profile) my.redirectTo({ url: '/pages/splash/splash' }); + var app = typeof getApp === 'function' ? getApp() : null; + if (!api.user() || !app || !app.globalData.profile) { my.redirectTo({ url: '/pages/splash/splash' }); return; } + if (!supported) { + // 淘宝 组件不可用:直接用系统相机/相册,页面不再挂相机组件(否则整页崩溃) + this.setData({ cameraEnabled: false, camError: true, + permissionMessage: '请用下方按钮拍摄,三张依次为正脸、右脸、左脸。' }); + } }, onReady: function () { this.pageReady = true; @@ -25,15 +36,17 @@ Page({ onShow: function () { this.visible = true; if (!this.data.previewUrl && !this.data.analyzing && !this.data.debugComplete) this.renderStep(); + if (!this.cameraSupported) return; // 平台没有内置相机:直接用系统相机/相册 var that = this; permissions.settings().then(function (current) { if (!that.alive || !that.visible) return; if (permissions.state(current, 'scope.camera') === true) { that.cameraAuthorized = true; that.mountCamera(); } else if (permissions.state(current, 'scope.camera') === false) that.setData({ cameraEnabled: false, permissionMessage: '相机权限未开启,可前往授权设置或使用相册。' }); - }); + }).catch(function () {}); }, mountCamera: function () { if (!this.alive || !this.visible || !this.pageReady || this.data.cameraEnabled || this.data.debugComplete) return; + if (!this.cameraSupported) return; var that = this; this.setData({ cameraEnabled: true, camError: false }, function () { if (!that.alive || !that.visible) return; @@ -56,6 +69,17 @@ Page({ if (permissions.state(current, 'scope.camera') === true) { that.cameraAuthorized = true; that.mountCamera(); } }).catch(function (err) { if (that.alive) my.showToast({ content: err.message, type: 'none' }); }); }, + onCloseDiagnose: function () { this.setData({ diagText: '' }); }, + // 开发版:把真机上的相机能力/授权返回值直接打在页面上,供截图定位 + onDiagnose: function () { + var that = this; + this.setData({ diagText: '检测中…' }); + permissions.diagnose().then(function (info) { + if (that.alive) that.setData({ diagText: info.join('\n') }); + }).catch(function (err) { + if (that.alive) that.setData({ diagText: 'diagnose error: ' + (err && err.message ? err.message : String(err)) }); + }); + }, onHide: function () { this.pause(); }, onUnload: function () { this.pause(); this.alive = false; this.photos = []; this.debugResults = []; }, pause: function () { @@ -89,7 +113,12 @@ Page({ if (file) that.setData({ previewUrl: file }); else my.showToast({ content: '拍摄失败,请重试', type: 'none' }); }, fail: function () { - if (that.alive && that.visible) my.showToast({ content: '拍摄失败,请重试', type: 'none' }); + if (!that.alive || !that.visible || seq !== that.seq) return; + // 真机上内置相机 takePhoto 失败(机型/组件差异、权限被拒等): + // 不打断流程,直接退回系统相机走 my.chooseImage 那条已验证过的路。 + that.busy = false; + my.showToast({ content: '内置相机不可用,改用系统相机', type: 'none' }); + that.choose(['camera']); }, complete: function () { if (seq === that.seq) that.busy = false; } }); }, choose: function (sourceType) { diff --git a/taobao/pages/camtest/camtest.acss b/taobao/pages/camtest/camtest.acss new file mode 100644 index 0000000..97419c3 --- /dev/null +++ b/taobao/pages/camtest/camtest.acss @@ -0,0 +1,20 @@ +.wrap { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + background: #000; +} +.cam { + position: absolute; + left: 0; top: 0; + width: 100%; + height: 100%; +} +.tip { + position: absolute; + left: 0; right: 0; + bottom: 80rpx; + text-align: center; + color: #fff; + font-size: 13px; + z-index: 2; +} diff --git a/taobao/pages/camtest/camtest.axml b/taobao/pages/camtest/camtest.axml new file mode 100644 index 0000000..5cc2279 --- /dev/null +++ b/taobao/pages/camtest/camtest.axml @@ -0,0 +1,6 @@ + + + + 如果能看到实时画面 → 相机组件可用 + diff --git a/taobao/pages/camtest/camtest.js b/taobao/pages/camtest/camtest.js new file mode 100644 index 0000000..9f36d84 --- /dev/null +++ b/taobao/pages/camtest/camtest.js @@ -0,0 +1,10 @@ +Page({ + data: {}, + onLoad: function () { + this.alive = true; + }, + onUnload: function () { this.alive = false; }, + onError: function (err) { + my.showToast({ content: '相机组件报错: ' + JSON.stringify(err || {}), type: 'none', duration: 6000 }); + } +}); diff --git a/taobao/pages/camtest/camtest.json b/taobao/pages/camtest/camtest.json new file mode 100644 index 0000000..5e6be01 --- /dev/null +++ b/taobao/pages/camtest/camtest.json @@ -0,0 +1,5 @@ +{ + "defaultTitle": "相机组件测试", + "titleBarColor": "#101823", + "backgroundColor": "#000000" +} diff --git a/taobao/pages/history/history.acss b/taobao/pages/history/history.acss index 3946c9c..9fdc181 100644 --- a/taobao/pages/history/history.acss +++ b/taobao/pages/history/history.acss @@ -1,5 +1,208 @@ +/* ═════ 历史报告 —— 逐条对照 H5: + style.css .card(L355-358) .toast(L588-599) .policy-header .history-clear + ui-20261009.css L8(#s-history 字体栈) L33-44(.history-content/.history-count/.identity-note/ + .history-item/.history-detail/.history-retry) L99-106(.font-credit/.account-status) + ui-20261009.css L47(.report-title 全局:font-size 23px / padding 0 / margin-bottom) + ui-20261009.css L92(.report-title font-weight 600) + 换算:calc(N * var(--u)) → 2N rpx;字号/行高/圆角固定 px 原样保留。 ═════ */ + page { background: #EFEFEF; } -.history { padding-bottom: 48rpx; } -.history-note { padding: 32rpx; color: #666; text-align: center; } -.history-item { line-height: 1.9; } -.history-open { color: #666; text-align: right; } + +/* H5 .screen#s-history */ +.history { + position: relative; + min-height: 100vh; + background: #efefef; +} + +/* H5 #s-history 的 background-image center/cover + background-attachment:fixed + → 等价实现:fixed 定位、铺满视口的 image */ +.history-bg { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 0; +} + +/* H5 .history-content(ui-20261009 L34) */ +.history-content { + position: relative; + z-index: 1; + padding: 56rpx 34rpx 72rpx; /* calc(28*u) calc(17*u) calc(36*u) */ + box-sizing: border-box; +} + +/* H5 .report-title(style.css L350-354 + ui-20261009 L47/L92) */ +.report-title { + font-size: 23px; + font-weight: 600; + color: #282828; + text-align: center; + letter-spacing: 1rpx; /* calc(.5 * var(--u)) */ + padding: 0; + margin-bottom: 46rpx; /* calc(23 * var(--u)) */ +} + +/* H5 .card(style.css L355-358 + ui-20261009 L48:圆角 calc(7*u)) */ +.history .card { + background: #fff; + border-radius: 14rpx; + margin-bottom: 24rpx; /* calc(12 * var(--u)) */ + box-sizing: border-box; +} + +/* H5 .account-status / .account-status-history(ui-20261009 L100/L106) */ +.account-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-radius: 8px; + font-size: 12px; + box-sizing: border-box; +} +.account-status-history { + width: 100%; + background: #fff; + margin-bottom: 14px; + text-align: left; +} +.account-label { display: flex; align-items: center; } +.account-dot { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 7px; + border-radius: 50%; + background: #b7bac0; + flex-shrink: 0; +} +.account-status-on .account-dot { background: #34ca7b; } +.account-text { white-space: nowrap; } +/* H5 .account-logout(ui-20261012 L4) */ +.account-logout { + font-size: 11px; + opacity: .8; + white-space: nowrap; +} + +/* H5 .history-count(ui-20261009 L35-38) + H5 里 .history-count 没设 font-size,行盒取浏览器默认 16px;app.acss 把 page 设成 + 14px,所以这里显式补回 16px,内联的「测试数量」才会落在同一基线上。 */ +.history .history-count { + text-align: center; + padding: 32rpx 24rpx 42rpx; /* calc(16*u) calc(12*u) calc(21*u) */ + font-size: 16px; +} +/* H5 .history-count > span 是内联元素(内含 display:block 的 ::after 分隔线) */ +.history-count-label { + display: inline; + color: #888; + font-size: 12px; +} +/* H5 .history-count > span::after */ +.history-count-label::after { + content: ''; + display: block; + width: 90rpx; /* calc(45 * var(--u)) */ + margin: 24rpx auto; /* calc(12 * var(--u)) auto */ + border-bottom: 1px solid #eee; +} +.history-count-value { font-size: 12px; } + +/* H5 .identity-note(ui-20261009 L39) */ +.identity-note { + color: #888; + font-size: 10px; + line-height: 1.6; + margin: 24rpx 8rpx 40rpx; /* calc(12*u) calc(4*u) calc(20*u) */ +} + +/* H5 .history-empty(style.css L637) */ +.history-empty { + text-align: center; + color: #868686; + font-size: 12.5px; + padding: 60px 0; +} +/* H5 的加载中/失败文案是直接写进 #history-list 的纯文本(继承 16px、左对齐、无内边距) */ +.history-list-note { + font-size: 16px; + color: #282828; +} + +/* H5 .history-item(ui-20261009 L40-42) */ +.history .history-item { + padding: 26rpx 38rpx 20rpx; /* calc(13*u) calc(19*u) calc(10*u) */ + overflow: hidden; + margin-top: 12rpx; /* calc(6 * var(--u)) */ +} +.history-item-line { + font-size: 12px; + padding-bottom: 14rpx; /* calc(7 * var(--u)) */ + border-bottom: 1px solid #eee; +} +.history-item-label { + color: #888; + margin-right: 16rpx; /* calc(8 * var(--u)) */ +} + +/* H5 .history-detail(ui-20261009 L43) */ +.history .history-detail { + display: block; + margin: 14rpx 0 0 auto; /* calc(7 * var(--u)) 0 0 auto */ + background: #F5F078; + border-radius: 10rpx; /* calc(5 * var(--u)) */ + width: 160rpx; /* calc(80 * var(--u)) */ + min-height: 46rpx; /* calc(23 * var(--u)) */ + font-size: 12px; + color: #282828; + line-height: 46rpx; + text-align: center; + padding: 0; + border: none; + box-sizing: border-box; +} +.history .history-detail::after { border: none; } + +/* H5 .history-retry(ui-20261009 L44) */ +.history .history-retry { + display: block; + width: fit-content; /* H5 是 block + width:auto 的按钮 → 收缩到文字宽再 margin:auto 居中 */ + margin: 20px auto; + text-align: center; + color: #707070; + text-decoration: underline; + font-size: 12px; + background: none; + border: none; + padding: 0; + line-height: normal; + box-sizing: border-box; +} +.history .history-retry::after { border: none; } + +/* ─── H5 .toast ─── */ +.toast { + position: fixed; + left: 50%; + bottom: 15%; + transform: translateX(-50%) translateY(8px); + max-width: 86%; + background: rgba(20, 20, 20, .85); + color: #fff; + font-size: 13px; + line-height: 1.5; + text-align: center; + padding: 9px 18px; + border-radius: 18px; + opacity: 0; + pointer-events: none; + transition: all .25s ease; + z-index: 99; + box-sizing: border-box; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } diff --git a/taobao/pages/history/history.axml b/taobao/pages/history/history.axml index 8ca3da6..3ddecb9 100644 --- a/taobao/pages/history/history.axml +++ b/taobao/pages/history/history.axml @@ -1,10 +1,48 @@ + - 最近5份检测报告 - 加载中… - {{error}},点击重试 - 暂无检测报告 - - {{item.date}}综合评分 {{item.total}} · 肌肤年龄 {{item.skinAge}} 岁 - 查看报告 › + + + + + + 您的报告 + + + + + + + 测试数量 + {{countText}} + + + + {{identityNote}} + + + + 正在读取报告… + {{error}},请刷新列表重试。 + + 暂无检测报告 + 完成一次检测后,报告会自动保存在这里。 + + + 测试时间 {{item.date}} + + + + + + + + + {{toastText}} diff --git a/taobao/pages/history/history.js b/taobao/pages/history/history.js index 1076758..70dcf33 100644 --- a/taobao/pages/history/history.js +++ b/taobao/pages/history/history.js @@ -1,34 +1,81 @@ +/* ═════ 历史报告:api.request('reports') 拉列表 + 点条目进报告页 + 对照 H5 index.html #s-history(L32-42) 与 app.js renderHistoryList()/openHistory()(L1944-2001) + 跳转前把 payload 放进 globalData,由 pages/report 渲染。 ═════ */ var api = require('../../utils/api'); + +function pad2(n) { return (n < 10 ? '0' : '') + n; } +// H5 用 toLocaleDateString('sv-SE'),即 YYYY-MM-DD 零填充 +function formatDate(value) { + var d = new Date(value); + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} + Page({ - data: { items: [], loading: false, error: '' }, + data: { + bgUrl: api.assetUrl('img/ui-20261009/bg-history-h5.jpg'), + items: [], loading: false, error: '', + signedIn: false, accountLabel: '尚未登录', + countText: '正在读取报告…', // H5 #history-count 初值 + identityNote: '', + toastText: '', toastOn: false + }, onShow: function () { if (!api.user()) { this.setData({ items: [] }); my.redirectTo({ url: '/pages/splash/splash' }); return; } + this.setData({ signedIn: true, accountLabel: '淘宝已登录' }); this.load(); }, onUnload: function () { this.closed = true; }, + + // H5 .toast + toast: function (msg) { + var that = this; + this.setData({ toastText: msg, toastOn: true }); + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(function () { + that.setData({ toastOn: false }); + }, Math.max(1800, String(msg).length * 130)); + }, + load: function () { var that = this; + this.closed = false; this.setData({ loading: true, error: '' }); api.request('reports').then(function (data) { if (that.closed) return; - that.setData({ items: data.items.map(function (item) { - var date = new Date(item.created_at); - return { id: item.id, total: item.total == null ? '--' : item.total, skinAge: item.skinAge == null ? '--' : item.skinAge, - date: date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() }; - }) }); - }).catch(function (err) { if (!that.closed) that.setData({ error: err.message }); }) - .then(function () { if (!that.closed) that.setData({ loading: false }); }); + that.setData({ + countText: '您最近已进行过' + data.total + '次面部问题检测', + identityNote: '保留最近 5 份报告,同一淘宝账号可跨手机查看。', + items: data.items.map(function (item) { + return { id: item.id, date: formatDate(item.created_at) }; + }) + }); + }).catch(function (err) { + if (!that.closed) that.setData({ error: err.message, countText: '暂时无法读取' }); + }).then(function () { if (!that.closed) that.setData({ loading: false }); }); }, + onOpen: function (e) { if (this.data.loading) return; + var id = e.currentTarget.dataset.id; + if (!id) return; var that = this; + this.closed = false; this.setData({ loading: true }); - api.request('reports', { id: e.currentTarget.dataset.id }).then(function (data) { + api.request('reports', { id: id }).then(function (data) { if (that.closed) return; getApp().globalData.photos = data.report.photos; getApp().globalData.payload = Object.assign({}, data.report.payload, { vm: data.report.vm, presentation: data.report.presentation }); my.navigateTo({ url: '/pages/report/report' }); - }).catch(function (err) { my.showToast({ content: err.message, type: 'none' }); }) + }).catch(function (err) { that.toast(err.message); }) .then(function () { if (!that.closed) that.setData({ loading: false }); }); + }, + + // H5 data-action="logout-wechat"(历史页右上角) + onLogout: function () { + var that = this; + api.logout().then(function () { + that.setData({ signedIn: false, accountLabel: '尚未登录', items: [], countText: '', identityNote: '' }); + my.redirectTo({ url: '/pages/splash/splash' }); + }).catch(function (err) { that.toast(err.message); }); } }); diff --git a/taobao/pages/history/history.json b/taobao/pages/history/history.json index 799d461..a9f271e 100644 --- a/taobao/pages/history/history.json +++ b/taobao/pages/history/history.json @@ -1 +1 @@ -{ "defaultTitle": "历史报告", "backgroundColor": "#EFEFEF" } +{ "defaultTitle": "历史报告", "titleBarColor": "#FFFFFF", "backgroundColor": "#EFEFEF" } diff --git a/taobao/pages/mvp/mvp.js b/taobao/pages/mvp/mvp.js index 51486ac..d26ca63 100644 --- a/taobao/pages/mvp/mvp.js +++ b/taobao/pages/mvp/mvp.js @@ -19,6 +19,7 @@ Page({ configText: JSON.stringify({ environment: config.environment, transport: config.transport, + cloudType: config.cloudType || 'empty', baseUrl: config.baseUrl, cloudAppId: config.cloudAppId || '(empty)', cloudEnv: config.cloudEnv @@ -33,10 +34,11 @@ Page({ transport.request(path, body || {}).then(function (data) { that.setData({ loading: false, resultText: JSON.stringify(data, null, 2) }); }).catch(function (err) { - that.setData({ - loading: false, - resultText: 'ERROR: ' + (err && err.message ? err.message : String(err)) - }); + // 真机上没法看 console,把云网关的 code/msg 一起显示出来 + var detail = err && err.message ? err.message : String(err); + if (err && err.code) detail = err.code + ' / ' + detail; + if (err && err.msg && err.msg !== err.message) detail += ' | ' + err.msg; + that.setData({ loading: false, resultText: 'ERROR: ' + detail }); }); }, diff --git a/taobao/pages/profile/profile.acss b/taobao/pages/profile/profile.acss index 0aa01b8..b50a143 100644 --- a/taobao/pages/profile/profile.acss +++ b/taobao/pages/profile/profile.acss @@ -1,141 +1,494 @@ -/* ═════ 档案填写页样式 ═════ */ +/* ═════ 03 档案填写 —— 逐条对照 H5: + style.css #s-profile(L105-205) .profile-bg/.profile-body/.page-title/.field/.field-label/ + .form-row.two/.pick/.pick-value/.pick .chev/.skin-card/.skin-axis/.axis-item/.chip/ + .skin-stage/.skin-arrow/.skin-carousel/.skin-track/.skin-item/.skin-name/.skin-desc/ + .skin-dots/.btn-submit/.ms-mobile-select(滚轮外观)/.overlay/.popup/.privacy 类 + ui-20261009.css L31-32,L89-92 + ui-20261012.css L34-69(.popup 与 #p-photoguide 定位), L169-198(#s-profile 的 --profile-space 收缩规则) + 换算:calc(N * var(--u)) → 2N rpx;字号/行高/圆角固定 px 原样保留。 + 注:H5 的 --profile-space = min(var(--u), max(.3px, calc((100vh - 420px)/306))), + 这里写成同构的 min(2rpx, max(.6rpx, ...)),并用 var(--profile-space, 2rpx) 兜底。 ═════ */ + +page { background: #0e0f18; } + +/* H5 #s-profile(style.css L105 + ui-20261012 L172-175) */ .profile { position: relative; - min-height: 100vh; + width: 100%; + height: 100vh; + overflow: hidden; + overscroll-behavior: none; + background: #0e0f18; + --profile-space: min(2rpx, max(0.6rpx, calc((100vh - 420px) / 306))); } + +/* H5 .profile-bg(ui-20261009 L31:bg-profile-h5,cover) */ .profile-bg { - position: fixed; - left: 0; top: 0; + position: absolute; + inset: 0; width: 100%; height: 100%; - /* 上半部分露出背景图,表单区由白色卡片承载 */ } + +/* H5 .profile-body(style.css L113-117 + ui-20261012 L179-184) */ .profile-body { position: relative; - padding: 48rpx 32rpx 64rpx; + z-index: 1; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; box-sizing: border-box; -} -.page-title { - color: #FFFFFF; - font-size: 44rpx; - font-weight: 600; - text-align: center; - margin-bottom: 48rpx; + padding: 68rpx 128rpx 80rpx; /* calc(34*u) calc(64*u) calc(40*u) */ + padding-top: max(env(safe-area-inset-top), calc(34 * var(--profile-space, 2rpx))); + padding-bottom: max(env(safe-area-inset-bottom), calc(40 * var(--profile-space, 2rpx))); } -.form-row { - display: flex; - justify-content: space-between; -} -.field { margin-bottom: 32rpx; } -.field-half { width: 48%; } -.field-label { - color: #FFFFFF; - font-size: 28rpx; - margin-bottom: 16rpx; +/* H5 .page-title(style.css L118-122 + ui-20261009 L92 + ui-20261012 L185) + 注:#s-profile 根节点自身不接收 padding-top(safe-area),与 H5 一致 */ +.profile .page-title { + color: #fff; + font-size: 21.5px; + font-weight: 600; + letter-spacing: 2rpx; /* calc(1 * var(--u)) */ text-align: center; + margin-bottom: calc(22 * var(--profile-space, 2rpx)); + flex-shrink: 0; } -.pick { + +/* H5 .field / .field-label(ui-20261012 L186-187) */ +.profile .field { + margin-bottom: calc(19 * var(--profile-space, 2rpx)); + flex-shrink: 0; +} +.profile .field-label { + display: block; + text-align: center; + color: #fff; + font-size: 14.5px; + font-weight: 400; + margin-bottom: calc(9 * var(--profile-space, 2rpx)); + letter-spacing: 1rpx; /* calc(.5 * var(--u)) */ +} + +/* H5 .form-row.two */ +.profile .form-row.two { + display: flex; + gap: 46rpx; /* calc(23 * var(--u)) */ + flex-shrink: 0; +} +.profile .form-row.two .field { flex: 1; min-width: 0; } + +/* H5 .pick(style.css L133-142) —— 三个选择控件同一套外观 */ +.profile .pick { display: flex; align-items: center; justify-content: space-between; - height: 88rpx; - background: #FFFFFF; - border-radius: 16rpx; - padding: 0 24rpx; + gap: 6px; + width: 100%; + height: 74rpx; /* calc(37 * var(--u)) */ + padding: 0 26rpx 0 24rpx; /* 0 calc(13*u) 0 calc(12*u) */ box-sizing: border-box; -} -.pick-value { + background: #fff; + border-radius: 10rpx; /* calc(5 * var(--u)) */ + font-size: 13.5px; color: #282828; - font-size: 28rpx; + text-align: left; } -.chev { - color: #868686; - font-size: 28rpx; +.profile .pick:active { background: #F7F7F7; } +.profile .pick-value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +.profile .pick .chev { + flex: none; + width: 16rpx; /* calc(8 * var(--u)) */ + height: 16rpx; + margin-top: -8rpx; /* calc(-4 * var(--u)) */ + border-width: 0 1.5px 1.5px 0; +} +.profile .pick-host { display: block; width: 100%; } -/* ─── 肤质卡 ─── */ -.skin-card { - background: #FFFFFF; - border-radius: 24rpx; - padding: 32rpx 24rpx; +/* ─── H5 .skin-card(style.css L144-148 + ui-20261012 L189) ─── */ +.profile .skin-card { + background: #fff; + border-radius: 10rpx; /* calc(5 * var(--u)) */ + padding-top: calc(21 * var(--profile-space, 2rpx)); + padding-bottom: calc(24 * var(--profile-space, 2rpx)); + padding-left: 0; + padding-right: 0; box-sizing: border-box; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; } -.skin-axis { +/* H5 .skin-axis / .axis-item / .chip */ +.profile .skin-axis { display: flex; justify-content: center; - margin-bottom: 24rpx; + gap: 24rpx; /* calc(12 * var(--u)) */ + margin-bottom: calc(18 * var(--profile-space, 2rpx)); + flex-shrink: 0; } -.axis-item { - display: flex; +.profile .axis-item { + display: inline-flex; align-items: center; - font-size: 24rpx; - color: #868686; - margin: 0 16rpx; + gap: 10rpx; /* calc(5 * var(--u)) */ + font-size: 9px; + color: #282828; } -.chip { - display: inline-block; - width: 20rpx; - height: 20rpx; +.profile .chip { + width: 16rpx; /* calc(8 * var(--u)) */ + height: 16rpx; border-radius: 50%; - margin-right: 8rpx; + display: inline-block; + flex-shrink: 0; } .chip-dry { background: #EAE6E1; } .chip-oily { background: #F5F078; } .chip-balance { background: #DBF6D6; } -.skin-stage { +/* H5 .skin-stage / .skin-arrow(ui-20261012 L191) */ +.profile .skin-stage { + position: relative; + padding: 0 56rpx; /* 0 calc(28 * var(--u)) */ + box-sizing: border-box; + flex: 1 1 auto; + min-height: 0; + display: flex; +} +.profile .skin-arrow { + position: absolute; + top: 84rpx; /* calc(42 * var(--u)) */ + z-index: 1; + width: 52rpx; /* calc(26 * var(--u)) */ + height: 52rpx; display: flex; align-items: center; + justify-content: center; } -.skin-arrow { - width: 64rpx; - font-size: 56rpx; - color: #868686; - text-align: center; - flex-shrink: 0; +.profile .skin-arrow.prev { left: 10rpx; } /* calc(5 * var(--u)) */ +.profile .skin-arrow.next { right: 10rpx; } +.profile .skin-arrow .chev { + width: 18rpx; /* calc(9 * var(--u)) */ + height: 18rpx; + border-width: 0 1.5px 1.5px 0; } -.skin-swiper { +.profile .skin-arrow.disabled { opacity: .2; } + +/* H5 .skin-carousel / .skin-track(ui-20261012 L192-193) */ +.profile .skin-carousel { + overflow: hidden; flex: 1; - height: 460rpx; + min-width: 0; + min-height: 0; + display: flex; + touch-action: none; + user-select: none; + -webkit-user-select: none; } -.skin-item { +.profile .skin-track { + display: flex; + flex: 0 0 100%; + min-width: 0; + min-height: 0; + transition: transform .32s cubic-bezier(.22, .61, .36, 1); +} +/* H5 .skin-item / .skin-item img / .skin-name / .skin-desc */ +.profile .skin-item { + flex: 0 0 100%; + min-width: 0; display: flex; flex-direction: column; align-items: center; - padding: 0 16rpx; - box-sizing: border-box; } -.skin-img { - width: 280rpx; - height: 280rpx; +.profile .skin-img { + width: 218rpx; /* calc(109 * var(--u)) */ + height: 218rpx; + flex-shrink: 1; + min-height: 0; } -.skin-name { - font-size: 32rpx; - font-weight: 600; - margin-top: 16rpx; +.profile .skin-name { + font-size: 12.5px; + font-weight: 700; + color: #000; + margin-top: calc(16 * var(--profile-space, 2rpx)); + flex-shrink: 0; } -.skin-desc { - font-size: 24rpx; - color: #868686; +.profile .skin-desc { + font-size: 11.5px; /* ui-20261009 L32 */ + color: #282828; + line-height: 1.55; text-align: center; - margin-top: 8rpx; + margin-top: 10rpx; /* calc(5 * var(--u)) */ + width: 300rpx; /* calc(150 * var(--u)) */ + max-width: 300rpx; + min-height: 35.65px; /* calc(2 * 1.55 * 11.5px) */ + flex-shrink: 0; } -.skin-dots { +/* H5 第一条描述含
:用两个块级行还原硬换行 */ +.profile .skin-desc-line { display: block; } + +/* H5 .skin-dots / .skin-dots i */ +.profile .skin-dots { display: flex; justify-content: center; - margin-top: 16rpx; + gap: 10rpx; /* calc(5 * var(--u)) */ + margin-top: calc(22 * var(--profile-space, 2rpx)); + flex-shrink: 0; } -.dot { - width: 12rpx; - height: 12rpx; +.profile .skin-dots .dot { + width: 16rpx; /* calc(8 * var(--u)) */ + height: 16rpx; border-radius: 50%; - background: #E5E5E5; - margin: 0 6rpx; + background: #D6D6D6; + transition: background .25s; } -.dot.on { background: #282828; } +.profile .skin-dots .dot.on { background: #000; } -.btn-submit { margin-top: 24rpx; } -.picker-sheet { background: white; width: 100%; align-self: flex-end; border-radius: 24rpx 24rpx 0 0; } -.picker-actions { display: flex; justify-content: space-between; padding: 32rpx; } -.native-picker { width: 100%; height: 440rpx; text-align: center; line-height: 80rpx; } +/* H5 .btn-submit */ +.profile .btn-submit { + margin: calc(25 * var(--profile-space, 2rpx)) auto 0; + flex-shrink: 0; +} +.btn-press { transform: scale(.96); } + +/* ═══ 滚轮弹层:H5 vendor/mobile-select 外观(style.css L191-205) ═══ */ +.profile .overlay-sheet { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + flex-direction: column; + justify-content: flex-end; + background: rgba(0, 0, 0, .55); +} +.picker-sheet { + width: 100%; + background: #fff; + border-radius: 12px 12px 0 0; + overflow: hidden; + padding-bottom: env(safe-area-inset-bottom); + box-sizing: border-box; +} +.picker-btn-bar { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + height: 50px; + line-height: 50px; + font-size: 15px; + border-bottom: 1px solid #F0F0F0; + box-sizing: border-box; +} +.picker-cancel, +.picker-ensure { + width: 64px; + height: 50px; + line-height: 50px; + text-align: center; + font-size: 15px; +} +.picker-cancel { color: #757575; } +.picker-ensure { color: #282828; font-weight: 600; } +.picker-title { + position: absolute; + left: 50%; + top: 0; + transform: translateX(-50%); + font-size: 15px; + font-weight: 600; + color: #282828; +} +.native-picker { + width: 100%; + height: 220px; + font-size: 16px; + color: #282828; +} +.picker-cell { + font-size: 16px; + color: #282828; + text-align: center; + line-height: 44px; +} + +/* ═══ 拍照引导弹窗 H5 #p-photoguide(L311-327) ═══ */ +.profile .overlay-guide { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: #101823; +} +.profile .guide-bg { + position: absolute; + inset: 0; +} +.profile .guide-bg-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.profile .guide-dim { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .5); +} +/* H5 .splash-slogan.splash-slogan-updated(ui-20261012 L9-20) */ +.profile .splash-slogan { + position: absolute; + left: calc(50% - 1.041667px); + top: max(6.838833%, calc(env(safe-area-inset-top) + 48px)); + width: 249.305556px; + height: 75.347222px; + transform: translateX(-50%); + text-align: center; + color: #fff; + font-size: 23px; + font-weight: 400; + line-height: 1.16; + letter-spacing: 0; + text-shadow: none; +} +.profile .splash-slogan-copy-clip { + position: absolute; + left: 0; + right: 0; + top: 51.236111px; + height: 24.111111px; + overflow: hidden; +} +.profile .splash-slogan-copy { + position: absolute; + left: 0; + top: -51.236111px; + width: 100%; + height: 75.347222px; +} +.profile .splash-slogan-brand, +.profile .splash-slogan-english { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-family: Arial, sans-serif; + font-size: 23px; + font-weight: 400; + line-height: 1; + white-space: nowrap; + color: #fff; +} +.profile .splash-slogan-brand { top: -4px; font-size: 28px; } +.profile .splash-slogan-english { top: 26.736111px; } + +/* H5 #p-photoguide .popup(ui-20261012 L34-38) */ +.profile .popup { + position: relative; + z-index: 2; + width: 307.986111px; + height: 313.194444px; + min-height: 313.194444px; + top: -42.881944px; + padding: 0; + background: #fff; + border-radius: 8.680556px; + display: flex; + flex-direction: column; + text-align: left; + box-sizing: border-box; + text-rendering: geometricPrecision; +} +.profile .popup-close { + position: absolute; + top: 10.055556px; + right: 10.055556px; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + box-sizing: border-box; +} +.profile .popup-close::after { border: none; } +.popup-close-img { + width: 17.708333px; + height: 17.361111px; +} +/* H5 #p-photoguide .popup-title(L44) */ +.profile .popup-title { + position: absolute; + left: 37.5px; + top: 61.388889px; + margin: 0; + font-size: 23.148148px; + line-height: 28.935185px; + font-weight: 700; + letter-spacing: 0; + color: #282828; + white-space: nowrap; + text-align: left; +} +/* H5 .guide-description(L45-52) */ +.profile .guide-description { + position: absolute; + left: 37.847222px; + top: 135.069444px; + width: 237px; + margin: 0; + font-size: 11.574074px; + line-height: 17.361111px; + font-weight: 400; + letter-spacing: 0; +} +.profile .guide-line { white-space: nowrap; } +/* H5 #p-photoguide .popup-btn(L57-64) */ +.profile .popup-btn { + position: absolute; + left: 63.194444px; + top: 219.791667px; + margin: 0; + width: 181.25px; + height: 38.888889px; + line-height: 38.888889px; + font-size: 14.467593px; + font-weight: 700; + letter-spacing: 0; + border-radius: 6.944444px; +} +@media (max-height: 440px) { + .profile .popup { top: 0; } +} + +/* ─── H5 .toast ─── */ +.toast { + position: fixed; + left: 50%; + bottom: 15%; + transform: translateX(-50%) translateY(8px); + max-width: 86%; + background: rgba(20, 20, 20, .85); + color: #fff; + font-size: 13px; + line-height: 1.5; + text-align: center; + padding: 9px 18px; + border-radius: 18px; + opacity: 0; + pointer-events: none; + transition: all .25s ease; + z-index: 99; + box-sizing: border-box; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } diff --git a/taobao/pages/profile/profile.axml b/taobao/pages/profile/profile.axml index c405371..c91f7b3 100644 --- a/taobao/pages/profile/profile.axml +++ b/taobao/pages/profile/profile.axml @@ -1,91 +1,130 @@ - + + + + 您的肌肤状态 - - - + + + 您的出生年月 - - - {{birthText}} - ⌄ - + + {{birthText}} + - + 您的性别 - + {{genderText}} - ⌄ + - + 您的所在地区 - - - {{regionText}} - ⌄ - + + {{regionText}} + - - + + 您的日常肤质 - 偏干 - 偏油 - 水油平衡 + 偏干 + 偏油 + 水油平衡 - ‹ - - - - - {{item.name}} - {{item.desc}} + + + + + + + + {{item.name}} + + + {{item.desc}} + {{item.desc2}} + - - - › + + + + + - + - + - - - - - - 请拍摄或上传 - 正面及左右侧面照片 - - - - - + + - 取消确定 - - {{item}} - {{item}} + + 取消 + {{pickerTitle}} + 确定 + + + + {{item}} + + + {{item}} + + + + + + + + + + + + 1'me, + FROM THE INSIDE OUT + + + + + + + + 请拍摄或上传 + 正面及左右侧面照片 + + + 为使测试结果更准确,请关闭美颜/滤镜、并确 + 保光线充足均匀。 + + + + + + + {{toastText}} diff --git a/taobao/pages/profile/profile.js b/taobao/pages/profile/profile.js index b74ad58..40f3032 100644 --- a/taobao/pages/profile/profile.js +++ b/taobao/pages/profile/profile.js @@ -1,11 +1,13 @@ -/* ═════ 档案填写页:出生年月/性别/地区(原生 picker)+ 日常肤质选择(对齐 H5 #s-profile) ═════ - * 档案字段与 H5 一致:{ real_age, gender, region, daily_skin_type } */ +/* ═════ 档案填写页:出生年月/性别/地区 + 日常肤质轮播 → 拍照 + 对照 H5 index.html #s-profile(L61-108)、#p-photoguide(L311-327) + 档案字段与 H5 一致:{ real_age, gender, region, daily_skin_type } + 选择器弹层保持现有实现(自绘 picker-view 滚轮 + 原生 selector),只调整触发控件外观。 ═════ */ var api = require('../../utils/api.js'); var REGION_DATA = require('../../utils/region-data.js'); -// 肤质选项(与 H5 SKINS 一致,图片远程加载) +// 肤质选项(与 H5 SKINS 一致,图片远程加载;第一条描述在 H5 里含
,拆成两行) var SKINS = [ - { img: 'img/ui-20261009/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' }, + { img: 'img/ui-20261009/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、', desc2: '缺乏光泽' }, { img: 'img/ui-20261009/skin-1-mixeddry.png', name: '混合偏干肌肤', desc: '两颊偏干,T区油脂较少或偶尔出油' }, { img: 'img/ui-20261009/skin-2-neutral.png', name: '中性肌肤', desc: '水油平衡,肤感舒适,无明显干燥出油' }, { img: 'img/ui-20261009/skin-3-oily.png', name: '油性肌肤', desc: '全脸易出油,容易泛油光、毛孔明显' }, @@ -21,7 +23,10 @@ function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未 Page({ data: { - bgUrl: api.assetUrl('img/bg-splash.jpg'), + bgUrl: api.assetUrl('img/ui-20261009/bg-profile-h5.jpg'), + guideBgUrl: api.assetUrl('img/ui-20261009/bg-splash-h5.jpg'), + sloganUrl: api.assetUrl('img/ui-20261012/splash-slogan.png'), + closeIconUrl: api.assetUrl('img/ui-20261009/icon-close.png'), // 出生年月(两列滚轮:年+月,默认 30 岁 1 月) birthRange: [YEARS, MONTHS], @@ -44,11 +49,14 @@ Page({ // 肤质轮播 skins: SKINS.map(function (s) { - return { img: api.assetUrl(s.img), name: s.name, desc: s.desc }; + return { img: api.assetUrl(s.img), name: s.name, desc: s.desc, desc2: s.desc2 || '' }; }), skinIndex: 0, + skinPercent: 0, + skinLastIndex: SKINS.length - 1, - showGuide: false, picker: '', pickerValue: [0, 0] + showGuide: false, picker: '', pickerTitle: '', pickerValue: [0, 0], + toastText: '', toastOn: false }, onLoad: function () { if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; } @@ -60,6 +68,8 @@ Page({ this.setData(saved); } } catch (_) {} + // H5 goSkin(0) 的等价初始化:轨道位移与当前下标同步 + this.setData({ skinIndex: this.data.skinIndex || 0, skinPercent: (this.data.skinIndex || 0) * 100 }); }, onShow: function () { if (!api.user() || this.ownerId !== api.user().id) { @@ -67,11 +77,25 @@ Page({ my.redirectTo({ url: '/pages/splash/splash' }); } }, - onOpenBirth: function () { this.setData({ picker: 'birth', pickerValue: this.data.birthIndex.slice() }); }, + + // H5 .toast + toast: function (msg) { + var that = this; + this.setData({ toastText: msg, toastOn: true }); + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(function () { + that.setData({ toastOn: false }); + }, Math.max(1800, String(msg).length * 130)); + }, + + onOpenBirth: function () { + // H5 的 mobile-select title 为「出生年月」/「所在地区」 + this.setData({ picker: 'birth', pickerTitle: '出生年月', pickerValue: this.data.birthIndex.slice() }); + }, onOpenRegion: function () { var index = this.data.regionIndex.slice(); // A canceled draft may have changed the cities while the saved province stayed put. - this.setData({ picker: 'region', pickerValue: index, + this.setData({ picker: 'region', pickerTitle: '所在地区', pickerValue: index, regionRange: [REGION_DATA.map(function (p) { return p[0]; }), REGION_DATA[index[0]][1]] }); }, onPickerChange: function (e) { @@ -91,13 +115,13 @@ Page({ /* ─── 出生年月 ─── */ onBirthChange: function (e) { var idx = e.detail.value; - var y = parseInt(YEARS[idx[0]], 10); - var m = idx[1] + 1; + var yv = parseInt(YEARS[idx[0]], 10); + var mv = idx[1] + 1; this.setData({ birthIndex: idx, - birthText: y + '年' + m + '月', - birth: y + '-' + (m < 10 ? '0' : '') + m, - age: ageFromBirth(y, m) + birthText: yv + '年' + mv + '月', + birth: yv + '-' + (mv < 10 ? '0' : '') + mv, + age: ageFromBirth(yv, mv) }); }, @@ -121,30 +145,39 @@ Page({ this.setData({ regionIndex: idx, regionText: text }); }, - /* ─── 肤质轮播 ─── */ - onSkinChange: function (e) { - this.setData({ skinIndex: e.detail.current }); + /* ─── 肤质轮播(H5 goSkin 的等价实现:轨道 translateX) ─── */ + applySkin: function (index) { + var i = Math.max(0, Math.min(SKINS.length - 1, index)); + this.setData({ skinIndex: i, skinPercent: i * 100 }); }, - onSkinPrev: function () { - if (this.data.skinIndex > 0) this.setData({ skinIndex: this.data.skinIndex - 1 }); + onSkinPrev: function () { this.applySkin(this.data.skinIndex - 1); }, + onSkinNext: function () { this.applySkin(this.data.skinIndex + 1); }, + onSkinTouchStart: function (e) { + var t = (e.changedTouches || e.touches || [])[0]; + this.touchX = t ? (t.clientX != null ? t.clientX : t.pageX) : 0; }, - onSkinNext: function () { - if (this.data.skinIndex < SKINS.length - 1) this.setData({ skinIndex: this.data.skinIndex + 1 }); + onSkinTouchEnd: function (e) { + var t = (e.changedTouches || e.touches || [])[0]; + if (!t) return; + var x = t.clientX != null ? t.clientX : t.pageX; + var dx = x - (this.touchX || 0); + if (Math.abs(dx) < 40) return; + this.applySkin(this.data.skinIndex + (dx < 0 ? 1 : -1)); }, - /* ─── 提交 → 拍照引导弹窗 ─── */ + /* ─── 提交 → 拍照引导弹窗(H5 data-action="submit-profile") ─── */ onSubmit: function () { var d = this.data; if (!d.birth || !(d.age >= 1 && d.age <= 99)) { - my.showToast({ content: '请选择出生年月', type: 'none' }); + this.toast('请选择出生年月'); return; } if (!d.genderText) { - my.showToast({ content: '请选择性别', type: 'none' }); + this.toast('请选择性别'); return; } if (!d.regionText) { - my.showToast({ content: '请选择所在地区', type: 'none' }); + this.toast('请选择所在地区'); return; } this.setData({ showGuide: true }); @@ -154,7 +187,7 @@ Page({ this.setData({ showGuide: false }); }, - // 「开始」:档案入全局态,进入拍照页 + // H5 data-action="start-camera":档案入全局态,进入拍照页 onStartCamera: function () { if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; } var d = this.data; diff --git a/taobao/pages/report/report.acss b/taobao/pages/report/report.acss index 2e06ca1..fc464c7 100644 --- a/taobao/pages/report/report.acss +++ b/taobao/pages/report/report.acss @@ -1,28 +1,106 @@ -/* ═════ 报告页样式(灰底白卡,对齐设计稿 08) ═════ */ -page { background: #EFEFEF; } -.report { padding-bottom: 64rpx; } +/* ═════ 08 报告页 —— 1:1 移植 H5 #s-report ═════ + 对照来源(逐条核对,后者覆盖前者): + index.html #s-report L174-291 + css/style.css L347-503 (基础) + css/ui-20261009.css L45-87 (第二轮 PSD) + css/ui-20261012.css L71-172 (第三轮 PSD,最终生效值) + 换算:H5 的 calc(N * var(--u))(375 设计宽基准)→ 2N rpx; + H5 里固定的 px 字号、1px 描边、百分比、transform 原样保留; + 注释用「基础 → ui-20261009 → ui-20261012」标出覆盖关系。 + 限制:只能用 class/id 选择器;acss 无 @font-face,字体沿用 app.acss 的系统栈, + 故 H5 的 "MiSans Demibold"(700) 退化为系统字体 + font-weight:700。 */ + +/* ─── 页面容器(H5 #s-report / .report-body) ─── + #s-report 还有一条 background:#efefef url(bg-report-h5.webp) center top/100% auto。 + 该 webp 实测为 1080×7512 的纯 #EFEFEF 单色图(全图只有一种颜色 239,239,239), + 与 app.acss 里 page 的底色等价,所以这里不再引这张空图 + ( 会额外解码约 32MB,并把页面滚动高度撑到 2608px)。 + .report-body { padding: calc(28u) calc(17u) calc(42u) } */ +.report { position: relative; } +.report-body { position: relative; z-index: 1; padding: 56rpx 34rpx 84rpx; } + +/* .report-title { font-size:23px; font-weight:600(覆盖 700); letter-spacing:calc(.5u); + text-align:center; margin-bottom:calc(23u) };行高固定 30px = H5 实测行盒 */ .report-title { - font-size: 40rpx; + font-size: 23px; font-weight: 600; + color: #282828; text-align: center; - padding: 32rpx 0 8rpx; + letter-spacing: 1rpx; + line-height: 30px; + margin-bottom: 46rpx; } -/* ─── 总分卡 ─── */ +/* .card { background:#fff; border-radius:calc(7u)(覆盖 8u); margin-bottom:calc(12u) } */ +.card { margin-bottom: 24rpx; } + +/* ─── 总分卡:H5 是 display:grid 两列 1fr + row-gap calc(15u),padding calc(16u) calc(14u) + acss 用 flex-wrap 等价实现:两个 50% 列 + 整行 .card-dims ─── */ .card-summary { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: space-around; + padding: 32rpx 28rpx; + box-sizing: border-box; } -.ring-box { + +/* .sum-score { flex:none; border-right:1px solid #EFEFEF; flex-direction:column; + align-items:center; justify-content:center } */ +.sum-score { + flex: 0 0 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; position: relative; - width: 240rpx; - height: 240rpx; + border-right: 1px solid #EFEFEF; + box-sizing: border-box; } -.ring-canvas { - width: 240rpx; - height: 240rpx; + +/* .score-dial { position:relative; width:calc(108u); height:calc(108u) } */ +.score-dial { position: relative; width: 216rpx; height: 216rpx; } + +/* H5 用 画环形:r=52、stroke-width=9 + → 在 108px 画盘上外径 50.85、内径 42.75。 + acss 无 svg,改成等价的三层几何:轨道圆环 + conic-gradient 进度弧 + 圆头补点。 */ +.ring-track { + position: absolute; + left: 50%; top: 50%; + width: 203.4rpx; height: 203.4rpx; + margin: -101.7rpx 0 0 -101.7rpx; + border-radius: 50%; + background: #EFEFEF; } +/* 进度弧:report.js 按总分写入 conic-gradient(#F5F078 0 deg, 透明 deg)。 + conic-gradient 的 0deg 在正上方、顺时针递增,与 H5 SVG rotate(-90deg) 的进度方向一致。 + 圆的直径必须与 .ring-track 一致(203.4rpx):若铺满 216rpx 画盘, + 环会变成 11.25rpx 厚、外径 54(H5 是 8.1rpx 厚、外径 50.85)。 */ +.ring-arc { + position: absolute; + left: 50%; top: 50%; + width: 203.4rpx; height: 203.4rpx; + margin: -101.7rpx 0 0 -101.7rpx; + border-radius: 50%; +} +/* 圆形内孔(卡片底色为白):半径 42.75 → 直径 171rpx */ +.ring-hole { + position: absolute; + left: 50%; top: 50%; + width: 171rpx; height: 171rpx; + margin: -85.5rpx 0 0 -85.5rpx; + border-radius: 50%; + background: #FFFFFF; +} +/* stroke-linecap:round 的等价物:半径 8.1rpx 的圆点落在弧的两端 */ +.ring-cap { + position: absolute; + width: 16.2rpx; height: 16.2rpx; + margin: -8.1rpx 0 0 -8.1rpx; + border-radius: 50%; + background: #F5F078; +} +/* .ring-center { position:absolute; inset:0; flex-direction:column; align-items:center; + justify-content:center; gap:0 } */ .ring-center { position: absolute; left: 0; top: 0; right: 0; bottom: 0; @@ -31,215 +109,525 @@ page { background: #EFEFEF; } align-items: center; justify-content: center; } + +/* .stat-value { color:var(--ink); line-height:1; white-space:nowrap; display:flex; align-items:baseline } */ +.stat-value { + display: flex; + align-items: baseline; + color: #282828; + line-height: 1; + white-space: nowrap; +} +/* .ring-center .stat-value b { font-size:40px } + #stat-score b { letter-spacing:0; display:block } */ +.stat-b { font-size: 40px; font-weight: 700; line-height: 1; letter-spacing: 0; } +/* #stat-score i { margin-left:2px } + .ring-center .stat-value i { font-size:14px } */ +.stat-i { font-size: 14px; font-weight: 700; line-height: 1; margin-left: 2px; } +/* .ring-center .stat-label / .sum-age > .stat-label { margin:0; font-size:12px; line-height:1.5; color:var(--ink) } */ +.stat-label { font-size: 12px; line-height: 1.5; color: #282828; margin: 0; } + +/* .sum-age { flex:1; align-self:stretch; flex-direction:column; align-items:center; + justify-content:center; gap:0; padding-left:calc(16u)(覆盖 26u) } */ .sum-age { + flex: 0 0 50%; + align-self: stretch; + min-width: 0; display: flex; flex-direction: column; align-items: center; + justify-content: center; + padding-left: 32rpx; + box-sizing: border-box; } -.stat-label { - font-size: 24rpx; - color: #868686; -} -.stat-value { display: flex; align-items: baseline; } -.stat-num { - font-size: 64rpx; - font-weight: 700; - color: #282828; -} -.stat-unit { - font-size: 24rpx; - color: #868686; - margin-left: 4rpx; -} -.age-pill { - margin-top: 12rpx; - font-size: 22rpx; - color: #282828; - background: #F5F078; - border-radius: 20rpx; - padding: 4rpx 20rpx; -} - -/* ─── 三维度 ─── */ -.dims-row { - display: flex; - justify-content: space-between; -} -.dim { - width: 31%; +/* .age-dial { width:calc(108u); height:auto; display:flex; align-items:center; justify-content:center } */ +.age-dial { position: relative; -} -.dim-name { - font-size: 26rpx; - color: #4A4A4A; -} -.dim-score { margin: 8rpx 0; } -.dim-int { - font-size: 48rpx; - font-weight: 700; -} -.dim-dec { - font-size: 28rpx; - color: #868686; -} -.dim.worst .dim-int, .dim.worst .dim-dec { color: #D30C0C; } -.dim-bar { - height: 8rpx; - background: #EFEFEF; - border-radius: 4rpx; - overflow: hidden; -} -.dim-bar-fill { - height: 100%; - background: #E5E5E5; - border-radius: 4rpx; -} -.dim.best .dim-bar-fill { background: #DBF6D6; } -.dim.worst .dim-bar-fill { background: #F5C6C6; } -.dim-flag { - position: absolute; - top: 0; - right: 0; - font-size: 20rpx; - color: #D30C0C; -} -.dims-note { margin-top: 24rpx; } -.dims-note-l1 { - font-size: 26rpx; - color: #868686; -} -.dims-focus { - color: #282828; - font-weight: 600; - text-decoration: underline; -} -.dims-note-l2 { - font-size: 24rpx; - color: #868686; - margin-top: 4rpx; -} - -/* ─── 6 分类 Tab ─── */ -.detail-tabs { - display: flex; - border-bottom: 2rpx solid #E5E5E5; - margin-bottom: 24rpx; -} -.dtab { - flex: 1; - text-align: center; - font-size: 28rpx; - color: #868686; - padding: 16rpx 0; -} -.dtab.active { - color: #282828; - font-weight: 600; - border-bottom: 4rpx solid #F5F078; -} -.ditem { margin-bottom: 32rpx; } -.ditem-title { - font-size: 28rpx; - font-weight: 600; - margin-bottom: 16rpx; -} -.ditem-img { - width: 100%; - border-radius: 16rpx; - margin-bottom: 16rpx; - background: #EFEFEF; -} -.ditem-para { - font-size: 26rpx; - color: #4A4A4A; - line-height: 1.7; -} - -/* ─── 降级标注 + 免责 ─── */ -.vm-notes { - font-size: 22rpx; - color: #868686; - padding: 16rpx 32rpx 0; - line-height: 1.6; -} -.report-disclaimer { - font-size: 22rpx; - color: #868686; - text-align: center; - line-height: 1.8; - padding: 24rpx 32rpx; -} - -/* ─── 护理建议 / 精华 ─── */ -.care-item { - font-size: 26rpx; - color: #4A4A4A; - line-height: 1.7; - margin-bottom: 16rpx; -} -.care-summary { - font-size: 24rpx; - color: #868686; - margin-top: 16rpx; -} -.essence-name { - font-size: 32rpx; - font-weight: 600; -} -.essence-triggers { - font-size: 24rpx; - color: #868686; - margin-top: 12rpx; -} - -/* ─── 产品推荐 ─── */ -.prod-title { - text-align: center; - font-size: 36rpx; - font-weight: 700; - padding: 40rpx 0 8rpx; -} -.prod-title text { display: block; line-height: 1.5; } -.prod-card { + width: 216rpx; + height: auto; display: flex; align-items: center; - justify-content: space-between; + justify-content: center; } -.prod-info { flex: 1; padding-right: 24rpx; } -.prod-badge { height: 40rpx; margin-bottom: 16rpx; } -.prod-name { - font-size: 30rpx; - font-weight: 600; - line-height: 1.4; +/* .age-dial .stat-age { margin-top:0; display:flex; align-items:baseline } */ +.stat-age { margin-top: 0; display: flex; align-items: baseline; } +/* .age-dial .stat-age b { font-size:54px(覆盖 58px); display:block; line-height:1 } + letter-spacing 继承 .stat-value b 的 calc(.5u) → 1rpx */ +.stat-age .stat-b { font-size: 54px; line-height: 1; letter-spacing: 1rpx; } +/* .stat-age i { font-size:19px; margin-left:2px } */ +.stat-age .stat-i { font-size: 19px; margin-left: 2px; } +/* H5 ui-20261009.css: .age-pill, .dims-note { display:none !important } + ui-20261012 只恢复了 #dims-note,肌龄对比胶囊仍是隐藏态。 + ageNote 仍由 report-model 产出(接口不变),这里与 H5 一致:保留数据不展示。 */ +.age-pill { display: none; } + +/* ─── 三维度(.card-dims 并进总分卡:H5 grid-column:1/-1;acss 用 width:100% + row-gap) + .card-dims { padding:0(覆盖 calc(18u) calc(7u) calc(7u)) } + .dim { flex:1; align-items:flex-start; padding:0 0 calc(4u) calc(14u) } + .dim + .dim { border-left:1px solid #EFEFEF } */ +.card-dims { width: 100%; margin-top: 30rpx; } +.dims-row { display: flex; } +.dim { + flex: 1; + min-width: 0; + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0 0 8rpx 28rpx; + box-sizing: border-box; } -.prod-sub { - font-size: 22rpx; - color: #868686; +.dim + .dim { border-left: 1px solid #EFEFEF; } +/* .dim-name { font-size:11px(覆盖 12px); color:var(--ink) } + 行高固定成 H5 实测行盒 14px:acss 是系统字体(H5 是 MiSans), + 若放任 normal 行高,三维度卡会整体高 1px 并向下累积。 */ +.dim-name { font-size: 11px; line-height: 14px; color: #282828; } +/* .dim-score { margin-top:calc(4u); font-size:27px(覆盖 30px); line-height:1.1; font-weight:700 } + .dim-score b { font-size:36px(覆盖 42px); font-weight:700 } */ +.dim-score { + display: flex; + align-items: baseline; margin-top: 8rpx; + font-size: 27px; + font-weight: 700; + color: #282828; + line-height: 1.1; } -.prod-img { - width: 240rpx; - height: 240rpx; +.dim-int { font-size: 36px; font-weight: 700; line-height: 1.1; } +.dim-dec { font-size: 27px; font-weight: 700; line-height: 1.1; } +/* .dim-score.dim-bad { color:var(--bad) } */ +.dim-bad { color: #D30C0C; } +/* .dim-bar { margin-top:calc(6u); width:calc(69u); height:calc(6u); + border-radius:calc(3u); background:#EFEFEF; overflow:hidden } */ +.dim-bar { + display: block; + margin-top: 12rpx; + width: 138rpx; + height: 12rpx; + border-radius: 6rpx; + background: #EFEFEF; + overflow: hidden; +} +/* .dim-bar i { height:100%; background:var(--yellow); border-radius:inherit } */ +.dim-fill { height: 100%; border-radius: 6rpx; background: #F5F078; } +.dim.best .dim-fill { background: #D7EE6A; } +.dim.worst .dim-fill { background: #F4B04F; } +/* .dim-flag { visibility:hidden; margin-top:calc(6u); font-size:8px(覆盖 9px); color:var(--ink); + background:#F8F4A6; border-radius:calc(8u); padding:calc(1u) calc(8u) } + → 非最低分维度用 visibility 保留占位,三列高度与 H5 一致 */ +.dim-flag { + visibility: hidden; + margin-top: 12rpx; + font-size: 8px; + line-height: 10px; + color: #282828; + background: #F8F4A6; border-radius: 16rpx; - flex-shrink: 0; + padding: 2rpx 16rpx; +} +.dim.worst .dim-flag { visibility: visible; } + +/* #dims-note { display:block !important; margin-top:12px; padding:10px 12px 10px 24px; + background:#F2F2F2; border-radius:calc(6u); position:relative } + #dims-note::before { left:12px; top:10px; bottom:10px; width:calc(3u); background:var(--yellow) } */ +.dims-note { + position: relative; + margin-top: 24rpx; + background: #F2F2F2; + border-radius: 12rpx; + padding: 20rpx 24rpx 20rpx 48rpx; + text-align: left; +} +.dims-note::before { + content: ''; + position: absolute; + left: 24rpx; + top: 20rpx; + bottom: 20rpx; + width: 6rpx; + border-radius: 4rpx; + background: #F5F078; +} +/* #dims-note .dims-note-l1 { font-size:12px; line-height:1.5; color:var(--para) } + .dims-note-l1 b { font-weight:500; color:var(--ink); text-decoration:underline } */ +.dims-note-l1 { font-size: 12px; line-height: 1.5; color: #4A4A4A; } +.dims-focus { + font-weight: 500; + color: #282828; + text-decoration: underline; +} +/* #dims-note .dims-note-l2 { font-size:11px; line-height:1.5; margin-top:3px; color:var(--grey) } */ +.dims-note-l2 { margin-top: 6rpx; font-size: 11px; line-height: 1.5; color: #868686; } + +/* ─── 维度标题(H5 h2.dimension-title,落在灰底上的卡外标题) + .dimension-title { font-size:23px; font-weight:600; text-align:center; + margin:calc(18u) 0(覆盖 24u/12u) },行高固定 30px = H5 实测行盒 + .dimension-stability-title { font-size:22.106597px; line-height:28.738542px } */ +.dimension-title { + font-size: 23px; + font-weight: 600; + color: #282828; + line-height: 30px; + text-align: center; + margin: 36rpx 0; +} +.dimension-stability-title { font-size: 22.106597px; line-height: 28.738542px; } + +/* ─── 维度详情卡(.card.card-detail) + .card-detail { margin-top:0; padding:calc(4u) calc(39u) calc(19u); border-radius:calc(7u) } */ +.card-detail { margin-top: 0; padding: 8rpx 78rpx 38rpx; box-sizing: border-box; } + +/* .detail-tabs { display:flex; border:none(覆盖 1px 底线) } + H5 高度由 .dtab 的 padding-top 13px + .dtab-tx 26px 撑成 40px → acss 固定 80rpx 更稳 */ +.detail-tabs { display: flex; height: 80rpx; } +/* .dtab { flex:1; padding:calc(13u) 0 0; text-align:center; font-weight:500; color:var(--ink) } + 容器 80rpx 内:上留白 28rpx(= H5 的 13px + 行盒基线偏移 1px),余下 52rpx 归 .dtab-tx; + 用 flex 起点对齐消掉 inline-block 的基线偏移,让黄色下划线与 H5 一样停在 + 距标签卡顶 34px、底 41px 的位置。 */ +.dtab { + flex: 1; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: flex-start; + padding-top: 28rpx; + text-align: center; + font-weight: 500; + color: #282828; +} +.dtab.active { font-weight: 700; } +/* .dtab-tx { position:relative; display:inline-block; font-size:12px; + padding:0 calc(5u) calc(10u) } */ +.dtab-tx { + position: relative; + display: inline-block; + font-size: 12px; + line-height: 16px; + padding: 0 10rpx 20rpx; +} +/* .dtab.active .dtab-tx::after { left:50%; transform:translateX(-50%); bottom:-1px; + width:calc(76u); height:calc(7u); + border-radius:calc(3.5u); background:var(--yellow) } */ +.dtab.active .dtab-tx::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: -1px; + width: 152rpx; + height: 14rpx; + border-radius: 7rpx; + background: #F5F078; } -.btn-more { - margin: 40rpx 32rpx 0; +/* .detail-viewport { display:block; overflow:hidden } / .detail-panels { display:flex } → + acss 只渲染当前 Tab 的面板,视觉效果等价(H5 用 transform 横滑切换) */ +.detail-viewport { overflow: hidden; } +.detail-panels { display: block; } +/* .detail-panels .dpanel { display:block; flex:0 0 100%; padding-top:calc(10u) } */ +.dpanel { display: block; padding-top: 20rpx; } +/* .ditem { display:block(覆盖 flex) } */ +.ditem { display: block; } + +/* ─── 检测图框(.viz-frame + .contour-front-frame 的 90% 高裁切) + .viz-frame { position:relative; width:calc(215u); height:calc(278u); margin:0 auto calc(12u); + background:#f6f6f6; border-radius:calc(5u); overflow:hidden } + .contour-front-frame { height:calc(250.2u); margin-bottom:calc(7.56u) } + .contour-front-frame .ditem-img { height:calc(278u) } */ +.viz-frame { + position: relative; + width: 430rpx; + height: 556rpx; + margin: 0 auto 24rpx; + background: #F6F6F6; + border-radius: 10rpx; + overflow: hidden; } -.detail-tabs { display: block; white-space: nowrap; } -.detail-tabs-inner { display: flex; width: max-content; } -.dtab { flex-shrink: 0; padding: 16rpx 20rpx; } -.analysis-image { position: relative; width: 100%; overflow: hidden; border-radius: 16rpx; background: #202020; } -.analysis-photo { display: block; width: 100%; } -.dark-photo { opacity: .45; } -.analysis-layer { position: absolute; left: 0; top: 0; width: 100%; height: 100%; } -.mark-box { position: absolute; box-sizing: border-box; border: 2rpx solid; border-radius: 4rpx; } -.side-views { display: flex; gap: 16rpx; margin: 24rpx 0; } -.side-view { flex: 1; min-width: 0; font-size: 24rpx; line-height: 1.8; } -.side-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.4); color: white; } -.side-note { color: #777; } -.detail-grade { color: #666; font-size: 24rpx; } -.care-name { font-weight: 600; margin-bottom: 8rpx; } -.dim-level { font-size: 22rpx; margin-top: 8rpx; color: #777; } -.ring-box { width: 120px; height: 120px; } +.contour-front-frame { height: 500.4rpx; margin-bottom: 15.12rpx; } +/* 画框内部固定 430×556rpx 的「照片坐标系」:照片与每个 face_maps 图层共用同一套 + left/top/width/height(report.js 按 H5 vizFaceCrop 算出),保证叠加层与人脸严格对齐 */ +.viz-stage { + position: absolute; + left: 0; top: 0; + width: 430rpx; height: 556rpx; + overflow: hidden; +} +.viz-photo { position: absolute; display: block; } +.viz-layer { position: absolute; display: block; } +/* H5 vizCompose: ctx.globalCompositeOperation='multiply'(白底色沉/缺水/红区图层) */ +.viz-blend { mix-blend-mode: multiply; } +/* H5 vizCompose({dim:.55}):照片先压暗再叠增强线(仅皱纹) */ +.viz-dim { + position: absolute; + left: 0; top: 0; + width: 100%; height: 100%; + background: #000000; + opacity: .55; +} +/* H5 坐标标注 canvas:lineWidth = max(3, 画布宽/200),缩到 430rpx 画框后约 3rpx; + rectangle 用 strokeRect,所以只描边、不填充。 */ +.viz-mark { + position: absolute; + box-sizing: border-box; + border-style: solid; + border-width: 3rpx; +} +/* .viz-missing { position:absolute; inset:0; display:flex; align-items:center; + justify-content:center; color:#888; font-size:12px } */ +.viz-missing { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + display: flex; + align-items: center; + justify-content: center; + color: #888888; + font-size: 12px; +} + +/* .ditem-text { border-top:1px solid #eee; padding-top:calc(10u) } + .ditem-para { font-size:12px(覆盖 10.1px); line-height:1.55; color:var(--para); + font-weight:400; white-space:pre-line } */ +.ditem-text { border-top: 1px solid #EEEEEE; padding-top: 20rpx; } +.ditem-para { + font-size: 12px; + font-weight: 400; + color: #4A4A4A; + line-height: 1.55; + white-space: pre-line; + overflow-wrap: anywhere; +} +/* .viz-caption { color:#707070; font-size:11px; line-height:1.6; margin-top:10px } */ +.viz-caption { + color: #707070; + font-size: 11px; + line-height: 1.6; + margin-top: 20rpx; +} +/* 稳韧力卡的标签/正文再小一号(.stability-card .dtab-tx / .ditem-para) */ +.stability-card .dtab-tx { font-size: 11.055903px; } +.stability-card .ditem-para { font-size: 11.574074px; line-height: 14.467593px; } + +/* ─── 侧脸照片(.contour-sides / .contour-side-photo / .wrinkle-side-photo) + .contour-sides { width:calc(215u); margin:0 auto calc(12u); + display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:calc(6.3u) } + .contour-side-photo { width:100%; height:calc(92.25u); border-radius:calc(5u); + background:#f6f6f6; overflow:hidden; position:relative } + .contour-side-photo img { position:absolute; inset:0; width:100%; height:100%; + object-fit:cover; transform:translateY(-25%) scale(2.8) } */ +.contour-sides { + width: 430rpx; + margin: 0 auto 24rpx; + display: flex; + justify-content: space-between; +} +.contour-side { + position: relative; + flex: none; + width: 208.7rpx; + height: 184.5rpx; + border-radius: 10rpx; + overflow: hidden; + background: #F6F6F6; +} +/* H5 grid: figure[data-view="right"] { grid-column:2 } → 只剩右脸时仍占第二列 */ +.contour-side.right { margin-left: auto; } +.side-img { + position: absolute; + left: 0; top: 0; + width: 100%; height: 100%; + transform: translateY(-25%) scale(2.8); + transform-origin: center; +} +.side-dim { + position: absolute; + left: 0; top: 0; + width: 100%; height: 100%; + background: #000000; + opacity: .55; +} +/* .wrinkle-loading { inset:0; background:rgba(0,0,0,.4); color:#fff; text-align:center; + font-size:calc(10u); line-height:1.6 } */ +.side-loading { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, .4); + color: #FFFFFF; + text-align: center; + font-size: 20rpx; + line-height: 1.6; +} +/* .wrinkle-layer-note { width:calc(215u); margin:0 auto calc(12u); color:#888; + font-size:10px; line-height:1.4 } */ +.wrinkle-layer-note { + width: 430rpx; + margin: 0 auto 24rpx; + color: #888888; + font-size: 10px; + line-height: 1.4; +} + +/* ─── 产品区大标题(.prod-title,落在灰底) + ui-20261012 最终值:width:calc(206.597222u); margin:calc(18u) auto; + transform:translateX(calc(.347222u)); font-size:22.106597px; line-height:28.738542px; + font-weight:700; white-space:nowrap; letter-spacing:0 */ +.prod-title { + width: 413.194444rpx; + max-width: 100%; + margin: 36rpx auto; + padding: 0; + text-align: center; + transform: translateX(0.694444rpx); + font-size: 22.106597px; + line-height: 28.738542px; + font-weight: 700; + color: #282828; + letter-spacing: 0; + white-space: nowrap; +} + +/* ─── 日常护理 + 推荐产品(同一张白卡 .card.card-care.report-care-products) + .report-care-products { width:calc(340.625u); margin-left:auto; margin-right:auto; + padding:calc(21.180556u) calc(24.652778u) calc(7.986111u) } */ +.report-care-products { + width: 681.25rpx; + margin-left: auto; + margin-right: auto; + padding: 42.361112rpx 49.305556rpx 15.972222rpx; + box-sizing: border-box; +} +/* .report-care-products .card-heading { font-size:13.888889px; line-height:19.444444px; + font-weight:700; text-align:left; + margin-bottom:calc(5.208333u) } */ +.card-heading { + font-size: 13.888889px; + line-height: 19.444444px; + font-weight: 700; + text-align: left; + color: #000000; + margin-bottom: 10.416666rpx; +} +/* .report-care-products .care-item { font-size:11.574074px; line-height:14.467593px; + color:var(--para); margin-bottom:calc(14.467593u) } + .care-item:last-child { margin-bottom:0 } → acss 避开伪类,由 report.js 打 '.last' 标记 */ +.care-item { + font-size: 11.574074px; + line-height: 14.467593px; + color: #4A4A4A; + margin-bottom: 28.935186rpx; + overflow-wrap: anywhere; +} +.care-item.last { margin-bottom: 0; } +/* .product-section-heading { position:relative; margin:calc(24.0625u) 0 calc(5.555556u); + font-size:13.888889px; line-height:19.444444px; + font-weight:700; text-align:left } + ::before { left:0; right:0; top:calc(-12.03125u); border-top:1px solid #DDD } */ +.product-section-heading { + position: relative; + margin: 48.125rpx 0 11.111112rpx; + font-size: 13.888889px; + line-height: 19.444444px; + font-weight: 700; + text-align: left; + color: #282828; +} +.product-section-heading::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: -24.0625rpx; + border-top: 1px solid #DDDDDD; +} +/* H5 #product-unavailable 也是 .care-item(文案见 report.axml,与 H5 index.html L274 一致) */ +.product-unavailable { margin-bottom: 0; } + +/* .report-care-products .card-product { width:auto; margin:0 calc(-18.402778u); + border-radius:calc(3.472222u); + background:#F6F6F6; overflow:hidden } */ +.card-product { + width: auto; + margin: 0 -36.805556rpx; + border-radius: 6.944444rpx; + background: #F6F6F6; + overflow: hidden; +} +/* .report-care-products .prod-card { height:auto; aspect-ratio:945/511 } + → 卡片内容宽 582.64rpx + 左右各 36.8rpx = 656.25rpx, + 高 = 656.25 × 511 ÷ 945 = 354.84rpx(定值,不依赖 aspect-ratio 支持度) */ +.prod-card { + position: relative; + height: 354.84rpx; + display: flex; + align-items: stretch; +} +/* .report-care-products .prod-info { flex-basis:49.100529%; padding:0 } */ +.prod-info { + flex: 0 0 49.100529%; + min-width: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: flex-start; +} +/* .report-care-products #product-title { position:absolute; left:8.571429%; top:29.158513%; + font-size:14.467593px; line-height:18.06279px; + font-weight:700; white-space:nowrap } */ +.prod-card-title { + position: absolute; + left: 8.571429%; + top: 29.158513%; + margin: 0; + font-size: 14.467593px; + line-height: 18.06279px; + font-weight: 700; + color: #282828; + white-space: nowrap; +} +/* .report-care-products .prod-name { left:8.677249%; top:41.09589%; font-size:12.013166px; + line-height:15.070408px; font-weight:700; color:#989898; + white-space:nowrap } */ +.prod-name { + position: absolute; + left: 8.677249%; + top: 41.09589%; + margin: 0; + font-size: 12.013166px; + line-height: 15.070408px; + font-weight: 700; + color: #989898; + white-space: nowrap; +} +/* .report-care-products .prod-badge { left:8.783069%; top:56.751468%; width:10.687831%; margin:0 } */ +.prod-badge { + position: absolute; + left: 8.783069%; + top: 56.751468%; + width: 10.687831%; + height: auto; + margin: 0; +} +/* .report-care-products .prod-scroll { flex-basis:50.899471% } + .prod-scroll img { width:100%; height:100%; aspect-ratio:auto; object-fit:fill } */ +.prod-scroll { + flex: 0 0 50.899471%; + display: flex; + overflow: hidden; +} +.prod-img { display: block; width: 100%; height: 100%; } + +/* ─── 免责声明(.report-care-products + .report-disclaimer) + margin:calc(12.152778u) calc(5.208333u) calc(20u); + font-size:7.233681px; line-height:10.127431px */ +.report-disclaimer { + margin: 24.305556rpx 10.416666rpx 40rpx; + font-size: 7.233681px; + line-height: 10.127431px; + color: #4A4A4A; +} +.report-disclaimer-line { display: block; } + +/* ─── 小程序独有:历史报告入口(H5 报告页无此按钮) + 沿用 H5 style.css 里已有的 .btn-more { margin: calc(26u) auto 0 },保持既有跳转不丢失 ─── */ +.btn-more { margin: 52rpx auto 0; } diff --git a/taobao/pages/report/report.axml b/taobao/pages/report/report.axml index ac655ef..1be7b04 100644 --- a/taobao/pages/report/report.axml +++ b/taobao/pages/report/report.axml @@ -1,53 +1,163 @@ + - 您的肌肤状态表现 - - - - {{total != null ? total : '--'}}分综合评分 - - 肌肤年龄{{skinAge != null ? skinAge : '--'}}岁{{ageNote}} - - - - {{item.name}}{{item.int}}.{{item.dec}} - {{item.level}} - 优先关注 - - 近期优先关注:{{focusName}}{{focusSlogan}} - - - - {{item.name}} - - - - {{item.name}} {{item.score}} · {{item.level}} - - - - + + + + + 您的肌肤状态表现 + + + + + + + + + + + + + {{total != null ? total : '--'}}分 + 综合评分 + - - - {{side.title}} - - - 标注生成中… - {{side.note}} + + + + 生物可视年龄 + + {{skinAge != null ? skinAge : '--'}}岁 + + + {{ageNote}} + + + + + + + {{item.name}} + {{item.int}}.{{item.dec ? item.dec : '-'}} + + + 优先关注 + + + + + 近期优先关注:{{focusName}} + {{focusSlogan}} + + + + + + + {{card.title}} + + + + + {{tab.name}} + + + + + + + + + + + + + + + + + + + 本次未获取{{view.name}}检测图 + + + + + + + + + + + 皱纹标注加载中… + + + + + {{wrinkleNote}} + + + + {{view.text}} + + + {{view.caption}} + + + - {{item.text}} + + + + 感受高光时刻 + 从日常的好状态开始 + + + + + 日常护理 + + {{ci + 1}}、{{care.text}} + 本次有效数据不足,暂无个性化护理建议。 + + + 推荐产品 + 本次未获取有效肌龄,暂无法推荐产品,建议重新检测。 + + + + {{product.title}} + {{product.name}} + + + + + + + + + + *您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。 + *本次检测仅代表当前肌肤状态参考。 + *仅根据您最近的测肤记录推荐。 + + + + - 您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。本次检测仅供当前肌肤状态参考。 - 护理建议 - {{item.name}}{{item.text}} - 好状态,是内外同调的长期节奏。 - - - {{product.title}}{{product.name}} - - - diff --git a/taobao/pages/report/report.js b/taobao/pages/report/report.js index 6178afb..209286a 100644 --- a/taobao/pages/report/report.js +++ b/taobao/pages/report/report.js @@ -1,67 +1,292 @@ +/* ═════ 08 报告页 —— 1:1 移植 H5 #s-report ═════ + 数据来源不变:utils/report-model.js 的 model()/layers()/marks()(接口未改), + 本文件只做 H5 版面映射: + 1) 七个检测视角按 H5 的三张维度卡分组(紧塑力 2 / 透亮感 3 / 稳韧力 2); + 2) H5 SVG 环形评分 → conic-gradient 角度 + 圆头补点(acss 无 svg); + 3) H5 app.js vizFaceCrop/vizCompose 的人脸裁切窗口 → 照片与 face_maps 图层共用的 + 「同一画框绝对定位」,保证叠加层位置与 H5 canvas 合成一致; + 4) 痘/斑标记坐标:接口像素坐标 → 画框百分比(含 H5 的最小框放大规则); + 5) 侧脸皱纹标注:payload.wrinkle_job + api.wrinkleViews,pending/unavailable 文案同 H5。 */ var api = require('../../utils/api'), report = require('../../utils/report-model'), config = require('../../config'); + +/* H5 检测图框 .viz-frame:calc(215 * var(--u)) × calc(278 * var(--u)) → 430×556rpx(report.acss 同值) */ +var FRAME_W = 430, FRAME_H = 556, FRAME_RATIO = 215 / 278; +/* H5 canvas 过小标记的最小框 = 画布宽 / 36 ≈ 6px;缩到画框后约 12rpx */ +var MIN_MARK = 12; +/* H5 环形评分 viewBox 0 0 120 120、r=52 → 216rpx 画盘上半径 93.6rpx、圆心 108rpx */ +var RING_R = 93.6, RING_C = 108; +/* 照片未拿到自然尺寸前的兜底:整框 + contain(H5 object-fit:contain 语义) */ +var DEFAULT_BOX = 'left:0;top:0;width:' + FRAME_W + 'rpx;height:' + FRAME_H + 'rpx;'; + +/* H5 报告页的三张维度卡(index.html L209-264:紧塑力/透亮感/稳韧力) */ +var GROUPS = [ + { key: 'firm', title: '紧塑力', titleCls: '', cardCls: '', views: ['contour', 'wrinkles'] }, + { key: 'vita', title: '透亮感', titleCls: '', cardCls: '', views: ['color', 'smooth', 'water'] }, + { key: 'stab', title: '稳韧力', titleCls: 'dimension-stability-title', cardCls: 'stability-card', views: ['sens', 'acne'] } +]; +/* H5 app.js VIZ_CAT:blend=正片叠底(白底色沉/缺水/红区图层);wrinkles 需把照片压暗 0.55 */ +var BLEND = { color: true, water: true, sens: true }; +var DARK = { wrinkles: .55 }; +/* H5 app.js vizCompose({contentDown}):轮廓/皱纹画框只露上部 90%,主体需下移 9% */ +var CONTENT_DOWN = { contour: .09, wrinkles: .09 }; +/* 有色沉图层时 H5 不再重复画黑色素框(VIZ_CAT.tone 的 filter) */ +var MELANIN_COLOR = '#9F641F'; + +function numeric(value) { return typeof value === 'number' && isFinite(value); } +function px(value) { return (Math.round(value * 100) / 100) + 'rpx'; } +function pct(ratio) { return Math.round(ratio * 10000) / 100; } + +/* H5 SVG 环形评分:#ring-fg 的 stroke-dashoffset = 326.73 × (1 - 总分/100),顺时针、从正上方开始。 + acss 用 conic-gradient 画弧 + 两个圆点还原 stroke-linecap:round 的两端。 */ +function ringOf(total) { + var value = total == null ? 0 : Math.max(0, Math.min(100, total)); + var angle = Math.PI * 2 * value / 100; + var x = RING_C + RING_R * Math.sin(angle), y = RING_C - RING_R * Math.cos(angle); + return { ringDeg: Math.round(360 * value / 100 * 100) / 100, ringCap: total != null && value > 0, + ringCapEnd: 'left:' + px(x) + ';top:' + px(y) + ';' }; +} + +/* H5 style.css .dim.best(最高分浅绿条)+ 既有 .dim.worst(report-model 已按原始 P 判定的优先关注) */ +function rankDims(dims) { + var worstKey = '', bestKey = '', best = -1, scored = 0; + dims.forEach(function (dim) { + if (dim.rank === 'worst') worstKey = dim.key; + if (typeof dim.int === 'string' && dim.int !== '-') scored++; + }); + dims.forEach(function (dim) { + if (dim.key === worstKey || dim.int === '-') return; + if (dim.barWidth > best) { best = dim.barWidth; bestKey = dim.key; } + }); + if (scored < 2) bestKey = ''; + return dims.map(function (dim) { + var copy = Object.assign({}, dim); + copy.rank = dim.key === worstKey ? 'worst' : dim.key === bestKey ? 'best' : ''; + return copy; + }); +} + +/* H5 app.js vizFaceCrop:人脸矩形 → 215:278 裁切窗口(矩形不可用时退回整幅) */ +function faceCrop(width, height, rectangle) { + var full = { left: 0, top: 0, width: width, height: height, focused: false }; + if (!rectangle) return full; + var keys = ['left', 'top', 'width', 'height']; + for (var i = 0; i < keys.length; i++) if (!numeric(rectangle[keys[i]])) return full; + if (rectangle.width <= 0 || rectangle.height <= 0 || rectangle.left < 0 || rectangle.top < 0 || + rectangle.left + rectangle.width > width || rectangle.top + rectangle.height > height) return full; + var ch = Math.min(Math.max(rectangle.height * 1.55, rectangle.width * 1.18 / FRAME_RATIO), height, width / FRAME_RATIO); + var cw = ch * FRAME_RATIO; + if (cw < rectangle.width || ch < rectangle.height) return full; + var left = Math.max(0, Math.min(width - cw, rectangle.left + rectangle.width / 2 - cw / 2)); + var top = Math.max(0, Math.min(height - ch, rectangle.top - rectangle.height * .3)); + if (top + ch < rectangle.top + rectangle.height) top = rectangle.top + rectangle.height - ch; + return { left: left, top: top, width: cw, height: ch, focused: true }; +} +/* H5 app.js vizShiftContentDown:把画面整体下移(源窗口上移) */ +function shiftContentDown(crop, rectangle, fraction) { + if (!crop.focused || !rectangle || !fraction) return crop; + var minimumTop = Math.max(0, rectangle.top + rectangle.height - crop.height); + var copy = Object.assign({}, crop); + copy.top = Math.max(minimumTop, crop.top - crop.height * fraction); + return copy; +} +/* 画框(430×556rpx)内的照片/图层定位:left/top/width/height 均为 rpx, + 照片与图层共用同一套值,图层因此与照片逐像素贴合 */ +function frameBox(width, height, rectangle, contentDown) { + var crop = faceCrop(width, height, rectangle), scale, left, top, boxW, boxH; + if (crop.focused) { + crop = shiftContentDown(crop, rectangle, contentDown); + scale = FRAME_W / crop.width; + left = -crop.left * scale; top = -crop.top * scale; + boxW = width * scale; boxH = height * scale; + } else { + // 无可用人脸矩形:H5 canvas 保留整幅比例,再由 object-fit:contain 内嵌 → 这里等价等比内嵌 + scale = Math.min(FRAME_W / width, FRAME_H / height); + boxW = width * scale; boxH = height * scale; + left = (FRAME_W - boxW) / 2; top = (FRAME_H - boxH) / 2; + } + return { left: left, top: top, width: boxW, height: boxH, scale: scale, + style: 'left:' + px(left) + ';top:' + px(top) + ';width:' + px(boxW) + ';height:' + px(boxH) + ';' }; +} +/* 标记:接口像素坐标 → 画框(430×556rpx)百分比;过小标记按 H5 规则放大并保持中心。 + 照片/图层在画框内的定位是 box.left = -裁切窗口左边界 × scale, + 所以原始像素 X 落在画框里的位置就是 box.left + X × scale。 */ +function placeMarks(marks, width, height, box) { + var scale = box.scale; + return (marks || []).map(function (mark) { + var left = box.left + (mark.left / 100 * width) * scale; + var top = box.top + (mark.top / 100 * height) * scale; + var w = mark.width / 100 * width * scale, h = mark.height / 100 * height * scale; + if (w < MIN_MARK) { left -= (MIN_MARK - w) / 2; w = MIN_MARK; } + if (h < MIN_MARK) { top -= (MIN_MARK - h) / 2; h = MIN_MARK; } + return { id: mark.id, color: mark.color, left: pct(left / FRAME_W), top: pct(top / FRAME_H), + width: pct(w / FRAME_W), height: pct(h / FRAME_H) }; + }); +} +/* H5 app.js renderViz 的 #smooth-map-note 文案(毛孔=蓝、黑头=红) */ +function smoothCaption(result) { + var maps = (result && result.face_maps) || {}, notes = []; + if (report.mapURL(maps.texture_enhanced_pores)) notes.push('蓝色为毛孔增强标记'); + if (report.mapURL(maps.texture_enhanced_blackheads)) notes.push('红色为黑头增强标记'); + return notes.length ? notes.join(',') + ';标记不代表严重程度,具体以检测计数和结果为准。' + : '本次未获取毛孔、黑头增强图;此处仅展示拍摄照片,不绘制色斑、痣或痤疮框。'; +} + Page({ - data: { total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '', - views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null, sideViews: [] }, + data: { + // 图片统一走 api.assetUrl(与 H5 h5/assets/ 同相对路径) + productBadgeUrl: api.assetUrl('img/ui-20261015/product-badge.png'), + // report-model.model() 产出(接口不变) + total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '', + views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null, + // 版面对齐 H5 所需的派生数据 + cards: [], sides: [], sidesShown: false, wrinkleNote: '', + ringDeg: 0, ringCap: false, ringCapEnd: '' + }, onLoad: function () { this.alive = true; this.visible = true; this.ownerId = api.user() && api.user().id; var gd = getApp().globalData; if (!gd.payload || !gd.payload.result) { my.redirectTo({ url: '/pages/splash/splash' }); return; } this.payload = gd.payload; this.photos = gd.photos || []; - try { this.setData(report.model(this.payload, this.photos, gd.profile || {})); } - catch (_) { my.showToast({ content: '报告生成失败,请重新检测', type: 'none' }); } + var model; + try { model = report.model(this.payload, this.photos, gd.profile || {}); } + catch (_) { my.showToast({ content: '报告生成失败,请重新检测', type: 'none' }); return; } + var result = this.payload.result, ring = ringOf(model.total); + this.setData(Object.assign({}, model, { + dims: rankDims(model.dims), + cards: this.buildCards(model, result), + ringDeg: ring.ringDeg, ringCap: ring.ringCap, ringCapEnd: ring.ringCapEnd + })); this.renderSides(this.payload.wrinkle_views); }, - onReady: function () { this.ready = true; this.drawRing(this.data.total); this.loadSides(); }, + onReady: function () { this.ready = true; this.measurePhoto(); this.loadSides(); }, onShow: function () { if (!api.user() || this.ownerId !== api.user().id) { - this.onHide(); this.setData({ frontPhoto: '', views: [], sideViews: [], careAdvice: [], product: null }); + this.onHide(); + this.setData({ frontPhoto: '', views: [], cards: [], sides: [], careAdvice: [], product: null }); my.redirectTo({ url: '/pages/splash/splash' }); return; } this.visible = true; if (this.ready) this.loadSides(); }, onHide: function () { this.visible = false; if (this.signal) this.signal.canceled = true; }, onUnload: function () { this.onHide(); this.alive = false; this.photos = []; this.payload = null; }, - onTabChange: function (e) { - var i = Number(e.currentTarget.dataset.tab), view = this.data.views[i]; - if (!view) return; - this.setData({ viewIndex: i, activeLayers: report.layers(this.payload.result, view.key) }); + + /* ─── H5 三张维度卡的视图分组(七个检测视角各带图层/标记/框型) ─── */ + buildCards: function (model, result) { + var byKey = {}; + model.views.forEach(function (view) { byKey[view.key] = view; }); + var hasPhoto = !!model.frontPhoto; + return GROUPS.map(function (group) { + var views = []; + group.views.forEach(function (key) { + var view = byKey[key]; + if (!view) return; + var layers = result ? report.layers(result, key) : []; + // 有水印图层即可出图;轮廓/肤色/平滑度/痘痘必须有人脸照片(H5 renderViz 的降级规则) + var needsPhotoOnly = key === 'contour' || key === 'color' || key === 'smooth' || key === 'acne'; + views.push({ + key: key, name: view.name, text: view.text, dim: !!view.dim, alpha: view.alpha, + layers: layers, marks: [], + blend: !!BLEND[key], dark: DARK[key] || 0, + frameCls: CONTENT_DOWN[key] ? 'contour-front-frame' : '', + fit: 'aspectFit', photoStyle: DEFAULT_BOX, + hasVisual: needsPhotoOnly ? hasPhoto : (hasPhoto || layers.length > 0), + caption: key === 'smooth' ? smoothCaption(result) : '' + }); + }); + return { key: group.key, title: group.title, titleCls: group.titleCls, cardCls: group.cardCls, index: 0, views: views }; + }); }, - onPhotoLoad: function (e) { - var i = Number(e.currentTarget.dataset.index), view = this.data.views[i]; - if (!view || !this.payload) return; - var points = report.marks(this.payload.result, view.key, e.detail.width, e.detail.height); - var patch = {}; patch['views[' + i + '].marks'] = points; this.setData(patch); + + /* ─── 人脸裁切窗口 + 标记坐标:照片自然尺寸已知后计算一次 ─── */ + measurePhoto: function () { + var that = this, src = this.data.frontPhoto; + if (!src || this.geoDone || typeof my.getImageInfo !== 'function') return; + my.getImageInfo({ src: src, + success: function (info) { that.applyGeometry(info && info.width, info && info.height); }, + fail: function () {} }); }, + // H5 img.onload 兜底(getImageInfo 不可用时也能拿到自然尺寸) + onPhotoLoad: function (event) { + var detail = (event && event.detail) || {}; + this.applyGeometry(detail.width, detail.height); + }, + applyGeometry: function (width, height) { + if (!(width > 0 && height > 0) || this.geoDone) return; + this.geoDone = true; + var that = this, result = this.payload && this.payload.result; + var rectangle = this.payload && this.payload.face_rectangle; + var patch = {}; + this.data.cards.forEach(function (card, ci) { + card.views.forEach(function (view, vi) { + var box = frameBox(width, height, rectangle, CONTENT_DOWN[view.key] || 0); + var marks = report.marks(result, view.key, width, height); + // H5:有色沉图层时不再画黑色素框(避免和 browning 图层重复) + if (view.key === 'color' && view.layers && view.layers.length) { + marks = marks.filter(function (mark) { return mark.color !== MELANIN_COLOR; }); + } + var key = 'cards[' + ci + '].views[' + vi + ']'; + patch[key + '.photoStyle'] = box.style; + patch[key + '.fit'] = 'scaleToFill'; + patch[key + '.marks'] = placeMarks(marks, width, height, box); + }); + }); + if (that.alive !== false) this.setData(patch); + }, + + /* ─── 视角切换:保持既有 viewIndex / activeLayers 语义 ─── */ + onTabTap: function (event) { + var dataset = (event && event.currentTarget && event.currentTarget.dataset) || {}; + var key = dataset.card, index = Number(dataset.index), patch = {}, selected = null; + this.data.cards.forEach(function (card, ci) { + if (card.key !== key) return; + patch['cards[' + ci + '].index'] = index; + selected = card.views[index] || null; + }); + if (!selected) return; + var flat = this.data.viewIndex; + this.data.views.forEach(function (view, i) { if (view.key === selected.key) flat = i; }); + patch.viewIndex = flat; + patch.activeLayers = this.payload && this.payload.result ? report.layers(this.payload.result, selected.key) : []; + this.setData(patch); + }, + + /* ─── 侧脸照片 + 皱纹标注(H5 renderWrinkleSides / renderContourSides) ─── + pending:未标注一侧显示「皱纹标注加载中…」 + unavailable:显示「部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。」 */ renderSides: function (views) { - var that = this; - this.setData({ sideViews: ['left', 'right'].map(function (key, i) { - var item = views && views[key] || {}, layer = report.mapURL(item.result && item.result.face_maps && item.result.face_maps.texture_enhanced_lines); - return { key: key, title: i ? '右脸皱纹' : '左脸皱纹', photo: that.photos[i + 1] || '', layer: layer, - loading: item.status === 'pending', note: item.status === 'unavailable' ? '侧脸标注暂不可用' : '' }; - }) }); + var payload = this.payload || {}, photos = this.photos || []; + views = views || payload.wrinkle_views || {}; + var pending = !!(payload.wrinkle_job && payload.wrinkle_job.status === 'pending'); + var sides = ['left', 'right'].map(function (key, i) { + var item = views[key]; + // 只使用该侧照片自己的接口数据:wrinkle_views → payload.side(H5 同序回退) + if (!item || !item.result) item = (payload.side || {})[key]; + var source = (item && (item.result || item)) || {}; + var layer = report.mapURL(source.face_maps && source.face_maps.texture_enhanced_lines); + return { key: key, photo: photos[i + 1] || '', layer: layer, loading: pending && !layer }; + }); + var shown = sides.filter(function (side) { return !!side.photo; }); + var plain = pending ? false : shown.some(function (side) { return !side.layer; }); + this.setData({ sides: sides, sidesShown: shown.length > 0, + wrinkleNote: plain ? '部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。' : '' }); }, loadSides: function () { var that = this, job = this.payload && this.payload.wrinkle_job; - if (!job || job.status === 'complete' || !this.visible || this.loadingSides) return; - this.loadingSides = true; this.signal = { canceled: false }; var signal = this.signal; + if (!job || job.status !== 'pending' || !this.visible || this.loadingSides) return; + this.loadingSides = true; this.signal = { canceled: false }; + var signal = this.signal; api.wrinkleViews(job.token, signal).then(function (data) { if (!that.alive || !that.visible || signal.canceled) return; - that.payload.wrinkle_views = data.wrinkle_views; job.status = 'complete'; that.renderSides(data.wrinkle_views); + that.payload.wrinkle_views = (data && data.wrinkle_views) || {}; + job.status = 'complete'; that.renderSides(that.payload.wrinkle_views); }).catch(function () { - if (that.alive && that.visible && !signal.canceled) that.renderSides({ left: { status: 'unavailable' }, right: { status: 'unavailable' } }); + if (!that.alive || !that.visible || signal.canceled) return; + job.status = 'unavailable'; that.renderSides(null); }).then(function () { that.loadingSides = false; if (signal.canceled && that.alive && that.visible) that.loadSides(); }); }, - drawRing: function (total) { - var ctx = my.createCanvasContext('score-ring'), ratio = total == null ? 0 : Math.max(0, Math.min(100, total)) / 100; - ctx.clearRect(0, 0, 120, 120); ctx.setLineWidth(8); - ctx.beginPath(); ctx.arc(60, 60, 52, 0, 2 * Math.PI); ctx.setStrokeStyle('#E5E5E5'); ctx.stroke(); - if (total != null) { - ctx.beginPath(); ctx.arc(60, 60, 52, -Math.PI / 2, -Math.PI / 2 + ratio * 2 * Math.PI); - ctx.setStrokeStyle('#F5F078'); ctx.setLineCap('round'); ctx.stroke(); - } - ctx.draw(); - }, + + /* 推荐产品 → 淘宝商品详情(config.productItemIds 未配置时为 no-op,不伪造链接) */ onMore: function () { var id = this.data.product && config.productItemIds[this.data.product.tier]; if (!id) return; diff --git a/taobao/pages/splash/splash.acss b/taobao/pages/splash/splash.acss index 73fcdec..6e5c2d7 100644 --- a/taobao/pages/splash/splash.acss +++ b/taobao/pages/splash/splash.acss @@ -1,145 +1,454 @@ -/* ═════ 启动页样式 ═════ */ +/* ═════ 01 启动页 —— 逐条对照 H5: + style.css #s-splash(L83-102) .splash-bg .splash-slogan .splash-bottom .brand-row .btn-primary + ui-20261009.css L8,L20-30,L88,L100-105 + ui-20261012.css L4,L9-30 .splash-slogan / .splash-slogan-copy / .splash-bottom / .brand-lockup + 换算:calc(N * var(--u)) → 2N rpx;固定 px 的字号/行高/圆角保持不变。 ═════ */ + +page { background: #101823; } + +/* H5 .screen(#s-splash: position:absolute; inset:0; overflow-y:auto) */ .splash { position: relative; width: 100%; height: 100vh; overflow: hidden; + background: #101823; } + +/* ─── H5 .splash-bg ─── */ .splash-bg { position: absolute; - left: 0; top: 0; + inset: 0; width: 100%; height: 100%; } -.splash-slogan { - position: absolute; - top: 200rpx; - left: 0; - right: 0; - text-align: center; - color: #FFFFFF; -} -.splash-slogan text { - display: block; - font-size: 44rpx; - font-weight: 600; - line-height: 1.6; -} -.splash-bottom { - position: absolute; - left: 0; right: 0; bottom: 0; - padding: 0 64rpx 96rpx; +/* ─── H5 .account-status(.account-status-home) ─── */ +.account-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-radius: 8px; + font-size: 12px; box-sizing: border-box; } +.account-status-home { + position: absolute; + top: max(10px, env(safe-area-inset-top)); + right: 12px; + z-index: 2; + background: rgba(0, 0, 0, .533); + color: #fff; +} +/* H5 .account-status span::before */ +.account-label { display: flex; align-items: center; } +.account-dot { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 7px; + border-radius: 50%; + background: #b7bac0; +} +.account-status-on .account-dot { background: #34ca7b; } +.account-text { white-space: nowrap; } +/* H5 .account-logout(ui-20261012 L4) */ +.account-logout { + font-size: 11px; + opacity: .8; + white-space: nowrap; +} + +/* ─── H5 .splash-slogan.splash-slogan-updated(ui-20261012 L9-20) ─── */ +.splash-slogan { + position: absolute; + left: calc(50% - 1.041667px); + top: max(6.838833%, calc(env(safe-area-inset-top) + 48px)); + width: 249.305556px; + height: 75.347222px; + transform: translateX(-50%); + text-align: center; + color: #fff; + font-size: 23px; + font-weight: 400; + line-height: 1.16; + letter-spacing: 0; + text-shadow: none; +} +/* H5 .splash-slogan-updated .splash-slogan-copy{width:100%;height:100%;clip-path:inset(68% 0 0)} + → 等价实现:只露出底部 32%(24.111111px),image 整体上移 68%(51.236111px) */ +.splash-slogan-copy-clip { + position: absolute; + left: 0; + right: 0; + top: 51.236111px; + height: 24.111111px; + overflow: hidden; +} +.splash-slogan-copy { + position: absolute; + left: 0; + top: -51.236111px; + width: 100%; + height: 75.347222px; +} +.splash-slogan-brand, +.splash-slogan-english { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-family: Arial, sans-serif; + font-size: 23px; + font-weight: 400; + line-height: 1; + white-space: nowrap; + color: #fff; +} +.splash-slogan-brand { top: -4px; font-size: 28px; } +.splash-slogan-english { top: 26.736111px; } + +/* ─── H5 .splash-bottom(ui-20261012 L25) ─── */ +.splash-bottom { + position: absolute; + left: 0; + right: 0; + bottom: max(5.882353%, env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + align-items: center; + gap: 14.583334rpx; /* calc(7.291667 * var(--u)) */ + padding-bottom: 0; +} +/* H5 .brand-row(ui-20261012 L26) */ .brand-row { display: flex; align-items: center; - margin-bottom: 32rpx; + gap: 0; + margin-bottom: 20.833334rpx; /* calc(10.416667 * var(--u)) */ } -.brand-logo { - height: 48rpx; - margin-right: 16rpx; -} -.brand-text { - color: #FFFFFF; - font-size: 32rpx; - font-weight: 600; +.brand-lockup { + width: 179.513889px; + height: 20.486111px; } -/* ─── 隐私授权弹窗 ─── */ -.privacy-row { +/* ─── H5 #s-splash .btn-primary(ui-20261012 L28) ─── */ +.splash .btn-primary { display: flex; align-items: center; - margin-bottom: 32rpx; -} -.checkbox { - width: 36rpx; - height: 36rpx; - border: 2rpx solid #E5E5E5; - border-radius: 50%; - margin-right: 16rpx; - box-sizing: border-box; - position: relative; -} -.checkbox.checked { + justify-content: center; + width: 362.5rpx; /* calc(181.25 * var(--u)) */ + height: 77.777778rpx; /* calc(38.888889 * var(--u)) */ + line-height: 77.777778rpx; + border-radius: 13.888888rpx; /* calc(6.944444 * var(--u)) */ background: #F5F078; - border-color: #F5F078; -} -.checkbox.checked::after { - content: ''; - position: absolute; - left: 10rpx; - top: 6rpx; - width: 8rpx; - height: 14rpx; - border-right: 4rpx solid #282828; - border-bottom: 4rpx solid #282828; - transform: rotate(45deg); -} -.privacy-text { - font-size: 26rpx; - color: #4A4A4A; -} -.privacy-link { color: #282828; + font-size: 14.5px; + font-weight: 500; + letter-spacing: 1rpx; + padding: 0; + border: none; + box-sizing: border-box; + transition: transform .12s ease; +} +.splash .btn-primary::after { border: none; } +.btn-press { transform: scale(.96); } +/* H5 .btn-history{background:#fff} */ +.splash .btn-history { background: #fff; } +/* H5 .splash-button-label(ui-20261012 L29-30) */ +.splash-button-label { + width: 55.902778px; + height: 13.888889px; +} +.splash .btn-history .splash-button-label, +.splash-button-label.btn-history-label { + width: 56.597222px; + height: 13.541667px; +} +.btn-busy { opacity: .6; } + +/* ─── 弹层基类(H5 .overlay) ─── */ +.splash .overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, .5); +} + +/* ═══ 隐私授权弹窗 H5 #p-privacy ═══ */ +/* H5 #p-privacy{background:linear-gradient(#0008,#0008),url(bg-splash-h5) center/cover} */ +.overlay-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; +} +.overlay-dim { + position: absolute; + inset: 0; + z-index: 1; + background: rgba(0, 0, 0, .533); +} +/* H5 #p-privacy .popup(ui-20261012 L34-38) */ +.splash .popup { + position: relative; + z-index: 2; + width: 307.986111px; + height: 313.194444px; + min-height: 313.194444px; + top: -42.881944px; + padding: 0; + background: #fff; + border-radius: 8.680556px; + display: flex; + flex-direction: column; + text-align: left; + box-sizing: border-box; + text-rendering: geometricPrecision; +} +/* H5 .popup-close / #p-privacy .popup-close(L65-66) */ +.splash .popup-close { + position: absolute; + top: 10.055556px; + right: 10.055556px; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + box-sizing: border-box; +} +.splash .popup-close::after { border: none; } +.popup-close-img { + width: 17.708333px; + height: 17.361111px; +} +/* H5 .popup-title(L39-44) */ +.splash .popup-title { + position: absolute; + left: 31.944444px; + top: 35px; + margin: 0; + font-size: 23.148148px; + line-height: 28.935185px; + font-weight: 700; + letter-spacing: 0; + color: #282828; + white-space: nowrap; + text-align: left; /* 覆盖 app.acss .popup-title{text-align:center} */ +} +/* H5 .popup-body(L45-49) */ +.splash .popup-body { + position: absolute; + left: 31.944444px; + top: 109.027778px; + width: 243.75px; + margin: 0; + font-size: 11.574074px; + line-height: 17.361111px; + font-weight: 400; + letter-spacing: 0; + color: #282828; +} +/* H5 .privacy-row / .checkbox / .privacy-text / .privacy-link(L53-56) */ +.splash .privacy-row { + position: absolute; + left: 29.861111px; + top: 184.375px; + display: flex; + align-items: center; + gap: 10.069444px; +} +.splash .checkbox { + width: 11.805556px; + height: 12.152778px; + border-radius: 50%; + border: 1px solid #282828; + background: #fff; + flex-shrink: 0; + position: relative; + box-sizing: border-box; +} +.splash .checkbox.checked::after { + content: ""; + position: absolute; + inset: 2px; + border-radius: 50%; + background: #2C2C2C; +} +.splash .privacy-text { + font-size: 10.127315px; + line-height: 18.807870px; + color: #757575; + white-space: nowrap; +} +.splash .privacy-link { + color: #282828; + font-weight: 700; text-decoration: underline; } -.popup-btn { width: 100%; } +/* H5 #p-privacy .popup-btn(L57-63) —— 必须排在 .btn-primary 之后才能覆盖宽高 */ +.splash .popup-btn { + position: absolute; + left: 63.194444px; + top: 239.930556px; + margin: 0; + width: 181.25px; + height: 38.888889px; + line-height: 38.888889px; + font-size: 14.467593px; + font-weight: 700; + letter-spacing: 0; + border-radius: 6.944444px; +} +@media (max-height: 440px) { + .splash .popup { top: 0; } +} -/* ─── 隐私协议全文弹层 ─── */ -.overlay-policy { padding: 0; } +/* ═══ 隐私政策全文 H5 #p-policy ═══ */ +.splash .overlay-policy { background: #F5F5F5; z-index: 60; } .policy-page { position: relative; width: 100%; height: 100%; - background: #FFFFFF; + display: flex; + flex-direction: column; + background: #fff; } -.policy-back { +/* H5 .policy-float-back + .nav-back */ +.splash .policy-float-back { position: absolute; - top: 24rpx; - left: 24rpx; + top: 12px; + left: 12px; z-index: 2; - width: 64rpx; - height: 64rpx; - line-height: 56rpx; - font-size: 56rpx; - color: #282828; - background: none; + width: 27px; + height: 27px; + margin: 0; padding: 0; -} -.policy-back::after { border: none; } -.policy-scroll { - height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #fff; + border: 1px solid #E5E5E5; + border-radius: 50%; + box-shadow: 0 1px 6px rgba(0, 0, 0, .12); box-sizing: border-box; - padding: 96rpx 40rpx 64rpx; +} +.splash .policy-float-back::after { border: none; } +/* H5 .policy-body + #p-policy .policy-body{padding-top:52px} */ +.policy-body { + flex: 1; + min-height: 0; + box-sizing: border-box; + padding: 52px 20px 48px; + background: #fff; } .policy-title { - font-size: 36rpx; + font-size: 19px; font-weight: 700; - margin-bottom: 40rpx; + color: #282828; + line-height: 1.4; + margin-bottom: 16px; } .policy-h2 { - font-size: 30rpx; + font-size: 15px; font-weight: 600; - margin: 40rpx 0 16rpx; + color: #282828; + margin: 26px 0 10px; + padding-left: 9px; + border-left: 4px solid #F5F078; + line-height: 1.3; } .policy-p { - font-size: 26rpx; + font-size: 12.5px; color: #4A4A4A; - line-height: 1.8; - margin-bottom: 16rpx; + line-height: 1.9; + margin-bottom: 10px; + text-align: justify; } .policy-end { - text-align: center; + font-size: 12.5px; color: #868686; - font-size: 24rpx; - margin: 48rpx 0; + line-height: 1.9; + text-align: center; + margin: 28px 0 10px; +} + +/* ─── H5 .toast ─── */ +.toast { + position: fixed; + left: 50%; + bottom: 15%; + transform: translateX(-50%) translateY(8px); + max-width: 86%; + background: rgba(20, 20, 20, .85); + color: #fff; + font-size: 13px; + line-height: 1.5; + text-align: center; + padding: 9px 18px; + border-radius: 18px; + opacity: 0; + pointer-events: none; + transition: all .25s ease; + z-index: 99; + box-sizing: border-box; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* ═══ 开发版入口(H5 无;保持低调) ═══ */ +.dev-entries { + position: absolute; + z-index: 3; + top: max(10px, env(safe-area-inset-top)); + left: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} +.dev-entry { + font-size: 10px; + line-height: 1.5; + color: rgba(255, 255, 255, .42); +} +.diag-panel { + position: absolute; + z-index: 20; + left: 32rpx; + right: 32rpx; + top: 240rpx; + bottom: 160rpx; + background: rgba(6, 16, 26, .96); + border: 1rpx solid rgba(120, 200, 255, .35); + border-radius: 20rpx; + padding: 20rpx; + display: flex; + flex-direction: column; + box-sizing: border-box; +} +.diag-scroll { flex: 1; min-height: 0; } +.diag-text { + color: #9fd0ff; + font-size: 22rpx; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-all; +} +.diag-close { + margin-top: 16rpx; + text-align: center; + color: #0b1622; + background: #ffd94d; + border-radius: 12rpx; + padding: 16rpx 0; + font-size: 26rpx; } -.account-links { display: flex; justify-content: center; gap: 40rpx; color: #fff; margin-top: 24rpx; font-size: 24rpx; } -.splash-slogan { top: 10.7%; } -.splash-slogan text { font-size: 44rpx; line-height: 1.4; } -.brand-row { justify-content: center; } -.brand-logo { width: 450rpx; height: 52rpx; margin: 0; } -.camera-debug-entry { position: absolute; z-index: 2; top: 24rpx; right: 24rpx; padding: 16rpx 24rpx; border-radius: 16rpx; color: #fff; background: rgba(0,0,0,.65); font-size: 24rpx; } -.mvp-debug-entry { top: 100rpx; } diff --git a/taobao/pages/splash/splash.axml b/taobao/pages/splash/splash.axml index ac07a02..d5cd832 100644 --- a/taobao/pages/splash/splash.axml +++ b/taobao/pages/splash/splash.axml @@ -1,49 +1,84 @@ - + - 开发调试 · 相机验收 - SAE MVP 连通性 - - - 1'me, - FROM THE INSIDE OUT - 以我为始 自有表现 + + + 开发调试 · 相机验收 + SAE MVP 连通性 + 权限诊断 + 相机组件测试 + + + + + {{diagText}} + + 关闭 + + + + + + + + + + + + 1'me, + FROM THE INSIDE OUT + + + - - - - + + - - - - + + + + + + - 欢迎体验1'me - 肌肤状态评估服务! + 欢迎体验1'me + 肌肤状态评估服务! - 授权淘宝账号后,我们将采集您的面部影像和基础档案,用于生成肌肤分析报告,并在服务器保存检测记录及照片,供您查看最近5份报告。照片将交由 Face⁺⁺ 提供检测分析。 + 我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。 - - 我已阅读并同意 - 相关隐私协议 - + 我已阅读并同意相关隐私协议 - + - + - - + + Face⁺⁺个人信息与隐私保护政策 前言 @@ -80,4 +115,7 @@ + + + {{toastText}} diff --git a/taobao/pages/splash/splash.js b/taobao/pages/splash/splash.js index 61f1112..9f3e991 100644 --- a/taobao/pages/splash/splash.js +++ b/taobao/pages/splash/splash.js @@ -1,23 +1,51 @@ -/* ═════ 启动页:品牌底图 + Slogan + 隐私授权弹窗(对齐 H5 #s-splash + #p-privacy) ═════ */ +/* ═════ 启动页:品牌底图 + Slogan + 开始检测/我的报告 + 隐私授权弹窗 + 对照 H5 index.html #s-splash(L43-58)、#p-privacy(L294-307)、#p-policy(L335-466) + 交互保持:登录(必须勾选协议)、restore、历史入口、退出登录、政策全文、开发版入口。 ═════ */ var api = require('../../utils/api.js'); var development = require('../../utils/development'); var config = require('../../config'); Page({ data: { - bgUrl: api.assetUrl('img/bg-splash.jpg'), // 底图远程加载 + // 图片全部走 api.assetUrl(与 H5 h5/assets/ 同相对路径) + bgUrl: api.assetUrl('img/ui-20261009/bg-splash-h5.jpg'), logoUrl: api.assetUrl('img/ui-20261012/brand-lockup.png'), + sloganUrl: api.assetUrl('img/ui-20261012/splash-slogan.png'), + startLabelUrl: api.assetUrl('img/ui-20261012/button-start-label.png'), + historyLabelUrl: api.assetUrl('img/ui-20261012/button-history-label.png'), + closeIconUrl: api.assetUrl('img/ui-20261009/icon-close.png'), + showPrivacy: false, // 隐私授权弹窗 agreed: false, // 隐私协议勾选 - showPolicy: false, busy: false, signedIn: false, showCameraDebug: development.cameraDebugEnabled(), - showMvpDebug: config.mvpLocal === true + showPolicy: false, + busy: false, + signedIn: false, + accountLabel: '尚未登录', // H5 renderAccountStatus() + + toastText: '', toastOn: false, + + showCameraDebug: development.cameraDebugEnabled(), + showMvpDebug: config.mvpLocal === true || (config.transport === 'cloud' && config.environment !== 'production'), + showDiagnose: config.environment !== 'production', + diagText: '' }, onShow: function () { var that = this; - api.restore().then(function (user) { that.setData({ signedIn: !!user }); }).catch(function () {}); + api.restore().then(function (user) { that.setData({ signedIn: !!user, accountLabel: user ? '淘宝已登录' : '尚未登录' }); }) + .catch(function () {}); }, - // 「开始检测」→ 隐私授权弹窗 + // H5 .toast(自定义轻提示,替代系统 toast 以获得一致的视觉) + toast: function (msg) { + var that = this; + this.setData({ toastText: msg, toastOn: true }); + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(function () { + that.setData({ toastOn: false }); + }, Math.max(1800, String(msg).length * 130)); + }, + + // 「开始检测」→ 隐私授权弹窗(H5 data-action="open-privacy") onOpenPrivacy: function () { if (api.user()) { my.navigateTo({ url: '/pages/profile/profile' }); return; } this.setData({ showPrivacy: true }); @@ -26,7 +54,7 @@ Page({ this.setData({ showPrivacy: false }); }, - // 勾选/取消隐私协议(点链接不触发,由 catchtap 分隔) + // 勾选/取消隐私协议(点链接不触发,由 catchTap 分隔) onToggleAgree: function () { this.setData({ agreed: !this.data.agreed }); }, @@ -40,37 +68,52 @@ Page({ // 「继续」:必须已勾选协议 onAgreeContinue: function () { + var that = this; if (this.data.busy) return; if (!this.data.agreed) { - my.showToast({ content: '请先阅读并同意隐私协议', type: 'none' }); + this.toast('请先阅读并同意隐私协议'); return; } - var that = this; this.setData({ busy: true }); api.login(true).then(function () { - that.setData({ showPrivacy: false, signedIn: true }); + that.setData({ showPrivacy: false, signedIn: true, accountLabel: '淘宝已登录' }); my.navigateTo({ url: '/pages/profile/profile' }); }).catch(function (err) { - my.showToast({ content: err.message, type: 'none' }); + that.toast(err.message); }).then(function () { that.setData({ busy: false }); }); }, + // H5 data-action="history" / 「我的报告」 onHistory: function () { if (!api.user()) { this.onOpenPrivacy(); return; } my.navigateTo({ url: '/pages/history/history' }); }, onLogout: function () { var that = this; - api.logout().then(function () { that.setData({ signedIn: false, agreed: false }); }) - .catch(function (err) { my.showToast({ content: err.message, type: 'none' }); }); + api.logout().then(function () { + that.setData({ signedIn: false, agreed: false, accountLabel: '尚未登录' }); + }).catch(function (err) { that.toast(err.message); }); }, onCameraDebug: function () { if (!development.cameraDebugEnabled()) return; my.navigateTo({ url: '/pages/camera/camera?debug=camera' }); }, onMvpDebug: function () { - if (!config.mvpLocal) return; + if (!config.mvpLocal && !(config.transport === 'cloud' && config.environment !== 'production')) return; my.navigateTo({ url: '/pages/mvp/mvp' }); }, + // 相机/相册权限诊断:相机页可能整页崩掉,所以在启动页也能点 + onDiagnose: function () { + var that = this; + this.setData({ diagText: '检测中…' }); + require('../../utils/permissions').diagnose().then(function (info) { + that.setData({ diagText: info.join('\n') }); + }).catch(function (err) { + that.setData({ diagText: 'diagnose error: ' + (err && err.message ? err.message : String(err)) }); + }); + }, + onCloseDiagnose: function () { this.setData({ diagText: '' }); }, + // 相机组件冒烟测试:单独一页只放 ,用来判断淘宝真机是否支持该组件 + onCamTest: function () { my.navigateTo({ url: '/pages/camtest/camtest' }); }, noop: function () {} // 弹窗卡片内点击拦截冒泡用 }); diff --git a/taobao/test/frontend.test.cjs b/taobao/test/frontend.test.cjs index bf76353..2292cac 100644 --- a/taobao/test/frontend.test.cjs +++ b/taobao/test/frontend.test.cjs @@ -30,7 +30,7 @@ test('offline camera debug skips identity/profile, never uploads or plays remote '../../utils/development': { cameraDebugEnabled: () => true }, '../../utils/api': { user() { authReads++; return null; }, analyze() { backendCalls++; throw Error('debug must not upload'); } }, '../../utils/voice': voice, - '../../utils/permissions': { settings: async () => ({}), state: () => undefined, ensure: async () => {} }, + '../../utils/permissions': { settings: async () => ({}), state: () => undefined, ensure: async () => {}, cameraSupported: () => true }, '../../utils/image': { compressToBase64: async (photo, p) => { p.setData({ compressWidth: 1280, compressHeight: 960 }); return 'data:image/jpeg;base64,/9j/'; } } @@ -89,7 +89,8 @@ test('camera blocks duplicate shots/confirm, cancels late callbacks and submits const api = { user: () => ({ id: 'a' }), analyze: (photos) => { submitted++; ordered = photos; return new Promise(resolve => { resolver = resolve; }); } }; const voice = { play() {}, stop() {}, VOICE: { step: [], shot: '', analyzing: '', fail: '' } }; const camera = page('pages/camera/camera.js', { '../../utils/api': api, '../../utils/voice': voice, - '../../utils/permissions': { settings: async () => ({ 'scope.camera': true }), state: (s, key) => s[key], ensure: async () => {} }, + '../../config': { environment: 'development', nativeCamera: true }, + '../../utils/permissions': { settings: async () => ({ 'scope.camera': true }), state: (s, key) => s[key], ensure: async () => {}, cameraSupported: () => true }, '../../utils/image': { compressToBase64: async src => src + '-base64' } }, { getApp: () => app, my: { createCameraContext(id) { assert.equal(id, 'skin-camera'); return { takePhoto(opts) { captured = opts; calls++; } }; }, showToast() {}, redirectTo() { navigated++; } } }); diff --git a/taobao/test/permissions.test.cjs b/taobao/test/permissions.test.cjs index da0ebdc..5a49e83 100644 --- a/taobao/test/permissions.test.cjs +++ b/taobao/test/permissions.test.cjs @@ -5,26 +5,65 @@ function setup(my) { vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/permissions.js'), 'utf8'), { module, Promise, Error, my }); return module.exports; } -test('camera and album authorization are separate from login; deny never loops and only explicit settings can restore', async () => { +// 2026-11-01 真机实测(SDK 2.9.4):淘宝用带前缀的 scope.camera(裸名 camera 返回 5022 "Scope:camera信息不存在")。 +// 所以先试 scope.camera,失败再退到裸名,两者都失败才拒绝,且拒绝后不再重复弹授权。 +test('deny never loops: tries prefixed then bare once, then requires explicit settings', async () => { let granted = { 'scope.userInfo': true }, calls = [], openCalls = 0; const permissions = setup({ getSetting({ success }) { success({ authSetting: granted }); }, - authorize({ scopes, success, fail }) { calls.push(scopes); if (scopes === 'scope.camera') fail(); else success(); }, + authorize({ scopes, success, fail }) { calls.push(scopes); fail({ error: 11, errorMessage: 'no such scope' }); }, openSetting({ success }) { openCalls++; granted['scope.camera'] = true; success({ authSetting: granted }); } }); await assert.rejects(permissions.ensure('scope.camera'), e => e.permission === 'scope.camera'); await assert.rejects(permissions.ensure('scope.camera'), e => e.permission === 'scope.camera'); - assert.deepEqual(calls, ['scope.camera']); assert.equal(openCalls, 0); - await permissions.ensure('scope.album'); assert.deepEqual(calls, ['scope.camera', 'scope.album']); + assert.deepEqual(calls, ['scope.camera', 'camera'], '第二次不再尝试授权'); + assert.equal(openCalls, 0); await permissions.open(); await permissions.ensure('scope.camera'); - assert.equal(calls.length, 2); assert.equal(openCalls, 1); + assert.equal(calls.length, 2, '授权设置里开启后不再调用 authorize'); + assert.equal(openCalls, 1); }); -test('existing denial does not reauthorize; short-form settings keys work and unknown scope requests on demand', async () => { + +test('prefixed scope is used first and succeeds without falling back', async () => { + let calls = []; + const permissions = setup({ + getSetting({ success }) { success({ authSetting: {} }); }, + authorize({ scopes, success }) { calls.push(scopes); success(); } + }); + await permissions.ensure('scope.camera'); + assert.deepEqual(calls, ['scope.camera']); + await permissions.ensure('scope.album'); + assert.deepEqual(calls, ['scope.camera', 'scope.album']); +}); + +test('existing denial does not reauthorize; short-form settings keys work', async () => { let calls = 0; - const permissions = setup({ getSetting({ success }) { success({ authSetting: { camera: false, album: true } }); }, - authorize() { calls++; } }); - await assert.rejects(permissions.ensure('scope.camera')); await permissions.ensure('scope.album'); assert.equal(calls, 0); - const first = setup({ getSetting({ success }) { success({ authSetting: {} }); }, - authorize({ scopes, success }) { assert.equal(scopes, 'scope.camera'); calls++; success(); } }); - await first.ensure('scope.camera'); assert.equal(calls, 1); + const permissions = setup({ + getSetting({ success }) { success({ authSetting: { camera: false, album: true } }); }, + authorize() { calls++; } + }); + await assert.rejects(permissions.ensure('scope.camera')); + await permissions.ensure('scope.album'); + assert.equal(calls, 0); +}); + +test('diagnose reports capability and authorization outcomes', async () => { + const permissions = setup({ + SDKVersion: '2.9.4', + canIUse() { return true; }, + getSetting({ success }) { success({ authSetting: { 'scope.camera': true } }); }, + authorize({ scopes, success, fail }) { scopes === 'scope.camera' ? success() : fail({ errorCode: '5022' }); }, + createCameraContext() {}, chooseImage() {}, getImageInfo() {}, createCanvasContext() {}, openSetting() {} + }); + const info = await permissions.diagnose(); + const text = info.join('\n'); + assert.match(text, /SDKVersion=2\.9\.4/); + assert.match(text, /createCameraContext: typeof=function canIUse=yes/); + assert.match(text, /authSetting=\{"scope\.camera":true\}/); + assert.match(text, /authorize\(scope\.camera\)=ok/); +}); + +test('cameraSupported requires the native camera API and canIUse', () => { + assert.equal(setup({ canIUse: () => true }).cameraSupported(), false, '没有 createCameraContext 就不支持'); + assert.equal(setup({ canIUse: () => true, createCameraContext() {} }).cameraSupported(), true); + assert.equal(setup({ canIUse: (api) => api !== 'camera', createCameraContext() {} }).cameraSupported(), false); }); diff --git a/taobao/test/transport.test.cjs b/taobao/test/transport.test.cjs index 24ce6d0..ef1c42d 100644 --- a/taobao/test/transport.test.cjs +++ b/taobao/test/transport.test.cjs @@ -19,3 +19,19 @@ test('cloud transport initializes once, preserves subpath/auth, caps timeout and config.transport = 'direct'; await assert.rejects(transport.request('/api/taobao/me', {}), /仅用于本机/); }); +test('聚石塔 tower transport omits domain and does not require HTTPS', async () => { + const config = { transport: 'cloud', cloudType: 'tower', baseUrl: 'http://127.0.0.1:8080', cloudAppId: '', cloudEnv: 'test', basePath: '' }; + let options; + const cloud = { init: async ({ env }) => { assert.equal(env, 'test'); return true; }, + application: { httpRequest: async input => { options = input; return { success: true, data: { ok: true } }; } } }; + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/transport.js'), 'utf8'), { + module, Promise, require(name) { return name === '../config' ? config : { default: cloud }; } + }); + const transport = module.exports; + const data = await transport.request('/api/taobao/ping', {}); + assert.equal(data.ok, true); + assert.equal(options.path, '/api/taobao/ping'); + assert.equal(options.exts.domain, undefined); + assert.equal(options.exts.cloudAppId, undefined); +}); diff --git a/taobao/utils/permissions.js b/taobao/utils/permissions.js index 185b165..59616bd 100644 --- a/taobao/utils/permissions.js +++ b/taobao/utils/permissions.js @@ -1,29 +1,74 @@ // Taobao scopes are independent: userInfo login does not grant camera/album. +// +// 平台差异(2026-11-01 真机实测,SDKVersion=2.9.4): +// my.getSetting 返回 {"scope.userInfo":true,"scope.camera":true} +// my.authorize({scopes:'camera'}) → 失败 {"errorCode":"5022","errorMessage":"Scope:camera信息不存在"} +// my.authorize({scopes:'scope.camera'}) → 成功 +// 结论:淘宝用带前缀的 scope.camera / scope.album(和微信一致),裸名不存在。 +// 这里优先用带前缀的写法,失败再退回裸名(兼容其它淘宝/SDK 版本)。 var denied = {}; +var lastError = null; + +function bare(scope) { return scope.replace(/^scope\./, ''); } +function prefixed(scope) { return /^scope\./.test(scope) ? scope : 'scope.' + scope; } + function state(settings, scope) { settings = settings || {}; - var key = scope.replace(/^scope\./, ''); + var key = bare(scope); return typeof settings[scope] === 'boolean' ? settings[scope] : settings[key]; } function settings() { return new Promise(function (resolve) { if (typeof my.getSetting !== 'function') { resolve({}); return; } - my.getSetting({ success: function (res) { resolve(res.authSetting || {}); }, fail: function () { resolve({}); } }); + try { + my.getSetting({ + success: function (res) { resolve((res && res.authSetting) || {}); }, + fail: function () { resolve({}); } + }); + } catch (e) { resolve({}); } // API 不存在/同步抛错:当作空设置,走后续兜底 }); } + +// 平台是否具备内置相机能力(淘宝部分版本没有 / createCameraContext) +function cameraSupported() { + try { + if (typeof my.createCameraContext !== 'function') return false; + if (typeof my.canIUse === 'function') return !!my.canIUse('camera') && !!my.canIUse('createCameraContext'); + return true; + } catch (e) { return false; } +} +function describe(err) { + if (err === undefined || err === null) return 'null'; + try { return JSON.stringify(err).slice(0, 200); } catch (e) { return String(err).slice(0, 200); } +} function rejected(scope) { - var err = new Error(scope === 'scope.camera' ? '相机权限未开启,可在授权设置中开启,或使用相册' : '相册权限未开启,请在授权设置中开启'); - err.permission = scope; return err; + var err = new Error(scope === 'scope.camera' + ? '相机权限未开启:请点「授权设置」开启,或直接用系统相机/相册' + : '相册权限未开启,请在授权设置中开启'); + err.permission = scope; err.detail = lastError; + return err; +} +// 授权:先按支付宝/淘宝的裸名字(camera),失败且不是"用户拒绝"时再试微信式(scope.camera) +function authorizeOnce(scopes) { + return new Promise(function (resolve, reject) { + my.authorize({ scopes: scopes, + success: function (res) { resolve({ scopes: scopes, ok: true, res: res }); }, + fail: function (err) { reject({ scopes: scopes, ok: false, err: err }); } }); + }); } function ensure(scope) { return settings().then(function (current) { if (state(current, scope) === true) { delete denied[scope]; return; } if (state(current, scope) === false || denied[scope]) throw rejected(scope); - return new Promise(function (resolve, reject) { - if (typeof my.authorize !== 'function') { reject(new Error('当前淘宝版本不支持授权,请升级后重试')); return; } - my.authorize({ scopes: scope, success: function () { delete denied[scope]; resolve(); }, - fail: function () { denied[scope] = true; reject(rejected(scope)); } }); - }); + if (typeof my.authorize !== 'function') throw new Error('当前淘宝版本不支持授权,请升级后重试'); + return authorizeOnce(prefixed(scope)).catch(function (first) { + lastError = describe(first.err); + return authorizeOnce(bare(scope)).catch(function (second) { + lastError = describe(first.err) + ' / ' + describe(second.err); + denied[scope] = true; + throw rejected(scope); + }); + }).then(function () { delete denied[scope]; }); }); } function open() { @@ -36,4 +81,30 @@ function open() { }, fail: function () { reject(new Error('无法打开授权设置,请从小程序右上角进入')); } }); }); } -module.exports = { ensure: ensure, settings: settings, state: state, open: open }; + +// 权限诊断:把真机上的真实能力/返回值打出来,供页面展示与截图 +function diagnose() { + var info = []; + try { info.push('SDKVersion=' + (my.SDKVersion || '(none)')); } catch (e) { info.push('SDKVersion=error'); } + ['camera', 'createCameraContext', 'chooseImage', 'authorize', 'getSetting', 'openSetting', 'getImageInfo', 'createCanvasContext'].forEach(function (api) { + var use = 'n/a'; + try { use = typeof my.canIUse === 'function' ? (my.canIUse(api) ? 'yes' : 'no') : 'no-canIUse'; } catch (e) { use = 'canIUse-err'; } + info.push(api + ': typeof=' + (typeof my[api]) + ' canIUse=' + use); + }); + return settings().then(function (current) { + info.push('authSetting=' + describe(current)); + if (typeof my.authorize !== 'function') { info.push('authorize: 无此 API'); return info; } + return authorizeOnce(prefixed('camera')).then(function (r) { + info.push('authorize(scope.camera)=ok ' + describe(r.res)); + }, function (r) { + info.push('authorize(scope.camera)=fail ' + describe(r.err)); + return authorizeOnce(bare('camera')).then(function (r2) { + info.push('authorize(camera)=ok ' + describe(r2.res)); + }, function (r2) { + info.push('authorize(camera)=fail ' + describe(r2.err)); + }); + }).then(function () { return info; }); + }); +} + +module.exports = { ensure: ensure, settings: settings, state: state, open: open, diagnose: diagnose, bare: bare, cameraSupported: cameraSupported }; diff --git a/taobao/utils/transport.js b/taobao/utils/transport.js index 6d1e3b1..bf49ed6 100644 --- a/taobao/utils/transport.js +++ b/taobao/utils/transport.js @@ -35,10 +35,16 @@ function request(path, body, token) { }, fail: function () { reject(new Error('网络异常,请稍后重试')); } }); }); } - if (!config.cloudAppId || !/^https:\/\//.test(config.baseUrl)) return Promise.reject(new Error('请先配置淘宝云应用和 HTTPS 服务地址')); + var cloudType = config.cloudType || 'empty'; + if (cloudType === 'empty' && (!config.cloudAppId || !/^https:\/\//.test(config.baseUrl))) { + return Promise.reject(new Error('请先配置淘宝云应用和 HTTPS 服务地址')); + } return initCloud().then(function (cloud) { + var exts = { timeout: 10000 }; + if (config.cloudAppId) exts.cloudAppId = config.cloudAppId; + if (cloudType === 'empty') exts.domain = config.baseUrl; return cloud.application.httpRequest({ path: config.basePath + path, method: 'POST', headers: headers, - params: {}, body: body, exts: { cloudAppId: config.cloudAppId, domain: config.baseUrl, timeout: 10000 } }); + params: {}, body: body, exts: exts }); }).then(unwrap); } module.exports = { request: request, unwrap: unwrap }; diff --git a/taobao/utils/voice.js b/taobao/utils/voice.js index beca0e8..61a9fbc 100644 --- a/taobao/utils/voice.js +++ b/taobao/utils/voice.js @@ -47,9 +47,17 @@ function start(id, kind) { lastAt[id] = Date.now(); cur = { id: id, kind: kind, token: {} }; var token = cur.token; - audio.stop(); - audio.src = api.assetUrl('voice/' + id + '.mp3'); - audio.play(); + try { + audio.stop(); + audio.src = api.assetUrl('voice/' + id + '.mp3'); + audio.play(); + } catch (_) { + // 资源域名不可用、平台拒绝非 HTTPS src 等情况:静默跳过。 + // 语音只是辅助,绝不能让异常冒泡成页面崩溃(真机曾整页白屏)。 + cur = null; + drain(); + return; + } // 播放失败/结束时 cur 清理放在 drain 前,避免排队卡死 cur.token = token; }