feat(taobao): add native mini program with shared backend
Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# 巨子测肤 · H5 与淘宝小程序
|
||||||
|
|
||||||
|
本仓库包含两个入口,共用 Node 后端、Face++ 检测、评分与报告话术。
|
||||||
|
|
||||||
|
- `h5/`:现有浏览器/微信公众号 H5、管理后台,以及共享后端 `server.js`。
|
||||||
|
- `taobao/`:淘宝原生小程序。开发、联调、测试说明见 [taobao/README.md](taobao/README.md)。
|
||||||
|
- `h5/js/skin-score.js`、`report-ui.js`、`report-copy.js`:业务规则的源文件。小程序内的副本由同步脚本生成并检查哈希。
|
||||||
|
- `h5/lib/taobao-*.js`:淘宝网关验签、短期上传/异步任务和独立 API 路由。
|
||||||
|
- `docs/`、`reference/`、`design/`、`feedback/`、`new_feedback/`:需求、接口资料、设计与历轮反馈;早期文档的规则可能已被后续实现替代。
|
||||||
|
- `backend/docs/`:后台需求资料;可运行的共享服务实际在 `h5/server.js`。
|
||||||
|
- `temp/`:本地测试数据、预览和截图,已忽略,不提交。
|
||||||
|
|
||||||
|
兼容边界:允许共享后端扩展,但保持 H5 的页面、微信公众号链接、微信登录、游客不留存和历史报告行为。淘宝使用独立账号来源,不把淘宝账号与微信账号自动合并。
|
||||||
|
|
||||||
|
本轮工作只在本地开发和样本验证,未部署或修改线上服务器。启动本地隔离环境:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd taobao
|
||||||
|
npm ci
|
||||||
|
npm run dev:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
H5 本地入口为 http://127.0.0.1:18180;这个环境固定使用测试样本,不调用真实 Face++。生产 H5 的运行说明仍见 [h5/README.md](h5/README.md)。
|
||||||
+33
-3
@@ -177,7 +177,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
|||||||
}
|
}
|
||||||
function storeReport(ownerId, snapshot) {
|
function storeReport(ownerId, snapshot) {
|
||||||
const user = db.users.find(u => u.id === ownerId);
|
const user = db.users.find(u => u.id === ownerId);
|
||||||
if (!user || user.kind !== 'wechat' || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
|
if (!user || !['wechat', 'taobao'].includes(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
|
||||||
const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
atomic(path.join(dir, snapshot.id + '.json'), snapshot);
|
atomic(path.join(dir, snapshot.id + '.json'), snapshot);
|
||||||
user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }]
|
user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }]
|
||||||
@@ -193,7 +193,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
|||||||
catch (_) { return null; }
|
catch (_) { return null; }
|
||||||
}
|
}
|
||||||
function reportSnapshot(ownerId,id) {
|
function reportSnapshot(ownerId,id) {
|
||||||
if (!db.users.some(u=>u.id===ownerId&&u.kind==='wechat') || !/^R[\w-]+$/.test(id)) return null;
|
if (!db.users.some(u=>u.id===ownerId&&['wechat','taobao'].includes(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
|
||||||
try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;}
|
try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;}
|
||||||
}
|
}
|
||||||
function updateWrinkleViews(ownerId,id,views) {
|
function updateWrinkleViews(ownerId,id,views) {
|
||||||
@@ -203,5 +203,35 @@ module.exports = function createAccounts(root, env = process.env) {
|
|||||||
if (snapshot.payload.wrinkle_job) snapshot.payload.wrinkle_job.status='complete';
|
if (snapshot.payload.wrinkle_job) snapshot.payload.wrinkle_job.status='complete';
|
||||||
atomic(path.join(snapshots,ownerId,id+'.json'),snapshot);
|
atomic(path.join(snapshots,ownerId,id+'.json'),snapshot);
|
||||||
}
|
}
|
||||||
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available };
|
function loginTaobao(providerKey) {
|
||||||
|
let user = db.users.find(u => u.kind === 'taobao' && u.provider_key === providerKey);
|
||||||
|
if (!user) { user = { id: crypto.randomUUID(), kind: 'taobao', provider_key: providerKey, reports: [] }; db.users.push(user); }
|
||||||
|
const token = random(), expires = Date.now() + 30 * 86400000;
|
||||||
|
for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key];
|
||||||
|
db.sessions[hash(token)] = { user: user.id, expires };
|
||||||
|
save();
|
||||||
|
return { token, expires_in: 30 * 86400, user: publicUser(user) };
|
||||||
|
}
|
||||||
|
function taobaoSession(req) {
|
||||||
|
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
|
||||||
|
const row = match && db.sessions[hash(match[1])];
|
||||||
|
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null;
|
||||||
|
}
|
||||||
|
function taobaoLogout(req) {
|
||||||
|
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
|
||||||
|
if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); }
|
||||||
|
}
|
||||||
|
function reports(owner) {
|
||||||
|
return db.users.find(u => u.id === owner && u.kind === 'taobao')?.reports || [];
|
||||||
|
}
|
||||||
|
function ownedReport(owner, id) {
|
||||||
|
if (!reports(owner).some(r => r.id === id)) return null;
|
||||||
|
const snapshot = reportSnapshot(owner, id);
|
||||||
|
if (!snapshot) return null;
|
||||||
|
snapshot.vm = SkinScore.withDisplayRecommendation(snapshot.vm, snapshot.payload && snapshot.payload.result);
|
||||||
|
snapshot.presentation = ReportUI.forDisplay(snapshot.vm, snapshot.payload && snapshot.payload.result, snapshot.presentation);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available,
|
||||||
|
loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use strict';
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
// Cloud gateway query parameters + the exact, unread JSON body. Algorithm:
|
||||||
|
// https://developer.alibaba.com/docs/doc.htm?articleId=121746&docType=1&treeId=832
|
||||||
|
function signature(params, body, secret) {
|
||||||
|
const method = params.sign_method || '';
|
||||||
|
if (!['', 'md5', 'hmac', 'hmac_md5', 'hmac_sha256'].includes(method)) return null;
|
||||||
|
const content = Object.keys(params).filter(key => key !== 'sign' && params[key] !== '').sort()
|
||||||
|
.map(key => key + params[key]).join('') + body;
|
||||||
|
if (method === 'hmac' || method === 'hmac_md5' || method === 'hmac_sha256') {
|
||||||
|
return crypto.createHmac(method === 'hmac_sha256' ? 'sha256' : 'md5', secret).update(content).digest('hex').toUpperCase();
|
||||||
|
}
|
||||||
|
return crypto.createHash('md5').update((method === 'md5' ? secret : '') + content + secret).digest('hex').toUpperCase();
|
||||||
|
}
|
||||||
|
function verify(url, body, env = process.env) {
|
||||||
|
if (!env.TAOBAO_APP_KEY || !env.TAOBAO_APP_SECRET) return null;
|
||||||
|
const params = Object.create(null);
|
||||||
|
for (const [key, value] of new URL(url, 'http://localhost').searchParams) {
|
||||||
|
if (key in params) return null; // Never ambiguously verify duplicate parameters.
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Za-z0-9_-]{4,256}$/.test(params.open_id || '') ||
|
||||||
|
!/^[A-Fa-f0-9]{32,64}$/.test(params.sign || '')) return null;
|
||||||
|
const expected = signature(params, body, env.TAOBAO_APP_SECRET);
|
||||||
|
if (!expected || expected.length !== params.sign.length ||
|
||||||
|
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(params.sign.toUpperCase()))) return null;
|
||||||
|
// Login challenge is single-use and signed inside the body, preventing replay.
|
||||||
|
return { providerKey: crypto.createHash('sha256').update(params.app_key + ':' + params.open_id).digest('hex') };
|
||||||
|
}
|
||||||
|
module.exports = { signature, verify };
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use strict';
|
||||||
|
const TaobaoAuth = require('./taobao-auth');
|
||||||
|
module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAnalyze }) {
|
||||||
|
const taobaoSessions = require('./taobao-session')({ session: accounts.taobaoSession });
|
||||||
|
return async function (req, res, pathname) {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
// The cloud gateway requires its own envelope. H5 responses stay unchanged.
|
||||||
|
const reply = (status, data) => sendJson(res, 200, { success: true, data: { ...data, http_status: status } });
|
||||||
|
if (req.method !== 'POST') return reply(405, { ok: false, message: '请求方法不支持' });
|
||||||
|
if (!accounts.sameOrigin(req)) return reply(403, { ok: false, message: '请求来源不匹配' });
|
||||||
|
try {
|
||||||
|
const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8');
|
||||||
|
const body = JSON.parse(raw);
|
||||||
|
if (pathname === '/api/taobao/challenge') {
|
||||||
|
const challenge = taobaoSessions.challenge(req.socket.remoteAddress);
|
||||||
|
return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/taobao/session') {
|
||||||
|
if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' });
|
||||||
|
const identity = TaobaoAuth.verify(req.url, raw);
|
||||||
|
if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' });
|
||||||
|
return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) });
|
||||||
|
}
|
||||||
|
const owner = accounts.taobaoSession(req);
|
||||||
|
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新登录' });
|
||||||
|
if (pathname === '/api/taobao/me') return reply(200, { ok: true, user: { id: owner.id, kind: owner.kind } });
|
||||||
|
if (pathname === '/api/taobao/photo') {
|
||||||
|
const { status, ...data } = taobaoSessions.upload(req, body);
|
||||||
|
return reply(status, { ok: status === 200, ...data });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/taobao/skin-analyze') {
|
||||||
|
const photos = taobaoSessions.takePhotos(req, body.photo_refs);
|
||||||
|
if (!photos) return reply(409, { ok: false, message: '照片上传未完成或检测进行中,请重试' });
|
||||||
|
// Start once and poll: cloud gateway requests have a maximum 10s timeout.
|
||||||
|
const job = taobaoSessions.start(req, () => new Promise(resolve => {
|
||||||
|
const response = { setHeader() {}, writeHead() {}, end(text, cb) { resolve(JSON.parse(text)); if (cb) cb(); } };
|
||||||
|
handleAnalyze(req, response, true, { ...body, ...photos }, owner).catch(() => resolve({ ok: false, message: '检测服务暂不可用,请重试' }));
|
||||||
|
}), true);
|
||||||
|
if (!job) { taobaoSessions.finish(req); return reply(429, { ok: false, message: '服务繁忙,请稍后再试' }); }
|
||||||
|
return reply(202, { ok: true, job });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/taobao/analysis') {
|
||||||
|
const result = taobaoSessions.result(req, body.job);
|
||||||
|
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/taobao/reports') {
|
||||||
|
if (!body.id) return reply(200, { ok: true, total: accounts.reports(owner.id).length, items: accounts.reports(owner.id) });
|
||||||
|
const report = accounts.ownedReport(owner.id, body.id);
|
||||||
|
return reply(report ? 200 : 404, report ? { ok: true, report } : { ok: false, message: '报告不存在' });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/taobao/logout') {
|
||||||
|
taobaoSessions.revoke(req);
|
||||||
|
accounts.taobaoLogout(req);
|
||||||
|
return reply(200, { ok: true });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/taobao/wrinkle-views') {
|
||||||
|
const job = taobaoSessions.start(req, async () => {
|
||||||
|
const views = await wrinkleJobs.run(owner, body.token);
|
||||||
|
return views ? { ok: true, wrinkle_views: views } : { ok: false, message: '标注任务已失效' };
|
||||||
|
});
|
||||||
|
return reply(job ? 202 : 429, job ? { ok: true, job } : { ok: false, message: '服务繁忙,请稍后再试' });
|
||||||
|
}
|
||||||
|
return reply(404, { ok: false, message: '接口不存在' });
|
||||||
|
} catch (e) {
|
||||||
|
const status = e.message === 'TOO_LARGE' ? 413 : e instanceof SyntaxError ? 400 : 500;
|
||||||
|
return reply(status, { ok: false, message: status === 413 ? '请求数据过大' : status === 400 ? '请求数据异常' : '服务暂不可用,请稍后再试' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use strict';
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
// Short-lived transfers/jobs/challenges. Identity/history live in accounts.js.
|
||||||
|
module.exports = function ({ session, now = Date.now } = {}) {
|
||||||
|
const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map();
|
||||||
|
const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024;
|
||||||
|
let bytes = 0;
|
||||||
|
function clearPhotos(row) {
|
||||||
|
for (const photo of Object.values(row.photos)) bytes -= photo.bytes;
|
||||||
|
row.photos = {};
|
||||||
|
}
|
||||||
|
function sweep() {
|
||||||
|
for (const [id, row] of transfers) if (row.expires <= now() && !row.active) { clearPhotos(row); transfers.delete(id); }
|
||||||
|
for (const [id, until] of challenges) if (until <= now()) challenges.delete(id);
|
||||||
|
for (const [id, row] of rates) if (row.until <= now()) rates.delete(id);
|
||||||
|
for (const [id, job] of jobs) if (job.expires <= now() && job.status !== 'running') jobs.delete(id);
|
||||||
|
}
|
||||||
|
function challenge(address) {
|
||||||
|
sweep();
|
||||||
|
const rate = rates.get(address) || { count: 0, until: now() + 60000 };
|
||||||
|
if (rates.size >= 10000 && !rates.has(address)) return null;
|
||||||
|
rates.set(address, rate);
|
||||||
|
if (++rate.count > 30 || challenges.size >= 1000) return null;
|
||||||
|
const id = crypto.randomBytes(24).toString('hex');
|
||||||
|
challenges.set(id, now() + 60000);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function consume(id) {
|
||||||
|
sweep();
|
||||||
|
if (!challenges.has(id)) return false;
|
||||||
|
challenges.delete(id); return true;
|
||||||
|
}
|
||||||
|
function row(req, create = false) {
|
||||||
|
sweep();
|
||||||
|
const owner = session(req);
|
||||||
|
if (!owner) return null;
|
||||||
|
if (!transfers.has(owner.id) && create && transfers.size < 1000) transfers.set(owner.id, { photos: {}, active: false, expires: now() + lifetime });
|
||||||
|
return transfers.get(owner.id) || null;
|
||||||
|
}
|
||||||
|
function upload(req, body) {
|
||||||
|
const current = row(req, true);
|
||||||
|
if (!current) return { status: 401, message: '检测会话已失效,请重新开始' };
|
||||||
|
if (current.active) return { status: 409, message: '检测进行中,请稍候' };
|
||||||
|
const { view, upload_id: id, index, total, data } = body || {};
|
||||||
|
if (!['front', 'left', 'right'].includes(view) || !/^[a-z0-9-]{8,64}$/.test(id || '') ||
|
||||||
|
!Number.isInteger(total) || total < 1 || total > 64 || !Number.isInteger(index) || index < 0 || index >= total ||
|
||||||
|
typeof data !== 'string' || !data.length || data.length > maxChunk || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
||||||
|
return { status: 400, message: '照片分片数据异常,请重新拍摄' };
|
||||||
|
}
|
||||||
|
let photo = current.photos[view];
|
||||||
|
if (!photo || photo.id !== id) {
|
||||||
|
if (index !== 0) return { status: 409, message: '照片上传已失效,请重试' };
|
||||||
|
if (photo) bytes -= photo.bytes;
|
||||||
|
photo = current.photos[view] = { id, total, chunks: [], bytes: 0 };
|
||||||
|
}
|
||||||
|
if (photo.total !== total || index > photo.chunks.length || (index < photo.chunks.length && photo.chunks[index] !== data)) {
|
||||||
|
return { status: 409, message: '照片上传顺序异常,请重试' };
|
||||||
|
}
|
||||||
|
if (index === photo.chunks.length) {
|
||||||
|
if (photo.bytes + data.length > maxPhoto || bytes + data.length > 64 * 1024 * 1024) return { status: 413, message: '照片过大或服务繁忙,请稍后重试' };
|
||||||
|
photo.chunks.push(data); photo.bytes += data.length; bytes += data.length;
|
||||||
|
}
|
||||||
|
current.expires = now() + lifetime;
|
||||||
|
return { status: 200, complete: photo.chunks.length === total, photo_ref: id };
|
||||||
|
}
|
||||||
|
function takePhotos(req, refs) {
|
||||||
|
const current = row(req);
|
||||||
|
if (!current || current.active) return null;
|
||||||
|
if (!refs || !['front', 'left', 'right'].every(view => {
|
||||||
|
const photo = current.photos[view];
|
||||||
|
return photo && photo.id === refs[view] && photo.chunks.length === photo.total;
|
||||||
|
})) return null;
|
||||||
|
const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
|
||||||
|
clearPhotos(current); current.active = true;
|
||||||
|
return photos;
|
||||||
|
}
|
||||||
|
function finish(req, ownerId) { const current = ownerId ? transfers.get(ownerId) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } }
|
||||||
|
function revoke(req) { const current = row(req); if (current) { clearPhotos(current); current.expires = 0; sweep(); } }
|
||||||
|
function start(req, run, releasePhotos = false) {
|
||||||
|
sweep();
|
||||||
|
const owner = session(req);
|
||||||
|
if (!owner || jobs.size >= 1000) return null;
|
||||||
|
const id = crypto.randomBytes(24).toString('hex');
|
||||||
|
const job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null };
|
||||||
|
jobs.set(id, job);
|
||||||
|
Promise.resolve().then(run).then(result => { job.result = result; }, () => { job.result = { ok: false, message: '检测服务暂不可用,请重试' }; })
|
||||||
|
.finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, owner.id); });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function result(req, id) {
|
||||||
|
sweep();
|
||||||
|
const job = jobs.get(id), owner = session(req);
|
||||||
|
return job && owner && job.owner === owner.id ? { status: job.status, payload: job.result } : null;
|
||||||
|
}
|
||||||
|
const timer = setInterval(sweep, 60000); timer.unref();
|
||||||
|
return { challenge, consume, upload, takePhotos, finish, revoke, start, result };
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
||||||
// Lazy, owner-bound, single-flight tasks. Only WeChat jobs persist. Guest photos
|
// Lazy, owner-bound, single-flight tasks. Account jobs persist. Guest photos
|
||||||
// remain in RAM for at most five minutes; no paid work starts until requested.
|
// remain in RAM for at most five minutes; no paid work starts until requested.
|
||||||
module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
||||||
const jobs = new Map();
|
const jobs = new Map();
|
||||||
@@ -16,7 +16,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
|||||||
fs.renameSync(tmp,file);
|
fs.renameSync(tmp,file);
|
||||||
}
|
}
|
||||||
function register(owner,record,context) {
|
function register(owner,record,context) {
|
||||||
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:owner.kind==='wechat',status:'pending',context};
|
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao'].includes(owner.kind),status:'pending',context};
|
||||||
save(job);remember(job);
|
save(job);remember(job);
|
||||||
return {token:job.token,status:'pending'};
|
return {token:job.token,status:'pending'};
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
|||||||
let job=jobs.get(token);
|
let job=jobs.get(token);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;}
|
try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;}
|
||||||
if (job.owner!==owner.id || owner.kind!=='wechat') return null;
|
if (job.owner!==owner.id || !['wechat','taobao'].includes(owner.kind)) return null;
|
||||||
remember(job);
|
remember(job);
|
||||||
}
|
}
|
||||||
if (job.owner!==owner.id) return null;
|
if (job.owner!==owner.id) return null;
|
||||||
|
|||||||
+35
-11
@@ -60,6 +60,7 @@ const wrinkleJobs = require('./lib/wrinkle-jobs.js')({
|
|||||||
|
|
||||||
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
|
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
|
||||||
const SkinScore = require('./js/skin-score.js');
|
const SkinScore = require('./js/skin-score.js');
|
||||||
|
const handleTaobao = require('./lib/taobao-routes.js')({ accounts, wrinkleJobs, readBody, sendJson, handleAnalyze });
|
||||||
|
|
||||||
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
|
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
|
||||||
function loadCredentials() {
|
function loadCredentials() {
|
||||||
@@ -421,16 +422,18 @@ async function callFacepp(front, left, right) {
|
|||||||
return fetchErr ? { error_message: 'NETWORK_ERROR' } : fpJson;
|
return fetchErr ? { error_message: 'NETWORK_ERROR' } : fpJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAnalyze(req, res) {
|
async function handleAnalyze(req, res, miniProgram = false, preparedBody = null, preparedOwner = null) {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
|
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
|
||||||
let persist = false;
|
let persist = false;
|
||||||
try {
|
try {
|
||||||
const owner = accounts.session(req);
|
const owner = miniProgram ? preparedOwner : accounts.session(req);
|
||||||
if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' });
|
if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' });
|
||||||
persist = owner.kind === 'wechat';
|
|
||||||
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
|
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
|
||||||
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
|
const body = preparedBody || JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
|
||||||
|
// A client-supplied channel must never change H5 guest retention behavior.
|
||||||
|
const channel = miniProgram ? 'taobao' : 'h5';
|
||||||
|
persist = ['wechat', 'taobao'].includes(owner.kind);
|
||||||
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
|
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
|
||||||
const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : '');
|
const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : '');
|
||||||
const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : '');
|
const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : '');
|
||||||
@@ -457,6 +460,7 @@ async function handleAnalyze(req, res) {
|
|||||||
gender: profile.gender,
|
gender: profile.gender,
|
||||||
region: profile.region,
|
region: profile.region,
|
||||||
daily_skin_type: profile.daily_skin_type,
|
daily_skin_type: profile.daily_skin_type,
|
||||||
|
channel,
|
||||||
img_front: photos.front || null,
|
img_front: photos.front || null,
|
||||||
img_left: photos.left || null,
|
img_left: photos.left || null,
|
||||||
img_right: photos.right || null,
|
img_right: photos.right || null,
|
||||||
@@ -544,7 +548,7 @@ async function handleAnalyze(req, res) {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (rec && persist) { // 仅微信检测失败记录落库;游客不保留异常记录。
|
if (rec && persist) { // H5 游客不保留异常记录;已登录账号保存检测记录。
|
||||||
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
|
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
|
||||||
rec.updated_at = nowIso();
|
rec.updated_at = nowIso();
|
||||||
updateRecord(rec);
|
updateRecord(rec);
|
||||||
@@ -592,6 +596,7 @@ function listItem(rec) {
|
|||||||
record_id: rec.record_id,
|
record_id: rec.record_id,
|
||||||
created_at: rec.created_at,
|
created_at: rec.created_at,
|
||||||
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
|
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
|
||||||
|
channel: rec.channel || 'h5',
|
||||||
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
|
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
|
||||||
capture_quality: rec.capture_quality || null,
|
capture_quality: rec.capture_quality || null,
|
||||||
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
|
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
|
||||||
@@ -620,6 +625,7 @@ function matchQuery(rec, q) {
|
|||||||
if (!Number.isFinite(timestamp) || timestamp < q.timeRange.start || timestamp >= q.timeRange.end) return false;
|
if (!Number.isFinite(timestamp) || timestamp < q.timeRange.start || timestamp >= q.timeRange.end) return false;
|
||||||
}
|
}
|
||||||
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
|
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
|
||||||
|
if (q.channel && (rec.channel || 'h5') !== q.channel) return false;
|
||||||
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
|
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
|
||||||
if (q.group && rec.group !== q.group) return false;
|
if (q.group && rec.group !== q.group) return false;
|
||||||
if (q.q) {
|
if (q.q) {
|
||||||
@@ -642,6 +648,7 @@ function handleRecordsQuery(req, res, url) {
|
|||||||
from: sp.get('from') || null,
|
from: sp.get('from') || null,
|
||||||
to: sp.get('to') || null,
|
to: sp.get('to') || null,
|
||||||
detect_status: sp.get('detect_status') || null,
|
detect_status: sp.get('detect_status') || null,
|
||||||
|
channel: sp.get('channel') || null,
|
||||||
fail_code: sp.get('fail_code') || null,
|
fail_code: sp.get('fail_code') || null,
|
||||||
group: sp.get('group') || null,
|
group: sp.get('group') || null,
|
||||||
dim: sp.get('dim') || null,
|
dim: sp.get('dim') || null,
|
||||||
@@ -746,9 +753,21 @@ function handlePhoto(req, res, id, which) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStats(req, res) {
|
function handleStats(req, res, url) {
|
||||||
const all = readAllRecords().map(displayRecommendationRecord);
|
const channel = url.searchParams.get('channel') || null; // 传 channel 时以下指标仅统计该渠道
|
||||||
const now = Date.now(), today = BeijingTime.day(now);
|
const now = Date.now(), today = BeijingTime.day(now);
|
||||||
|
const allRaw = readAllRecords().map(displayRecommendationRecord);
|
||||||
|
// 按渠道拆分概览(始终基于全量,历史无 channel 字段的记录按 'h5' 计)
|
||||||
|
const by_channel = {};
|
||||||
|
for (const r of allRaw) {
|
||||||
|
const ch = r.channel || 'h5';
|
||||||
|
const b = by_channel[ch] || (by_channel[ch] = { total: 0, today_total: 0, success_total: 0, fail_total: 0 });
|
||||||
|
b.total += 1;
|
||||||
|
if (BeijingTime.day(r.created_at) === today) b.today_total += 1;
|
||||||
|
if (r.detect_status === '成功') b.success_total += 1;
|
||||||
|
if (r.detect_status === '失败') b.fail_total += 1;
|
||||||
|
}
|
||||||
|
const all = channel ? allRaw.filter(r => (r.channel || 'h5') === channel) : allRaw;
|
||||||
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
|
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
|
||||||
const succeeded = finished.filter(r => r.detect_status === '成功');
|
const succeeded = finished.filter(r => r.detect_status === '成功');
|
||||||
const failed = finished.filter(r => r.detect_status === '失败');
|
const failed = finished.filter(r => r.detect_status === '失败');
|
||||||
@@ -798,6 +817,7 @@ function handleStats(req, res) {
|
|||||||
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
|
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
|
||||||
avg_cost_ms: avgCost,
|
avg_cost_ms: avgCost,
|
||||||
capture_funnel: captureFunnel.stats(now),
|
capture_funnel: captureFunnel.stats(now),
|
||||||
|
by_channel,
|
||||||
fail_dist,
|
fail_dist,
|
||||||
dim_dist,
|
dim_dist,
|
||||||
group_dist,
|
group_dist,
|
||||||
@@ -823,6 +843,7 @@ async function handleRetry(req, res, id) {
|
|||||||
created_at: nowIso(),
|
created_at: nowIso(),
|
||||||
updated_at: nowIso(),
|
updated_at: nowIso(),
|
||||||
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
|
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
|
||||||
|
channel: rec.channel || 'h5', // 重试记录沿用原渠道
|
||||||
img_front: photos.front || null,
|
img_front: photos.front || null,
|
||||||
img_left: photos.left || null,
|
img_left: photos.left || null,
|
||||||
img_right: photos.right || null,
|
img_right: photos.right || null,
|
||||||
@@ -858,7 +879,8 @@ function csvCell(v) {
|
|||||||
function handleExport(req, res, url) {
|
function handleExport(req, res, url) {
|
||||||
const q = {
|
const q = {
|
||||||
from: url.searchParams.get('from') || null,
|
from: url.searchParams.get('from') || null,
|
||||||
to: url.searchParams.get('to') || null
|
to: url.searchParams.get('to') || null,
|
||||||
|
channel: url.searchParams.get('channel') || null
|
||||||
};
|
};
|
||||||
try { q.timeRange = BeijingTime.range(q.from, q.to); }
|
try { q.timeRange = BeijingTime.range(q.from, q.to); }
|
||||||
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
|
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
|
||||||
@@ -870,7 +892,7 @@ function handleExport(req, res, url) {
|
|||||||
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
|
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
|
||||||
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)',
|
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)',
|
||||||
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
|
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
|
||||||
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本'];
|
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本', '渠道'];
|
||||||
const lines = [header.join(',')];
|
const lines = [header.join(',')];
|
||||||
for (const r of filtered) {
|
for (const r of filtered) {
|
||||||
lines.push([
|
lines.push([
|
||||||
@@ -885,7 +907,8 @@ function handleExport(req, res, url) {
|
|||||||
r.recommendation ? r.recommendation.version : '旧版规则(历史记录)',
|
r.recommendation ? r.recommendation.version : '旧版规则(历史记录)',
|
||||||
r.recommendation ? r.recommendation.reason : null,
|
r.recommendation ? r.recommendation.reason : null,
|
||||||
r.capture_quality ? r.capture_quality.status : null,
|
r.capture_quality ? r.capture_quality.status : null,
|
||||||
r.capture_diagnostics ? r.capture_diagnostics.version : null
|
r.capture_diagnostics ? r.capture_diagnostics.version : null,
|
||||||
|
r.channel || 'h5'
|
||||||
].map(csvCell).join(','));
|
].map(csvCell).join(','));
|
||||||
}
|
}
|
||||||
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
|
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
|
||||||
@@ -909,7 +932,7 @@ async function handleAdmin(req, res, pathname, url) {
|
|||||||
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
|
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
|
||||||
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
|
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
|
||||||
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
|
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
|
||||||
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res);
|
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res, url);
|
||||||
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
|
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
|
||||||
return sendJson(res, 404, { ok: false, message: '接口不存在' });
|
return sendJson(res, 404, { ok: false, message: '接口不存在' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -920,6 +943,7 @@ async function handleAdmin(req, res, pathname, url) {
|
|||||||
|
|
||||||
http.createServer(async (req, res) => {
|
http.createServer(async (req, res) => {
|
||||||
const pathname = req.url.split('?')[0];
|
const pathname = req.url.split('?')[0];
|
||||||
|
if (pathname.startsWith('/api/taobao/')) return handleTaobao(req, res, pathname);
|
||||||
if (await accounts.route(req, res, pathname)) return;
|
if (await accounts.route(req, res, pathname)) return;
|
||||||
if (req.method==='POST' && pathname==='/api/wrinkle-views') {
|
if (req.method==='POST' && pathname==='/api/wrinkle-views') {
|
||||||
const owner=accounts.session(req);
|
const owner=accounts.session(req);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 淘宝小程序开发说明
|
||||||
|
|
||||||
|
当前完成本地开发闭环:授权登录 → 基础档案 → 正/右/左三步手动拍摄或相册选图 → 服务端检测 → 七项报告 → 最近5份历史报告。真实淘宝授权、云网关、相机和图片组件仍需在关联 AppKey 的淘宝 IDE 与真机中验收。
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
在本目录运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm ci
|
||||||
|
npm run dev:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
只监听回环地址:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:18180`:本地 H5 与淘宝网关模拟代理。
|
||||||
|
- `http://127.0.0.1:18181`:实际共享 Node 后端。
|
||||||
|
- `http://127.0.0.1:18180/dev/health`:确认样本模式。
|
||||||
|
- `../temp/taobao-local-data/`:隔离的账号、照片、检测记录和报告。不会读写现有 `h5/data/`。
|
||||||
|
|
||||||
|
代理使用固定的本地测试身份并模拟淘宝网关签名;Face++ 返回仓库内的固定样本。模拟逻辑仅在开发启动脚本中,正式服务器不会接受客户端自报 `open_id`,也没有免验签的开发登录后门。本地 H5 的微信入口不启用;微信 `/state/` 授权由独立浏览器回归验证。
|
||||||
|
|
||||||
|
`config.js` 当前是本地配置:`transport: 'direct'`、端口18180。直连传输只允许 localhost/127.0.0.1;不要把它当成淘宝正式网络方案。IDE 中导入本目录并关联自己的 AppKey。桌面模拟器是否允许访问回环地址需以实际 IDE 为准;手机上的127.0.0.1指向手机自身。
|
||||||
|
|
||||||
|
## 文件职责
|
||||||
|
|
||||||
|
首页右上角“开发调试 · 相机验收”可直接进入相机页,不依赖淘宝登录、本地后端或网络。也可在IDE启动参数中指定页面 `pages/camera/camera`、参数 `debug=camera`。入口需要同时满足 `config.js` 中 `environment: 'development'`、`cameraDebug: true`、本机直连配置;改为正式/cloud配置后入口关闭,单独拼参数也不能绕过登录。
|
||||||
|
|
||||||
|
验收时先开启相机,按正脸→右脸→左脸拍摄/确认,第三张完成后仅在本页验证JPEG压缩并展示三张原始预览与压缩尺寸,不发检测请求、不生成报告、不播放远程语音,也不会写入正式档案/报告状态。临时预览用于检查前置照片方向;页面不判断拍摄姿态是否合格。退出时清空本页照片引用。
|
||||||
|
|
||||||
|
依次检查:首次授权、拒绝后相册仍可使用、授权设置恢复、前后镜头、三张方向、切后台恢复、压缩最长边≤1280。真实系统权限和相机画面仍需淘宝IDE/手机验收,自动测试仅验证代码流程。
|
||||||
|
|
||||||
|
- `pages/splash`:协议同意、淘宝授权登录、退出和历史入口。
|
||||||
|
- `pages/profile`:账号隔离的基础信息缓存、出生年月和省市滚轮、肤质选择。
|
||||||
|
- `pages/camera`:手动三步拍摄/预览确认;离页取消、重复点击保护;单画布顺序压缩成最长边1280的 JPEG。
|
||||||
|
- `pages/report`:服务端返回的当前评分/话术、七项分析、护理选三、精华、真实图层和坐标标注;侧脸图层异步加载。
|
||||||
|
- `pages/history`:本人最近5份完整报告。
|
||||||
|
- `utils/transport.js`:本机代理或淘宝云 SDK 传输,保留反代子路径配置。
|
||||||
|
- `utils/api.js`:账号凭据、64KiB照片分片、检测任务提交与轮询、历史报告。
|
||||||
|
- `vendor/`:从 H5 同步的评分、报告呈现、文案及哈希清单,不手工修改。
|
||||||
|
- `scripts/`、`test/`、`docs/`:开发与回归文件,IDE 配置排除上传。
|
||||||
|
|
||||||
|
H5 规则变更后运行 `npm run sync:shared`;`npm run check:shared` 会检查逐字节一致。在线报告优先使用服务端返回的 `vm/presentation`,原始检测分数和展示肌龄分别保存。
|
||||||
|
|
||||||
|
## 账号与共享后端
|
||||||
|
|
||||||
|
正式淘宝请求走 `cloud.application.httpRequest`。登录分两步:先获取60秒一次性 challenge,再发送经网关签名、含 challenge 与同意标记的登录请求。服务端验证全部查询参数和原始请求体的签名,从网关注入的 `open_id` 建立 `kind: taobao` 的稳定账号;不依赖前端上报身份。账号键保存为 appKey/openId 的哈希。
|
||||||
|
|
||||||
|
淘宝使用独立 Bearer 会话,微信继续使用原有 Cookie。两种会话不能互换。共用 `accounts` 存储和报告格式,按服务端身份隔离历史、图片上传、检测任务和侧脸标注。
|
||||||
|
|
||||||
|
新检测记录自动标记 `h5` 或 `taobao`,不信任请求体中的 `channel`。历史没有该字段的记录视为 H5。管理 API 支持 `?channel=taobao` 筛选;统计返回 `by_channel`;CSV在末尾追加渠道列,旧列的位置保持不变。后台页面本轮没有改布局。
|
||||||
|
|
||||||
|
## 云网关接入准备
|
||||||
|
|
||||||
|
这里仅说明未来配置,不会自动部署。
|
||||||
|
|
||||||
|
1. 关联淘宝 AppKey 与外部云应用,配置域名与资源域名权限。
|
||||||
|
2. 只在后端环境配置 `TAOBAO_APP_KEY`、`TAOBAO_APP_SECRET`;Face++ 密钥沿用现有服务端设置。所有 Secret 均不得进入小程序代码。
|
||||||
|
3. `config.js` 设置 `environment: 'production'`、`cameraDebug: false`、`transport: 'cloud'`、HTTPS `baseUrl`、`cloudAppId`、资源 `assetBase`。如果使用原来的 `/state/` 反代入口,设置 `basePath: '/state'`,资源根同时包含 `/state/assets/`。
|
||||||
|
4. 外部/空应用 `cloudEnv` 为 `online`,这表示淘宝网关的环境,和本次是否部署生产服务器无关。
|
||||||
|
5. 真实淘宝授权确认后才能创建账号。当前尚未填入真实 AppKey/Secret/cloudAppId,也没有宣称已完成真实平台联调。
|
||||||
|
|
||||||
|
云网关单次请求内容上限128KB、外部服务超时最多10秒。照片按64KiB base64顺序上传,每个用户的照片只可消费一次;检测与侧脸标注均用短请求提交、轮询结果,不能直接把三张大图发进一个45秒请求。中断轮询不会自动重复付费检测,已完成结果可在历史报告中读取。
|
||||||
|
|
||||||
|
官方依据:
|
||||||
|
|
||||||
|
- [外部/空应用调用标准、128KB、10秒、响应封装](https://developer.alibaba.com/docs/doc.htm?articleId=121864&docType=1&treeId=836)
|
||||||
|
- [外部服务请求签名算法](https://developer.alibaba.com/docs/doc.htm?articleId=121746&docType=1&treeId=832)
|
||||||
|
- [服务端获取平台OpenId](https://developer.alibaba.com/docs/doc.htm?articleId=121213&docType=1&treeId=804)
|
||||||
|
- [淘宝小程序外发请求接入说明](https://developer.alibaba.com/docs/doc.htm?articleId=121954&docType=1&treeId=842)
|
||||||
|
|
||||||
|
## 自动验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run check:shared
|
||||||
|
npm test
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run test:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` 覆盖验签和重放、账号与历史隔离、照片分片与过期、异步检测、渠道筛选/CSV兼容、评分与七项话术、拍摄竞态、图片尺寸和省市联动。检测上游均为固定样本,不消耗线上检测额度。
|
||||||
|
|
||||||
|
`npm run test:h5` 运行现有 H5 的四组浏览器测试,覆盖游客不留存、HTTPS `/state/` 微信授权/退出/跨设备、完整报告/后台/CSV,以及异步侧脸图层。使用本地 Playwright,不改写旧测试;Windows上自动使用已安装 Git 附带的 OpenSSL。截图保存至 `../temp/taobao-h5-compatibility/`。
|
||||||
|
|
||||||
|
## 待平台验收
|
||||||
|
|
||||||
|
开发账号采用独立淘宝账号,由公司主账号管理员将其加入目标小程序的开发成员。公司完成主体认证、开发者入驻及应用创建;开发人员按后台要求完成个人账号认证,获得开发权限后再登录IDE关联应用。不能把店铺子账号的应用使用权限当作小程序开发权限,也不能只添加为体验者。当前账号尚未就绪,以下平台验收未完成。
|
||||||
|
|
||||||
|
相机权限单独处理:进入拍摄页先查询 `my.getSetting`;首次使用由用户点击“开启相机”后调用 `my.authorize({ scopes: 'scope.camera' })`,授权后再挂载原生camera组件。拒绝后保留相册路径,并提供用户主动打开 `my.openSetting` 的入口,不循环申请。相册选择使用独立的 `scope.album`;淘宝账号登录的 `scope.userInfo` 不代表相机已获授权。`app.json` 中的用途描述也不代表授权完成。平台控制台仍需确认对应权限包,并在“开发设置-scope授权项管理”填写用途。手机系统对淘宝App的相机/相册权限同样需要真机验证。
|
||||||
|
|
||||||
|
参考:[淘宝用户授权、权限范围与拒绝后的设置入口](https://developer.alibaba.com/docs/doc.htm?articleId=119119&docType=1&treeId=776)、[淘宝camera组件与CameraContext API](https://developer.alibaba.com/docs/doc.htm?articleId=120279&docType=1&treeId=776)。
|
||||||
|
|
||||||
|
- 真实 AppKey/cloudAppId、签名参数和授权权限包联调。
|
||||||
|
- 真实网关传输报告大图时的返回体限制与内存占用;已减少小程序 `setData` 中重复照片和非当前Tab图层。
|
||||||
|
- Android/iOS相机权限、前置相片方向、cover-view覆盖、系统相机降级、后台恢复、原生canvas及大图读取。
|
||||||
|
- UI真机对齐。当前相机使用椭圆框和左右转头提示,尚未迁移H5精确取景轮廓与实时姿态自动拍摄。
|
||||||
|
- 商品跳转尚需真实商品ID与平台能力验收;当前报告展示推荐产品,没有伪造商品链接。
|
||||||
|
|
||||||
|
以上是待真实平台验收的范围,不影响已通过的本地接口与H5回归结论。
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/* ═════ 全局样式(对齐 H5 设计变量:--yellow #F5F078 / --ink #282828 / --bg #EFEFEF) ═════ */
|
||||||
|
page {
|
||||||
|
background: #101823;
|
||||||
|
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #282828;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 主按钮(品牌黄) ─── */
|
||||||
|
.btn-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: #F5F078;
|
||||||
|
color: #282828;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.btn-primary::after { border: none; }
|
||||||
|
.btn-primary.btn-disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* ─── 白色卡片(报告页通用) ─── */
|
||||||
|
.card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
margin: 24rpx 32rpx 0;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.card-heading {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 弹窗遮罩 ─── */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; top: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 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;
|
||||||
|
}
|
||||||
|
.popup-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.popup-body {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4A4A4A;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
.popup-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
right: 24rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
line-height: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #868686;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.popup-close::after { border: none; }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/* ═════ 1'me 肌肤状态检测 · 淘宝小程序 ═════
|
||||||
|
* 复用 H5 版业务逻辑与后端接口(POST /api/skin-analyze),channel 固定为 'taobao' */
|
||||||
|
App({
|
||||||
|
globalData: {
|
||||||
|
// 档案(profile 页填写):{ real_age, gender, region, daily_skin_type }
|
||||||
|
profile: null,
|
||||||
|
// 三连拍 dataURL,送检口径固定为 [正脸, 左脸, 右脸](与 H5 photosByView 一致)
|
||||||
|
photos: null,
|
||||||
|
// 检测接口返回:{ result, side, face_rectangle }
|
||||||
|
payload: null
|
||||||
|
},
|
||||||
|
|
||||||
|
onLaunch: function () {
|
||||||
|
// 预取语音清单(失败静默,不影响主流程)
|
||||||
|
require('./utils/voice.js').init();
|
||||||
|
},
|
||||||
|
onHide: function () {
|
||||||
|
require('./utils/voice.js').stop(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
"pages/splash/splash",
|
||||||
|
"pages/profile/profile",
|
||||||
|
"pages/camera/camera",
|
||||||
|
"pages/report/report",
|
||||||
|
"pages/history/history"
|
||||||
|
],
|
||||||
|
"window": {
|
||||||
|
"defaultTitle": "1'me在线肌肤测试",
|
||||||
|
"titleBarColor": "#FFFFFF",
|
||||||
|
"backgroundColor": "#101823",
|
||||||
|
"pullRefresh": false,
|
||||||
|
"allowsBounceVertical": "NO"
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"scope.camera": {
|
||||||
|
"desc": "用于拍摄面部照片进行肌肤状态检测"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 小程序内置资源
|
||||||
|
|
||||||
|
这里只放确实需要随包内置的资源。当前UI图片、推荐产品图与语音均通过 `config.js` 的 `assetBase` 加载,复用共享后端的现有素材;没有修改H5资源。
|
||||||
|
|
||||||
|
正式淘宝端需要确认资源域名权限。开发与平台验收事项见 [../README.md](../README.md)。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Public configuration only. AppSecret and Face++ keys stay on the server.
|
||||||
|
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
|
||||||
|
mock: false,
|
||||||
|
productItemIds: {} // Optional real Taobao item IDs keyed by tier 1/2/3
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 本地 Review 与提交说明(2026-10-19)
|
||||||
|
|
||||||
|
范围:当前淘宝小程序新增代码、共享后端的淘宝账号与渠道扩展、原有冲突解决结果。仅本地提交,不部署或修改线上服务。
|
||||||
|
|
||||||
|
## Review 结果
|
||||||
|
|
||||||
|
- Git 无未合并条目;检查了账号来源隔离、网关验签和登录 challenge、照片分片、异步检测、本人历史报告、相机权限及页面离开后的取消流程。
|
||||||
|
- 修复淘宝档案页的省市选择:更换省份后取消,再打开时,城市列表必须恢复到已确认省份,不能残留取消的草稿城市。
|
||||||
|
- 修复淘宝会话过期后的退出:401 表示会话已失效,清理本地身份和照片/报告状态并完成退出;网络失败仍提示错误,不伪称服务端已撤销会话。
|
||||||
|
- 本轮新增修复均在淘宝目录。H5 页面、样式、评分及报告文案源文件未修改;共享后端扩展已做 H5 回归。
|
||||||
|
- 当前没有真实淘宝开发账号。后续使用独立淘宝账号,由公司主账号管理员添加为目标应用的开发成员。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `cd taobao; npm test`:16 项通过,覆盖接口集成、身份与历史隔离、分片和任务、相机流程、权限、报告模型、省市取消恢复、过期退出。
|
||||||
|
- `npm run check:shared`:淘宝 vendor 中评分、报告呈现和文案与 H5 一致。
|
||||||
|
- `npm run test:h5`:四组浏览器回归通过(游客隐私、HTTPS `/state/` 微信授权及退出、报告持久化及后台 CSV、异步侧脸图层)。仅固定样本,不消耗付费接口。
|
||||||
|
- 补查 10 个 H5 单元脚本:8 个通过,2 个已有失败。通过脚本为 accounts、wrinkle-jobs、wrinkle-views、weighted-total、display-age-recommendation、care-selection、acne-three-view、vita-independent。
|
||||||
|
- 已有失败 `h5/test/tone-dark-circle.js:13`:预期透亮感 9.1,现有实现返回 9.25。使用 HEAD 评分源码及测试可复现,本轮未改评分。
|
||||||
|
- 已有失败 `h5/test/factual-copy.js:9`:旧文案基线仍要求轮廓文案包含眼袋/黑眼圈描述,当前文案已移除;执行 HEAD 测试同样失败,相关源文件及旧基线本轮均未改动。保留问题供后续核对,未为本次提交放宽旧断言或修改 H5 行为。
|
||||||
|
|
||||||
|
## 继续开发前的已知边界
|
||||||
|
|
||||||
|
- 真实淘宝账号授权、AppKey/Secret、云网关及返回大图限制未实测。
|
||||||
|
- Android/iOS 相机权限、照片方向、原生 canvas、cover-view 与视觉对齐待 IDE/真机验收。
|
||||||
|
- 相机仍为三步手动拍摄,未迁移 H5 实时姿态与自动拍摄。
|
||||||
|
- IDE 官方本地云应用调试使用 `test` 环境并需要初始化时传递启动 options;当前代码默认本机直连模拟代理,正式外部服务配置为 `online`。拿到开发权限后需接入并验证 IDE 调试链路,不能将本地样本通过等同于真实平台通过。
|
||||||
|
- 商品跳转需要真实商品 ID 和平台能力配置。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 淘宝小程序当前架构(2026-10-18)
|
||||||
|
|
||||||
|
本轮用户确认:允许改共享后端,必须保持现有H5功能和显示一致;淘宝使用自己的账号体系与历史报告;共用后端并区分渠道;先本地验证,不动线上服务器。
|
||||||
|
|
||||||
|
此前V1.0方案中的三项判断已更正:
|
||||||
|
|
||||||
|
- 外部服务使用淘宝云网关,不把支付宝 `my.request` 直接作为正式网络方案。
|
||||||
|
- 淘宝账号来自网关签名校验后的平台OpenId,不沿用H5匿名游客,不接收客户端自报身份作为登录凭据。
|
||||||
|
- 网关有限时、限体积,采用64KiB分片上传和服务端异步检测任务,而不是三张base64合并发送并等待45秒。
|
||||||
|
|
||||||
|
运行方式、模块职责、认证流程、共享规则同步、测试和真实平台待验收项统一维护在 [../README.md](../README.md),以实际代码和该说明为准。
|
||||||
|
|
||||||
|
后端主入口仍为 `h5/server.js`:H5继续使用原路由;淘宝扩展位于 `h5/lib/taobao-routes.js`、`taobao-auth.js`、`taobao-session.js`。账号使用同一个服务端存储,微信和淘宝分别限定会话来源与报告所有者。
|
||||||
|
|
||||||
|
H5页面、样式、前端脚本和素材本轮均未改动。渠道字段仅由服务端路由确定,H5游客即使传 `channel: taobao` 也不改变不留存行为。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"projectname": "juzi-skin-taobao",
|
||||||
|
"compileType": "mini",
|
||||||
|
"miniprogramRoot": "./",
|
||||||
|
"appid": "",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+71
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "juzi-skin-taobao",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "juzi-skin-taobao",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tbmp/mp-cloud-sdk": "1.5.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tbmp/mp-cloud-sdk": {
|
||||||
|
"version": "1.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tbmp/mp-cloud-sdk/-/mp-cloud-sdk-1.5.10.tgz",
|
||||||
|
"integrity": "sha512-KweMl44yjnU76jWEbuCyRpTDgxlgu6zCe41psre6IfjwGeQriSew/KymGESAs0SDjH0sAfFONo/tLR6ltrNa+w==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "juzi-skin-taobao",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"sync:shared": "node scripts/sync-shared.cjs",
|
||||||
|
"check:shared": "node scripts/sync-shared.cjs --check",
|
||||||
|
"test": "node --test test/*.test.cjs",
|
||||||
|
"dev:backend": "node scripts/dev-backend.cjs",
|
||||||
|
"test:h5": "node test/h5-compatibility.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": { "@tbmp/mp-cloud-sdk": "1.5.10" },
|
||||||
|
"devDependencies": { "playwright": "1.58.2" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/* ═════ 拍照页样式 ═════ */
|
||||||
|
.cam {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: #101823;
|
||||||
|
}
|
||||||
|
.cam-device {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 步骤文案 ─── */
|
||||||
|
.cam-step {
|
||||||
|
position: absolute;
|
||||||
|
top: 80rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.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-mask {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; right: 0; bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cam-oval {
|
||||||
|
width: 560rpx;
|
||||||
|
height: 720rpx;
|
||||||
|
border: 4rpx solid rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
/* 侧脸步轮廓偏移提示(骨架版:向左/右微移示意朝向) */
|
||||||
|
.mask-1 { transform: translateX(-60rpx); } /* 左脸出镜:耳在画面左 */
|
||||||
|
.mask-2 { transform: translateX(60rpx); } /* 右脸出镜:耳在画面右 */
|
||||||
|
|
||||||
|
/* ─── 底部引导要点 ─── */
|
||||||
|
.cam-guide {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 260rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cam-guide-item {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border-right: 2rpx solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
.cam-guide-item:last-child { border-right: none; }
|
||||||
|
|
||||||
|
/* ─── 控制条 ─── */
|
||||||
|
.cam-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.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%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cam-shutter-dot {
|
||||||
|
width: 108rpx;
|
||||||
|
height: 108rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #F5F078;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 停顿卡 ─── */
|
||||||
|
.cam-done {
|
||||||
|
position: absolute;
|
||||||
|
top: 45%;
|
||||||
|
left: 60rpx;
|
||||||
|
right: 60rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 拍后预览 ─── */
|
||||||
|
.cam-preview {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; right: 0; bottom: 0;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
.cam-preview-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 78%;
|
||||||
|
}
|
||||||
|
.cam-preview-btns {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.cam-preview-btn {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
border: 2rpx solid #FFFFFF;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cam-analyzing-text {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 隐藏画布(压缩降级用) ─── */
|
||||||
|
.compress-canvas {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999rpx;
|
||||||
|
top: -9999rpx;
|
||||||
|
width: 640px;
|
||||||
|
height: 640px;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!-- ═════ 05-07 拍照页 ═════
|
||||||
|
覆盖在 camera 原生组件上的一切必须用 cover-view / cover-image -->
|
||||||
|
<view class="cam">
|
||||||
|
<view a:if="{{debugMode && !cameraEnabled}}" class="debug-banner">相机验收 · 免登录 · 照片只在本页处理,不上传</view>
|
||||||
|
<view a:if="{{debugComplete}}" class="debug-results">
|
||||||
|
<view class="debug-title">三步拍摄与 JPEG 压缩已完成</view>
|
||||||
|
<view>请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。</view>
|
||||||
|
<view class="debug-result" a:for="{{debugPhotos}}" a:key="key">
|
||||||
|
<image src="{{item.photo}}" mode="aspectFit" />
|
||||||
|
<view>{{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB</view>
|
||||||
|
</view>
|
||||||
|
<button class="btn-primary" onTap="onDebugRestart">重新验收</button>
|
||||||
|
<button onTap="onCancel">退出并清空本页照片</button>
|
||||||
|
</view>
|
||||||
|
<view a:if="{{!cameraEnabled && !debugComplete}}" class="camera-permission">
|
||||||
|
<view>{{stepText}} · {{stepTip}}</view>
|
||||||
|
<view>{{permissionMessage}}</view>
|
||||||
|
<button class="btn-primary" onTap="onEnableCamera">开启相机</button>
|
||||||
|
<button onTap="onOpenSettings">授权设置</button>
|
||||||
|
<button onTap="onSystemCamera">使用系统相机</button>
|
||||||
|
<button onTap="onAlbum">从相册选择</button>
|
||||||
|
<view a:if="{{previewUrl}}">
|
||||||
|
<image src="{{previewUrl}}" mode="aspectFit" style="width:100%;height:400rpx;" />
|
||||||
|
<button onTap="onRetake">重新选择</button><button class="btn-primary" onTap="onConfirm">使用照片</button>
|
||||||
|
</view>
|
||||||
|
<view a:if="{{analyzing}}">{{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}}</view>
|
||||||
|
</view>
|
||||||
|
<camera a:if="{{cameraEnabled}}" id="skin-camera" class="cam-device" device-position="{{devicePosition}}"
|
||||||
|
flash="off" onError="onCameraError" onStop="onCameraStop">
|
||||||
|
<!-- 顶部步骤文案 -->
|
||||||
|
<cover-view class="cam-step">
|
||||||
|
<cover-view a:if="{{debugMode}}">开发调试 · 不上传照片</cover-view>
|
||||||
|
<cover-view class="cam-step-no">{{stepText}}</cover-view>
|
||||||
|
<cover-view class="cam-step-tip">{{stepTip}}</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
|
||||||
|
<!-- 取景蒙版:人脸轮廓(骨架版用椭圆线框,朝向提示随步切换;精确轮廓 TODO:换 cover-image 轮廓图) -->
|
||||||
|
<cover-view class="cam-mask">
|
||||||
|
<cover-view class="cam-oval mask-{{maskStep}}"></cover-view>
|
||||||
|
</cover-view>
|
||||||
|
|
||||||
|
<!-- 底部引导要点 -->
|
||||||
|
<cover-view class="cam-guide">
|
||||||
|
<cover-view class="cam-guide-item" a:for="{{guides}}" a:key="*this">{{item}}</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
|
||||||
|
<!-- 控制条:相册 / 快门 / 翻转 -->
|
||||||
|
<cover-view class="cam-controls">
|
||||||
|
<cover-view class="cam-side" onTap="onAlbum">相册</cover-view>
|
||||||
|
<cover-view class="cam-shutter" onTap="onShutter">
|
||||||
|
<cover-view class="cam-shutter-dot"></cover-view>
|
||||||
|
</cover-view>
|
||||||
|
<cover-view class="cam-side" onTap="onFlip">翻转</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
|
||||||
|
<!-- 本步完成停顿卡 -->
|
||||||
|
<cover-view class="cam-done" a:if="{{doneTip}}">{{doneTip}}</cover-view>
|
||||||
|
|
||||||
|
<!-- 拍后预览:确认使用 / 重拍 -->
|
||||||
|
<cover-view class="cam-preview" a:if="{{previewUrl}}" catchTap="noop">
|
||||||
|
<cover-image class="cam-preview-img" src="{{previewUrl}}"></cover-image>
|
||||||
|
<cover-view class="cam-preview-btns">
|
||||||
|
<cover-view class="cam-preview-btn" onTap="onRetake">重拍</cover-view>
|
||||||
|
<cover-view class="cam-preview-btn ok" onTap="onConfirm">使用照片</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
|
||||||
|
<!-- 分析中 -->
|
||||||
|
<cover-view class="cam-analyzing" a:if="{{analyzing}}">
|
||||||
|
<cover-view class="cam-analyzing-text">{{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}}</cover-view>
|
||||||
|
<cover-view class="cam-cancel" onTap="onCancel">{{debugMode ? '退出验收' : '返回,稍后查看历史报告'}}</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
</camera>
|
||||||
|
|
||||||
|
<!-- 压缩降级用的隐藏画布(canvas 重绘压缩,不可见) -->
|
||||||
|
<canvas id="compress-canvas" class="compress-canvas" width="{{compressWidth}}" height="{{compressHeight}}" style="width:{{compressWidth}}px;height:{{compressHeight}}px;" />
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
var api = require('../../utils/api'), image = require('../../utils/image'), voice = require('../../utils/voice');
|
||||||
|
var permissions = require('../../utils/permissions');
|
||||||
|
var development = require('../../utils/development');
|
||||||
|
var STEPS = [
|
||||||
|
{ target: 'front', tip: '请单人正脸出镜', turn: '正脸居中', mask: 0 },
|
||||||
|
{ target: 'right', tip: '请右脸出镜', turn: '请向左转头', mask: 2 },
|
||||||
|
{ target: 'left', tip: '请左脸出镜', turn: '请向右转头', mask: 1 }
|
||||||
|
];
|
||||||
|
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: [] },
|
||||||
|
onLoad: function (query) {
|
||||||
|
this.alive = true; this.visible = true; this.photos = []; this.busy = false; this.seq = 0;
|
||||||
|
this.debugMode = !!(query && query.debug === 'camera' && development.cameraDebugEnabled());
|
||||||
|
this.setData({ debugMode: this.debugMode });
|
||||||
|
if (this.debugMode) return;
|
||||||
|
if (!api.user() || !getApp().globalData.profile) my.redirectTo({ url: '/pages/splash/splash' });
|
||||||
|
},
|
||||||
|
onReady: function () {
|
||||||
|
this.pageReady = true;
|
||||||
|
if (this.cameraAuthorized) this.mountCamera();
|
||||||
|
},
|
||||||
|
onShow: function () {
|
||||||
|
this.visible = true;
|
||||||
|
if (!this.data.previewUrl && !this.data.analyzing && !this.data.debugComplete) this.renderStep();
|
||||||
|
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: '相机权限未开启,可前往授权设置或使用相册。' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mountCamera: function () {
|
||||||
|
if (!this.alive || !this.visible || !this.pageReady || this.data.cameraEnabled || this.data.debugComplete) return;
|
||||||
|
var that = this;
|
||||||
|
this.setData({ cameraEnabled: true, camError: false }, function () {
|
||||||
|
if (!that.alive || !that.visible) return;
|
||||||
|
try {
|
||||||
|
that.cameraCtx = my.createCameraContext('skin-camera');
|
||||||
|
if (!that.cameraCtx || !that.cameraCtx.takePhoto) that.onCameraError();
|
||||||
|
} catch (_) { that.onCameraError(); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onEnableCamera: function () {
|
||||||
|
if (this.authorizing) return;
|
||||||
|
var that = this; this.authorizing = true;
|
||||||
|
permissions.ensure('scope.camera').then(function () { that.cameraAuthorized = true; that.mountCamera(); }).catch(function (err) {
|
||||||
|
if (that.alive && that.visible) that.setData({ permissionMessage: err.message });
|
||||||
|
}).then(function () { that.authorizing = false; });
|
||||||
|
},
|
||||||
|
onOpenSettings: function () {
|
||||||
|
var that = this;
|
||||||
|
permissions.open().then(function (current) {
|
||||||
|
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' }); });
|
||||||
|
},
|
||||||
|
onHide: function () { this.pause(); },
|
||||||
|
onUnload: function () { this.pause(); this.alive = false; this.photos = []; this.debugResults = []; },
|
||||||
|
pause: function () {
|
||||||
|
this.visible = false; this.seq++; this.busy = false;
|
||||||
|
clearTimeout(this.nextTimer);
|
||||||
|
if (this.signal) this.signal.canceled = true;
|
||||||
|
voice.stop(true);
|
||||||
|
this.cameraCtx = null;
|
||||||
|
if (this.alive) this.setData({ cameraEnabled: false });
|
||||||
|
if (this.alive && this.data.analyzing) this.setData({ analyzing: false, doneTip: '' });
|
||||||
|
},
|
||||||
|
renderStep: function () {
|
||||||
|
var cfg = STEPS[this.data.stepIndex];
|
||||||
|
this.setData({ stepText: (this.data.stepIndex + 1) + '/3', stepTip: cfg.tip,
|
||||||
|
guides: [cfg.turn, '取下眼镜', '光线充足'], maskStep: cfg.mask, doneTip: '' });
|
||||||
|
if (!this.debugMode) voice.play(voice.VOICE.step[this.data.stepIndex]);
|
||||||
|
},
|
||||||
|
onCameraError: function () {
|
||||||
|
this.setData({ camError: true, cameraEnabled: false, permissionMessage: '相机暂不可用,请检查淘宝的系统相机权限,或使用系统相机/相册。' });
|
||||||
|
my.showToast({ content: '相机不可用,请使用系统相机或相册', type: 'none' });
|
||||||
|
},
|
||||||
|
onCameraStop: function () { if (this.alive && this.visible) this.onCameraError(); },
|
||||||
|
onShutter: function () {
|
||||||
|
if (this.busy || this.data.previewUrl || this.data.analyzing) return;
|
||||||
|
if (this.data.camError || !this.cameraCtx) return this.choose(['camera']);
|
||||||
|
var that = this, seq = this.seq;
|
||||||
|
this.busy = true;
|
||||||
|
this.cameraCtx.takePhoto({ quality: 'high', success: function (res) {
|
||||||
|
if (!that.alive || !that.visible || seq !== that.seq) return;
|
||||||
|
var file = res.tempImagePath || res.tempFilePath || res.apFilePath;
|
||||||
|
if (file) that.setData({ previewUrl: file });
|
||||||
|
else my.showToast({ content: '拍摄失败,请重试', type: 'none' });
|
||||||
|
}, fail: function () {
|
||||||
|
if (that.alive && that.visible) my.showToast({ content: '拍摄失败,请重试', type: 'none' });
|
||||||
|
}, complete: function () { if (seq === that.seq) that.busy = false; } });
|
||||||
|
},
|
||||||
|
choose: function (sourceType) {
|
||||||
|
if (this.busy || this.data.previewUrl || this.data.analyzing) return;
|
||||||
|
var that = this;
|
||||||
|
this.busy = true;
|
||||||
|
// chooseImage has its own album scope; native capture also needs camera.
|
||||||
|
permissions.ensure('scope.album').then(function () {
|
||||||
|
if (sourceType[0] === 'camera') return permissions.ensure('scope.camera');
|
||||||
|
}).then(function () {
|
||||||
|
if (!that.alive || !that.visible) { that.busy = false; return; }
|
||||||
|
my.chooseImage({ count: 1, sourceType: sourceType,
|
||||||
|
success: function (res) {
|
||||||
|
var paths = res.apFilePaths || res.tempFilePaths || [];
|
||||||
|
if (that.alive && paths[0]) that.setData({ previewUrl: paths[0] });
|
||||||
|
}, fail: function () { if (that.alive && that.visible) my.showToast({ content: '未选取照片,可重试或检查系统权限', type: 'none' }); },
|
||||||
|
complete: function () { that.busy = false; } });
|
||||||
|
}).catch(function (err) {
|
||||||
|
that.busy = false;
|
||||||
|
if (that.alive && that.visible) { that.setData({ permissionMessage: err.message }); my.showToast({ content: err.message, type: 'none' }); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSystemCamera: function () { this.choose(['camera']); },
|
||||||
|
onAlbum: function () { this.choose(['album']); },
|
||||||
|
onFlip: function () {
|
||||||
|
if (this.busy || this.data.previewUrl || this.data.analyzing) return;
|
||||||
|
this.setData({ devicePosition: this.data.devicePosition === 'front' ? 'back' : 'front' });
|
||||||
|
},
|
||||||
|
onRetake: function () { if (!this.busy) this.setData({ previewUrl: '' }); },
|
||||||
|
onConfirm: function () {
|
||||||
|
if (this.busy || !this.data.previewUrl || this.data.analyzing) return;
|
||||||
|
var that = this, step = this.data.stepIndex;
|
||||||
|
this.busy = true; this.photos[step] = this.data.previewUrl;
|
||||||
|
if (!this.debugMode) voice.play(voice.VOICE.shot, { cut: true });
|
||||||
|
if (step === 2) { this.setData({ previewUrl: '' }); this.submitPhotos(); return; }
|
||||||
|
this.setData({ stepIndex: step + 1, previewUrl: '', doneTip: '拍摄完成。下一张:' + STEPS[step + 1].tip + ',' + STEPS[step + 1].turn });
|
||||||
|
this.nextTimer = setTimeout(function () {
|
||||||
|
if (!that.alive || !that.visible) return;
|
||||||
|
that.busy = false; that.renderStep();
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
submitPhotos: function () {
|
||||||
|
if (!this.photos[0] || !this.photos[1] || !this.photos[2]) return;
|
||||||
|
var that = this, seq = ++this.seq, dataUrls = [];
|
||||||
|
var debugResults = [];
|
||||||
|
this.signal = { canceled: false }; var signal = this.signal;
|
||||||
|
this.busy = true; this.setData({ analyzing: true, doneTip: '' });
|
||||||
|
if (!this.debugMode) voice.play(voice.VOICE.analyzing);
|
||||||
|
// One shared native canvas: draw the photos sequentially.
|
||||||
|
this.photos.reduce(function (chain, photo) {
|
||||||
|
return chain.then(function () {
|
||||||
|
if (signal.canceled) throw { canceled: true };
|
||||||
|
return image.compressToBase64(photo, that);
|
||||||
|
}).then(function (value) {
|
||||||
|
dataUrls.push(value);
|
||||||
|
if (that.debugMode) debugResults.push({ key: STEPS[debugResults.length].target,
|
||||||
|
title: STEPS[debugResults.length].tip.replace(/^请/, ''), photo: photo,
|
||||||
|
width: that.data.compressWidth, height: that.data.compressHeight,
|
||||||
|
kilobytes: Math.round(value.replace(/^data:[^,]+,/, '').length * 3 / 4 / 1024) });
|
||||||
|
});
|
||||||
|
}, Promise.resolve()).then(function () {
|
||||||
|
if (signal.canceled) throw { canceled: true };
|
||||||
|
if (that.debugMode) {
|
||||||
|
if (!that.alive || !that.visible || seq !== that.seq) return;
|
||||||
|
that.busy = false; that.cameraCtx = null;
|
||||||
|
dataUrls = []; // Do not keep base64, upload, create an account or touch report state.
|
||||||
|
that.setData({ analyzing: false, cameraEnabled: false, debugComplete: true, debugPhotos: debugResults });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var ordered = [dataUrls[0], dataUrls[2], dataUrls[1]];
|
||||||
|
return api.analyze(ordered, getApp().globalData.profile, signal).then(function (payload) {
|
||||||
|
if (!that.alive || !that.visible || seq !== that.seq) return;
|
||||||
|
getApp().globalData.photos = ordered; getApp().globalData.payload = payload;
|
||||||
|
voice.stop(true); my.redirectTo({ url: '/pages/report/report' });
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (err.canceled || !that.alive || !that.visible || seq !== that.seq) return;
|
||||||
|
that.busy = false; that.setData({ analyzing: false });
|
||||||
|
if (!that.debugMode) voice.play(voice.VOICE.fail, { cut: true });
|
||||||
|
my.showToast({ content: err.message || '照片处理失败,请重新拍摄', type: 'none', duration: 3000 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDebugRestart: function () {
|
||||||
|
if (!this.debugMode || !development.cameraDebugEnabled()) return;
|
||||||
|
this.photos = []; this.busy = false; this.seq++;
|
||||||
|
this.setData({ debugComplete: false, debugPhotos: [], stepIndex: 0, previewUrl: '', analyzing: false });
|
||||||
|
this.renderStep(); this.onEnableCamera();
|
||||||
|
},
|
||||||
|
onCancel: function () {
|
||||||
|
if (this.debugMode) { this.photos = []; this.setData({ debugPhotos: [], previewUrl: '' }); }
|
||||||
|
this.pause(); my.navigateBack();
|
||||||
|
},
|
||||||
|
noop: function () {}
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"defaultTitle": "1'me在线肌肤测试",
|
||||||
|
"titleBarColor": "#101823",
|
||||||
|
"backgroundColor": "#101823",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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; }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<view class="history">
|
||||||
|
<view class="history-note">最近5份检测报告</view>
|
||||||
|
<view a:if="{{loading}}" class="history-note">加载中…</view>
|
||||||
|
<view a:if="{{error}}" class="history-note" onTap="load">{{error}},点击重试</view>
|
||||||
|
<view a:if="{{!loading && !error && !items.length}}" class="history-note">暂无检测报告</view>
|
||||||
|
<view class="card history-item" a:for="{{items}}" a:key="id" data-id="{{item.id}}" onTap="onOpen">
|
||||||
|
<view>{{item.date}}</view><view>综合评分 {{item.total}} · 肌肤年龄 {{item.skinAge}} 岁</view>
|
||||||
|
<view class="history-open">查看报告 ›</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
var api = require('../../utils/api');
|
||||||
|
Page({
|
||||||
|
data: { items: [], loading: false, error: '' },
|
||||||
|
onShow: function () {
|
||||||
|
if (!api.user()) { this.setData({ items: [] }); my.redirectTo({ url: '/pages/splash/splash' }); return; }
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
onUnload: function () { this.closed = true; },
|
||||||
|
load: function () {
|
||||||
|
var that = this;
|
||||||
|
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 }); });
|
||||||
|
},
|
||||||
|
onOpen: function (e) {
|
||||||
|
if (this.data.loading) return;
|
||||||
|
var that = this;
|
||||||
|
this.setData({ loading: true });
|
||||||
|
api.request('reports', { id: e.currentTarget.dataset.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' }); })
|
||||||
|
.then(function () { if (!that.closed) that.setData({ loading: false }); });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "defaultTitle": "历史报告", "backgroundColor": "#EFEFEF" }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/* ═════ 档案填写页样式 ═════ */
|
||||||
|
.profile {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.profile-bg {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/* 上半部分露出背景图,表单区由白色卡片承载 */
|
||||||
|
}
|
||||||
|
.profile-body {
|
||||||
|
position: relative;
|
||||||
|
padding: 48rpx 32rpx 64rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.pick-value {
|
||||||
|
color: #282828;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
.chev {
|
||||||
|
color: #868686;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 肤质卡 ─── */
|
||||||
|
.skin-card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.skin-axis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.axis-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #868686;
|
||||||
|
margin: 0 16rpx;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
}
|
||||||
|
.chip-dry { background: #EAE6E1; }
|
||||||
|
.chip-oily { background: #F5F078; }
|
||||||
|
.chip-balance { background: #DBF6D6; }
|
||||||
|
|
||||||
|
.skin-stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.skin-arrow {
|
||||||
|
width: 64rpx;
|
||||||
|
font-size: 56rpx;
|
||||||
|
color: #868686;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.skin-swiper {
|
||||||
|
flex: 1;
|
||||||
|
height: 460rpx;
|
||||||
|
}
|
||||||
|
.skin-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.skin-img {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
}
|
||||||
|
.skin-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
.skin-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #868686;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
.skin-dots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #E5E5E5;
|
||||||
|
margin: 0 6rpx;
|
||||||
|
}
|
||||||
|
.dot.on { background: #282828; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!-- ═════ 03 档案填写 ═════ -->
|
||||||
|
<view class="profile">
|
||||||
|
<image class="profile-bg" src="{{bgUrl}}" mode="aspectFill" />
|
||||||
|
<view class="profile-body">
|
||||||
|
<view class="page-title">您的肌肤状态</view>
|
||||||
|
|
||||||
|
<!-- 出生年月 + 性别(原生 picker 底部滚轮) -->
|
||||||
|
<view class="form-row">
|
||||||
|
<view class="field field-half">
|
||||||
|
<view class="field-label">您的出生年月</view>
|
||||||
|
<view onTap="onOpenBirth">
|
||||||
|
<view class="pick">
|
||||||
|
<text class="pick-value">{{birthText}}</text>
|
||||||
|
<text class="chev">⌄</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="field field-half">
|
||||||
|
<view class="field-label">您的性别</view>
|
||||||
|
<picker mode="selector" range="{{genderRange}}" onChange="onGenderChange">
|
||||||
|
<view class="pick">
|
||||||
|
<text class="pick-value">{{genderText}}</text>
|
||||||
|
<text class="chev">⌄</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 所在地区(省+市联动) -->
|
||||||
|
<view class="field">
|
||||||
|
<view class="field-label">您的所在地区</view>
|
||||||
|
<view onTap="onOpenRegion">
|
||||||
|
<view class="pick">
|
||||||
|
<text class="pick-value">{{regionText}}</text>
|
||||||
|
<text class="chev">⌄</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 日常肤质(轮播卡) -->
|
||||||
|
<view class="field">
|
||||||
|
<view class="field-label">您的日常肤质</view>
|
||||||
|
<view class="skin-card">
|
||||||
|
<view class="skin-axis">
|
||||||
|
<text class="axis-item"><text class="chip chip-dry"></text>偏干</text>
|
||||||
|
<text class="axis-item"><text class="chip chip-oily"></text>偏油</text>
|
||||||
|
<text class="axis-item"><text class="chip chip-balance"></text>水油平衡</text>
|
||||||
|
</view>
|
||||||
|
<view class="skin-stage">
|
||||||
|
<view class="skin-arrow" onTap="onSkinPrev">‹</view>
|
||||||
|
<swiper class="skin-swiper" current="{{skinIndex}}" onChange="onSkinChange" circular="{{false}}">
|
||||||
|
<swiper-item a:for="{{skins}}" a:key="name">
|
||||||
|
<view class="skin-item">
|
||||||
|
<image src="{{item.img}}" class="skin-img" mode="aspectFit" lazy-load />
|
||||||
|
<view class="skin-name">{{item.name}}</view>
|
||||||
|
<view class="skin-desc">{{item.desc}}</view>
|
||||||
|
</view>
|
||||||
|
</swiper-item>
|
||||||
|
</swiper>
|
||||||
|
<view class="skin-arrow" onTap="onSkinNext">›</view>
|
||||||
|
</view>
|
||||||
|
<view class="skin-dots">
|
||||||
|
<text a:for="{{skins}}" a:key="name" class="dot {{index === skinIndex ? 'on' : ''}}"></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn-primary btn-submit" onTap="onSubmit">提交</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ═══ 弹窗:拍照引导 ═══ -->
|
||||||
|
<view class="overlay" a:if="{{showGuide}}" onTap="onCloseGuide">
|
||||||
|
<view class="popup" catchTap="noop">
|
||||||
|
<button class="popup-close" onTap="onCloseGuide">✕</button>
|
||||||
|
<view class="popup-title">
|
||||||
|
<text>请拍摄或上传</text>
|
||||||
|
<text>正面及左右侧面照片</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn-primary popup-btn" onTap="onStartCamera">开始</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="overlay" a:if="{{picker}}" onTap="onPickerClose">
|
||||||
|
<view class="picker-sheet" catchTap="noop">
|
||||||
|
<view class="picker-actions"><text onTap="onPickerClose">取消</text><text onTap="onPickerConfirm">确定</text></view>
|
||||||
|
<picker-view value="{{pickerValue}}" onChange="onPickerChange" class="native-picker">
|
||||||
|
<picker-view-column><view a:for="{{picker === 'birth' ? birthRange[0] : regionRange[0]}}" a:key="*this">{{item}}</view></picker-view-column>
|
||||||
|
<picker-view-column><view a:for="{{picker === 'birth' ? birthRange[1] : regionRange[1]}}" a:key="*this">{{item}}</view></picker-view-column>
|
||||||
|
</picker-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/* ═════ 档案填写页:出生年月/性别/地区(原生 picker)+ 日常肤质选择(对齐 H5 #s-profile) ═════
|
||||||
|
* 档案字段与 H5 一致:{ real_age, gender, region, daily_skin_type } */
|
||||||
|
var api = require('../../utils/api.js');
|
||||||
|
var REGION_DATA = require('../../utils/region-data.js');
|
||||||
|
|
||||||
|
// 肤质选项(与 H5 SKINS 一致,图片远程加载)
|
||||||
|
var SKINS = [
|
||||||
|
{ img: 'img/ui-20261009/skin-0-dry.png', name: '干性肌肤', desc: '全脸干燥,容易紧绷、缺乏光泽' },
|
||||||
|
{ 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: '全脸易出油,容易泛油光、毛孔明显' },
|
||||||
|
{ img: 'img/ui-20261009/skin-4-mixedoily.png', name: '混合偏油肌肤', desc: 'T区易出油,两颊相对清爽或轻微干燥' }
|
||||||
|
];
|
||||||
|
|
||||||
|
var NOW = new Date(), NOW_Y = NOW.getFullYear(), NOW_M = NOW.getMonth() + 1;
|
||||||
|
var YEARS = [], MONTHS = [];
|
||||||
|
for (var y = NOW_Y - 99; y <= NOW_Y - 1; y++) YEARS.push(y + '年');
|
||||||
|
for (var m = 1; m <= 12; m++) MONTHS.push(m + '月');
|
||||||
|
|
||||||
|
function ageFromBirth(y, m) { return NOW_Y - y - (NOW_M < m ? 1 : 0); } // 未过生日月份按少一岁(与 H5 一致)
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
bgUrl: api.assetUrl('img/bg-splash.jpg'),
|
||||||
|
|
||||||
|
// 出生年月(两列滚轮:年+月,默认 30 岁 1 月)
|
||||||
|
birthRange: [YEARS, MONTHS],
|
||||||
|
birthIndex: [YEARS.indexOf((NOW_Y - 30) + '年'), 0],
|
||||||
|
birthText: '',
|
||||||
|
birth: '', // 'YYYY-MM'
|
||||||
|
age: 0, // real_age
|
||||||
|
|
||||||
|
// 性别
|
||||||
|
genderRange: ['女', '男'],
|
||||||
|
genderText: '',
|
||||||
|
|
||||||
|
// 所在地区(省 + 市两列)
|
||||||
|
regionRange: [
|
||||||
|
REGION_DATA.map(function (p) { return p[0]; }),
|
||||||
|
REGION_DATA[0][1]
|
||||||
|
],
|
||||||
|
regionIndex: [0, 0],
|
||||||
|
regionText: '',
|
||||||
|
|
||||||
|
// 肤质轮播
|
||||||
|
skins: SKINS.map(function (s) {
|
||||||
|
return { img: api.assetUrl(s.img), name: s.name, desc: s.desc };
|
||||||
|
}),
|
||||||
|
skinIndex: 0,
|
||||||
|
|
||||||
|
showGuide: false, picker: '', pickerValue: [0, 0]
|
||||||
|
},
|
||||||
|
onLoad: function () {
|
||||||
|
if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; }
|
||||||
|
this.ownerId = api.user().id;
|
||||||
|
try {
|
||||||
|
var saved = my.getStorageSync({ key: 'juzi.taobao.profile.' + api.user().id }).data;
|
||||||
|
if (saved) {
|
||||||
|
saved.age = ageFromBirth(Number(saved.birth.split('-')[0]), Number(saved.birth.split('-')[1]));
|
||||||
|
this.setData(saved);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
onShow: function () {
|
||||||
|
if (!api.user() || this.ownerId !== api.user().id) {
|
||||||
|
this.setData({ birthText: '', genderText: '', regionText: '', showGuide: false });
|
||||||
|
my.redirectTo({ url: '/pages/splash/splash' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenBirth: function () { this.setData({ picker: 'birth', 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,
|
||||||
|
regionRange: [REGION_DATA.map(function (p) { return p[0]; }), REGION_DATA[index[0]][1]] });
|
||||||
|
},
|
||||||
|
onPickerChange: function (e) {
|
||||||
|
var value = e.detail.value;
|
||||||
|
if (this.data.picker === 'region' && value[0] !== this.data.pickerValue[0]) value[1] = 0;
|
||||||
|
var patch = { pickerValue: value };
|
||||||
|
if (this.data.picker === 'region') patch.regionRange = [REGION_DATA.map(function (p) { return p[0]; }), REGION_DATA[value[0]][1]];
|
||||||
|
this.setData(patch);
|
||||||
|
},
|
||||||
|
onPickerConfirm: function () {
|
||||||
|
var e = { detail: { value: this.data.pickerValue } };
|
||||||
|
if (this.data.picker === 'birth') this.onBirthChange(e); else this.onRegionChange(e);
|
||||||
|
this.onPickerClose();
|
||||||
|
},
|
||||||
|
onPickerClose: function () { this.setData({ picker: '' }); },
|
||||||
|
|
||||||
|
/* ─── 出生年月 ─── */
|
||||||
|
onBirthChange: function (e) {
|
||||||
|
var idx = e.detail.value;
|
||||||
|
var y = parseInt(YEARS[idx[0]], 10);
|
||||||
|
var m = idx[1] + 1;
|
||||||
|
this.setData({
|
||||||
|
birthIndex: idx,
|
||||||
|
birthText: y + '年' + m + '月',
|
||||||
|
birth: y + '-' + (m < 10 ? '0' : '') + m,
|
||||||
|
age: ageFromBirth(y, m)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ─── 性别 ─── */
|
||||||
|
onGenderChange: function (e) {
|
||||||
|
this.setData({ genderText: this.data.genderRange[e.detail.value] });
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ─── 地区:省份列滚动时联动城市列 ─── */
|
||||||
|
onRegionColumnChange: function (e) {
|
||||||
|
if (e.detail.column !== 0) return;
|
||||||
|
var cities = REGION_DATA[e.detail.value][1];
|
||||||
|
this.setData({ 'regionRange[1]': cities, regionIndex: [e.detail.value, 0] });
|
||||||
|
},
|
||||||
|
onRegionChange: function (e) {
|
||||||
|
var idx = e.detail.value;
|
||||||
|
var prov = REGION_DATA[idx[0]][0];
|
||||||
|
var city = REGION_DATA[idx[0]][1][idx[1]];
|
||||||
|
// 直辖市/港澳台只显示一级(与 H5 一致)
|
||||||
|
var text = !city || city === prov ? prov : prov + ' ' + city;
|
||||||
|
this.setData({ regionIndex: idx, regionText: text });
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ─── 肤质轮播 ─── */
|
||||||
|
onSkinChange: function (e) {
|
||||||
|
this.setData({ skinIndex: e.detail.current });
|
||||||
|
},
|
||||||
|
onSkinPrev: function () {
|
||||||
|
if (this.data.skinIndex > 0) this.setData({ skinIndex: this.data.skinIndex - 1 });
|
||||||
|
},
|
||||||
|
onSkinNext: function () {
|
||||||
|
if (this.data.skinIndex < SKINS.length - 1) this.setData({ skinIndex: this.data.skinIndex + 1 });
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ─── 提交 → 拍照引导弹窗 ─── */
|
||||||
|
onSubmit: function () {
|
||||||
|
var d = this.data;
|
||||||
|
if (!d.birth || !(d.age >= 1 && d.age <= 99)) {
|
||||||
|
my.showToast({ content: '请选择出生年月', type: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!d.genderText) {
|
||||||
|
my.showToast({ content: '请选择性别', type: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!d.regionText) {
|
||||||
|
my.showToast({ content: '请选择所在地区', type: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setData({ showGuide: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
onCloseGuide: function () {
|
||||||
|
this.setData({ showGuide: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 「开始」:档案入全局态,进入拍照页
|
||||||
|
onStartCamera: function () {
|
||||||
|
if (!api.user()) { my.redirectTo({ url: '/pages/splash/splash' }); return; }
|
||||||
|
var d = this.data;
|
||||||
|
getApp().globalData.profile = {
|
||||||
|
real_age: d.age,
|
||||||
|
gender: d.genderText,
|
||||||
|
region: d.regionText,
|
||||||
|
daily_skin_type: SKINS[d.skinIndex].name
|
||||||
|
};
|
||||||
|
my.setStorageSync({ key: 'juzi.taobao.profile.' + api.user().id, data: {
|
||||||
|
birth: d.birth, birthIndex: d.birthIndex, birthText: d.birthText, genderText: d.genderText,
|
||||||
|
regionIndex: d.regionIndex, regionText: d.regionText, regionRange: d.regionRange, skinIndex: d.skinIndex
|
||||||
|
} });
|
||||||
|
this.setData({ showGuide: false });
|
||||||
|
my.navigateTo({ url: '/pages/camera/camera' });
|
||||||
|
},
|
||||||
|
|
||||||
|
noop: function () {}
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"defaultTitle": "1'me在线肌肤测试",
|
||||||
|
"titleBarColor": "#101823",
|
||||||
|
"backgroundColor": "#101823"
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/* ═════ 报告页样式(灰底白卡,对齐设计稿 08) ═════ */
|
||||||
|
page { background: #EFEFEF; }
|
||||||
|
.report { padding-bottom: 64rpx; }
|
||||||
|
.report-title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
padding: 32rpx 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 总分卡 ─── */
|
||||||
|
.card-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.ring-box {
|
||||||
|
position: relative;
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
}
|
||||||
|
.ring-canvas {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
}
|
||||||
|
.ring-center {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; right: 0; bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sum-age {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.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%;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.prod-sub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #868686;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
.prod-img {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-more {
|
||||||
|
margin: 40rpx 32rpx 0;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<view class="report">
|
||||||
|
<view class="report-title">您的肌肤状态表现</view>
|
||||||
|
<view class="card card-summary">
|
||||||
|
<view class="sum-score"><view class="ring-box">
|
||||||
|
<canvas id="score-ring" width="120" height="120" class="ring-canvas" style="width:120px;height:120px;" />
|
||||||
|
<view class="ring-center"><view class="stat-value"><text class="stat-num">{{total != null ? total : '--'}}</text><text class="stat-unit">分</text></view><view class="stat-label">综合评分</view></view>
|
||||||
|
</view></view>
|
||||||
|
<view class="sum-age"><view class="stat-label">肌肤年龄</view><view class="stat-value"><text class="stat-num">{{skinAge != null ? skinAge : '--'}}</text><text class="stat-unit">岁</text></view><view class="age-pill" a:if="{{ageNote}}">{{ageNote}}</view></view>
|
||||||
|
</view>
|
||||||
|
<view class="card card-dims">
|
||||||
|
<view class="dims-row"><view class="dim {{item.rank}}" a:for="{{dims}}" a:key="key">
|
||||||
|
<view class="dim-name">{{item.name}}</view><view class="dim-score"><text class="dim-int">{{item.int}}</text><text class="dim-dec" a:if="{{item.dec}}">.{{item.dec}}</text></view>
|
||||||
|
<view class="dim-bar"><view class="dim-bar-fill" style="width:{{item.barWidth}}%;"></view></view><view class="dim-level">{{item.level}}</view>
|
||||||
|
<view class="dim-flag" a:if="{{item.rank === 'worst'}}">优先关注</view>
|
||||||
|
</view></view>
|
||||||
|
<view class="dims-note" a:if="{{focusName}}"><view>近期优先关注:{{focusName}}</view><view>{{focusSlogan}}</view></view>
|
||||||
|
</view>
|
||||||
|
<view class="card card-detail" a:if="{{views.length}}">
|
||||||
|
<scroll-view class="detail-tabs" scroll-x="{{true}}"><view class="detail-tabs-inner">
|
||||||
|
<view class="dtab {{index === viewIndex ? 'active' : ''}}" a:for="{{views}}" a:key="key" data-tab="{{index}}" onTap="onTabChange">{{item.name}}</view>
|
||||||
|
</view></scroll-view>
|
||||||
|
<block a:for="{{views}}" a:key="key">
|
||||||
|
<view class="ditem" a:if="{{index === viewIndex}}">
|
||||||
|
<view class="ditem-title">{{item.name}} <text class="detail-grade">{{item.score}} · {{item.level}}</text></view>
|
||||||
|
<view class="analysis-image" a:if="{{frontPhoto}}">
|
||||||
|
<image class="analysis-photo {{item.dim ? 'dark-photo' : ''}}" src="{{frontPhoto}}" mode="widthFix" data-index="{{index}}" onLoad="onPhotoLoad" />
|
||||||
|
<image class="analysis-layer" a:for="{{activeLayers}}" a:for-item="layer" a:key="*this" src="{{layer}}" mode="scaleToFill" style="opacity:{{item.alpha}};" />
|
||||||
|
<view class="mark-box" a:for="{{item.marks}}" a:for-item="mark" a:key="id" style="left:{{mark.left}}%;top:{{mark.top}}%;width:{{mark.width}}%;height:{{mark.height}}%;border-color:{{mark.color}};"></view>
|
||||||
|
</view>
|
||||||
|
<view class="side-views" a:if="{{item.key === 'wrinkles'}}">
|
||||||
|
<view class="side-view" a:for="{{sideViews}}" a:for-item="side" a:key="key">
|
||||||
|
<view>{{side.title}}</view><view class="analysis-image" a:if="{{side.photo}}">
|
||||||
|
<image class="analysis-photo dark-photo" src="{{side.photo}}" mode="widthFix" />
|
||||||
|
<image a:if="{{side.layer}}" class="analysis-layer" src="{{side.layer}}" mode="scaleToFill" />
|
||||||
|
<view class="side-loading" a:if="{{side.loading}}">标注生成中…</view>
|
||||||
|
</view><view class="side-note" a:if="{{side.note}}">{{side.note}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="ditem-para">{{item.text}}</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<view class="report-disclaimer">您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。本次检测仅供当前肌肤状态参考。</view>
|
||||||
|
<view class="card card-care"><view class="card-heading">护理建议</view>
|
||||||
|
<view class="care-item" a:for="{{careAdvice}}" a:key="key"><view class="care-name">{{item.name}}</view>{{item.text}}</view>
|
||||||
|
<view class="care-summary">好状态,是内外同调的长期节奏。</view>
|
||||||
|
</view>
|
||||||
|
<view class="card card-product" a:if="{{product}}"><view class="prod-card">
|
||||||
|
<view class="prod-info"><view class="prod-name">{{product.title}}</view><view class="prod-sub">{{product.name}}</view></view>
|
||||||
|
<image class="prod-img" src="{{product.image}}" mode="aspectFit" />
|
||||||
|
</view></view>
|
||||||
|
<button class="btn-primary btn-more" onTap="onHistory">查看历史报告</button>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
var api = require('../../utils/api'), report = require('../../utils/report-model'), config = require('../../config');
|
||||||
|
Page({
|
||||||
|
data: { total: null, skinAge: null, ageNote: '', dims: [], focusName: '', focusSlogan: '',
|
||||||
|
views: [], viewIndex: 0, frontPhoto: '', activeLayers: [], careAdvice: [], product: null, sideViews: [] },
|
||||||
|
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' }); }
|
||||||
|
this.renderSides(this.payload.wrinkle_views);
|
||||||
|
},
|
||||||
|
onReady: function () { this.ready = true; this.drawRing(this.data.total); this.loadSides(); },
|
||||||
|
onShow: function () {
|
||||||
|
if (!api.user() || this.ownerId !== api.user().id) {
|
||||||
|
this.onHide(); this.setData({ frontPhoto: '', views: [], sideViews: [], 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) });
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
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' ? '侧脸标注暂不可用' : '' };
|
||||||
|
}) });
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}).catch(function () {
|
||||||
|
if (that.alive && that.visible && !signal.canceled) that.renderSides({ left: { status: 'unavailable' }, right: { status: 'unavailable' } });
|
||||||
|
}).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();
|
||||||
|
},
|
||||||
|
onMore: function () {
|
||||||
|
var id = this.data.product && config.productItemIds[this.data.product.tier];
|
||||||
|
if (!id) return;
|
||||||
|
if (my.tb && my.tb.openDetail) my.tb.openDetail({ itemId: String(id) });
|
||||||
|
},
|
||||||
|
onHistory: function () { my.navigateTo({ url: '/pages/history/history' }); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"defaultTitle": "1'me在线肌肤测试",
|
||||||
|
"titleBarColor": "#FFFFFF",
|
||||||
|
"backgroundColor": "#EFEFEF"
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/* ═════ 启动页样式 ═════ */
|
||||||
|
.splash {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.splash-bg {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 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;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
height: 48rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
.brand-text {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 隐私授权弹窗 ─── */
|
||||||
|
.privacy-row {
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.popup-btn { width: 100%; }
|
||||||
|
|
||||||
|
/* ─── 隐私协议全文弹层 ─── */
|
||||||
|
.overlay-policy { padding: 0; }
|
||||||
|
.policy-page {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #FFFFFF;
|
||||||
|
}
|
||||||
|
.policy-back {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
left: 24rpx;
|
||||||
|
z-index: 2;
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 56rpx;
|
||||||
|
font-size: 56rpx;
|
||||||
|
color: #282828;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.policy-back::after { border: none; }
|
||||||
|
.policy-scroll {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 96rpx 40rpx 64rpx;
|
||||||
|
}
|
||||||
|
.policy-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
.policy-h2 {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 40rpx 0 16rpx;
|
||||||
|
}
|
||||||
|
.policy-p {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4A4A4A;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.policy-end {
|
||||||
|
text-align: center;
|
||||||
|
color: #868686;
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin: 48rpx 0;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!-- ═════ 01 启动页 ═════ -->
|
||||||
|
<view class="splash">
|
||||||
|
<view class="camera-debug-entry" a:if="{{showCameraDebug}}" onTap="onCameraDebug">开发调试 · 相机验收</view>
|
||||||
|
<image class="splash-bg" src="{{bgUrl}}" mode="aspectFill" />
|
||||||
|
<view class="splash-slogan">
|
||||||
|
<text>1'me,</text>
|
||||||
|
<text>FROM THE INSIDE OUT</text>
|
||||||
|
<text>以我为始 自有表现</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="splash-bottom">
|
||||||
|
<view class="brand-row">
|
||||||
|
<image src="{{logoUrl}}" class="brand-logo" mode="heightFix" />
|
||||||
|
</view>
|
||||||
|
<button class="btn-primary splash-btn" onTap="onOpenPrivacy">开始检测</button>
|
||||||
|
<view a:if="{{signedIn}}" class="account-links">
|
||||||
|
<text onTap="onHistory">历史报告</text><text onTap="onLogout">退出登录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ═══ 弹窗:隐私授权 ═══ -->
|
||||||
|
<view class="overlay" a:if="{{showPrivacy}}" onTap="onClosePrivacy">
|
||||||
|
<view class="popup" catchTap="noop">
|
||||||
|
<button class="popup-close" onTap="onClosePrivacy">✕</button>
|
||||||
|
<view class="popup-title">
|
||||||
|
<text>欢迎体验1'me</text>
|
||||||
|
<text>肌肤状态评估服务!</text>
|
||||||
|
</view>
|
||||||
|
<view class="popup-body">授权淘宝账号后,我们将采集您的面部影像和基础档案,用于生成肌肤分析报告,并在服务器保存检测记录及照片,供您查看最近5份报告。照片将交由 Face⁺⁺ 提供检测分析。</view>
|
||||||
|
<view class="privacy-row" onTap="onToggleAgree">
|
||||||
|
<view class="checkbox {{agreed ? 'checked' : ''}}"></view>
|
||||||
|
<view class="privacy-text">
|
||||||
|
我已阅读并同意
|
||||||
|
<text class="privacy-link" catchTap="onOpenPolicy">相关隐私协议</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button class="btn-primary popup-btn" loading="{{busy}}" disabled="{{busy}}" onTap="onAgreeContinue">授权登录并继续</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ═══ 弹层:隐私政策全文(Face++ 个人信息与隐私保护政策,与 H5 同文) ═══ -->
|
||||||
|
<view class="overlay overlay-policy" a:if="{{showPolicy}}">
|
||||||
|
<view class="policy-page">
|
||||||
|
<button class="policy-back" onTap="onClosePolicy">‹</button>
|
||||||
|
<scroll-view scroll-y class="policy-scroll">
|
||||||
|
<view class="policy-title">Face⁺⁺个人信息与隐私保护政策</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">前言</view>
|
||||||
|
<view class="policy-p">北京旷视科技有限公司及其关联公司(以下简称"我们"或"旷视")尊重并致力于保护个人信息与隐私。Face⁺⁺人工智能开放平台(以下简称"本平台")和/或Face⁺⁺人工智能开放平台服务(以下简称"本服务")作为旷视面向开发者的视觉智能开放平台与服务,严格参照《旷视个人信息与隐私保护政策》中的统一要求和标准处理相关个人信息;为进一步明确在本平台和本服务中可能涉及的个人信息处理实践情况,我们特制定了Face⁺⁺个人信息与隐私保护政策(以下简称"Face⁺⁺政策"),以介绍本平台和本服务中收集、使用、留存等个人信息的具体处理。</view>
|
||||||
|
<view class="policy-p">Face⁺⁺政策拟帮助客户和用户了解:我们如何收集使用个人信息、如何保留和披露、如何保护、个人信息在全球范围内的转移、您的个人信息权利、如何处理儿童的个人信息、如何联系我们、政策如何更新。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">一、我们如何收集使用个人信息</view>
|
||||||
|
<view class="policy-p">旷视将与客户一道,在符合《个人信息保护法》第十三条规定的合法性基础的前提下,确保所收集和处理的个人信息已取得您的完整、有效授权同意。</view>
|
||||||
|
<view class="policy-p">为提供人脸和人体图像识别相关技术的能力,我们将基于客户主动提供的原始图片、视频等数据进一步识别和提取面部识别特征、人体区域、人体特征和关键点、手势等生物识别信息,用于实现人脸比对、人脸检测、人脸搜索、人脸美颜、人脸融合等具体功能。请您理解,如您拒绝提供原始图片、视频等数据,前述功能应用将无法实现。</view>
|
||||||
|
<view class="policy-p">提请您注意的是,该等功能项下处理的面部识别特征等生物识别信息属于个人敏感信息,只有在具有特定的目的和充分的必要性、并采取严格保护措施的情形下,我们才会进行处理。我们将依法向您单独告知处理该等敏感个人信息的必要性以及对您个人权益的影响,并在您通过弹窗、勾选等明示方式单独同意后,方进行该等处理;您拒绝单独同意的,我们将不处理您的上述生物识别信息,但不影响您使用本平台和本服务的其他功能。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">二、我们如何保留和披露您的个人信息</view>
|
||||||
|
<view class="policy-p">除法律法规另有规定外,我们将在实现处理目的所必要的最短时间内保留您的个人信息,并通过本政策或相关服务规则向您明确告知各类个人信息的具体保存期限。在本平台和本服务下,我们主要提供技术能力的具体应用,因此在具体功能实现后,对于客户提供的原始图片、视频等数据以及基于该等原始数据形成的服务成果,我们将尽快删除或进行匿名化处理。此外,根据《网络安全法》等强制性法律法规要求,我们将切实保留网络日志不少于六个月。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">三、我们如何保护您的个人信息</view>
|
||||||
|
<view class="policy-p">我们已采取了合理、可行的管理措施和技术措施,以保护所处理的个人信息并应对个人信息安全事件,包括:及时对个人信息进行去标识化处理;定期审查个人信息处理方式并不断强化 API 和 SDK 等技术工具的安全性;实施传输全程安全加密等保障手段。如发生或者可能发生个人信息泄露、篡改、丢失的,我们将立即采取补救措施,并按照法律法规要求及时通知您。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">四、个人信息在全球范围内的转移</view>
|
||||||
|
<view class="policy-p">我们的服务器位于中国和加拿大。由于中国法律法规要求,原则上,在中国境内收集和产生的个人信息将统一在中国境内服务器进行处理。对于中国境外的业务,根据客户的选择,您的个人信息可能转移至位于中国或加拿大的服务器上进行处理。如确有必要向境外提供您的个人信息,我们将遵守《个人信息保护法》及数据出境相关法律法规的规定。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">五、您的个人信息权利</view>
|
||||||
|
<view class="policy-p">您依法享有知情权、访问权、更正权、删除权、注销账户权、拒绝自动决策权、解释权、可携权、查询权、撤销权。您可以通过具体产品或服务中设置的权限管理或隐私设置功能直接撤回相应授权,也可以通过发送邮件至 business@megvii.com 提交相关请求,我们将在核实您的身份后及时处理。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">六、我们如何处理儿童的个人信息</view>
|
||||||
|
<view class="policy-p">我们的所有产品、网站和服务主要面向企业客户,并不会自行主动收集、处理儿童个人信息。如我们发现任何客户或用户在未经父母或监护人同意的情况下提供包含儿童个人信息的原始数据,我们将立即删除该等原始数据并不会就该等数据提供任何服务。为免歧义,我们将不满14周岁的任何人均视为儿童。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">七、如何联系我们</view>
|
||||||
|
<view class="policy-p">如您对本政策及您的个人信息存在任何相关问题、申/投诉,或您需要任何个人信息权利相关的协助,请及时联系我们。您可以通过向 business@megvii.com 发送邮件联系我们,并提供您的姓名(名称)和联系方式、详细请求及相应链接。一般而言,我们会在15个工作日内或法律法规规定的期限内尽快回复。</view>
|
||||||
|
|
||||||
|
<view class="policy-h2">八、Face⁺⁺政策如何更新</view>
|
||||||
|
<view class="policy-p">我们保留不时更新或修改 Face⁺⁺政策的权利。但未经您明确同意,我们不会削减您按照 Face⁺⁺政策所应享有的权利。对于重大变更,我们会提供更为显著的通知。您可通过本页面查看最新版本的 Face⁺⁺政策。</view>
|
||||||
|
|
||||||
|
<view class="policy-end">—— 全文完 ——</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/* ═════ 启动页:品牌底图 + Slogan + 隐私授权弹窗(对齐 H5 #s-splash + #p-privacy) ═════ */
|
||||||
|
var api = require('../../utils/api.js');
|
||||||
|
var development = require('../../utils/development');
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
bgUrl: api.assetUrl('img/bg-splash.jpg'), // 底图远程加载
|
||||||
|
logoUrl: api.assetUrl('img/ui-20261012/brand-lockup.png'),
|
||||||
|
showPrivacy: false, // 隐私授权弹窗
|
||||||
|
agreed: false, // 隐私协议勾选
|
||||||
|
showPolicy: false, busy: false, signedIn: false, showCameraDebug: development.cameraDebugEnabled()
|
||||||
|
},
|
||||||
|
onShow: function () {
|
||||||
|
var that = this;
|
||||||
|
api.restore().then(function (user) { that.setData({ signedIn: !!user }); }).catch(function () {});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 「开始检测」→ 隐私授权弹窗
|
||||||
|
onOpenPrivacy: function () {
|
||||||
|
if (api.user()) { my.navigateTo({ url: '/pages/profile/profile' }); return; }
|
||||||
|
this.setData({ showPrivacy: true });
|
||||||
|
},
|
||||||
|
onClosePrivacy: function () {
|
||||||
|
this.setData({ showPrivacy: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 勾选/取消隐私协议(点链接不触发,由 catchtap 分隔)
|
||||||
|
onToggleAgree: function () {
|
||||||
|
this.setData({ agreed: !this.data.agreed });
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpenPolicy: function () {
|
||||||
|
this.setData({ showPolicy: true });
|
||||||
|
},
|
||||||
|
onClosePolicy: function () {
|
||||||
|
this.setData({ showPolicy: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 「继续」:必须已勾选协议
|
||||||
|
onAgreeContinue: function () {
|
||||||
|
if (this.data.busy) return;
|
||||||
|
if (!this.data.agreed) {
|
||||||
|
my.showToast({ content: '请先阅读并同意隐私协议', type: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var that = this;
|
||||||
|
this.setData({ busy: true });
|
||||||
|
api.login(true).then(function () {
|
||||||
|
that.setData({ showPrivacy: false, signedIn: true });
|
||||||
|
my.navigateTo({ url: '/pages/profile/profile' });
|
||||||
|
}).catch(function (err) {
|
||||||
|
my.showToast({ content: err.message, type: 'none' });
|
||||||
|
}).then(function () { that.setData({ busy: false }); });
|
||||||
|
},
|
||||||
|
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' }); });
|
||||||
|
},
|
||||||
|
onCameraDebug: function () {
|
||||||
|
if (!development.cameraDebugEnabled()) return;
|
||||||
|
my.navigateTo({ url: '/pages/camera/camera?debug=camera' });
|
||||||
|
},
|
||||||
|
|
||||||
|
noop: function () {} // 弹窗卡片内点击拦截冒泡用
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"defaultTitle": "1'me在线肌肤测试",
|
||||||
|
"titleBarColor": "#101823",
|
||||||
|
"backgroundColor": "#101823"
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use strict';
|
||||||
|
// Local-only fixture backend and gateway simulator. Never contacts Face++/Taobao.
|
||||||
|
const http = require('node:http'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||||
|
const root = path.resolve(__dirname, '../..');
|
||||||
|
const dataRoot = path.join(root, 'temp/taobao-local-data');
|
||||||
|
Object.assign(process.env, { NODE_ENV: 'test', PORT: '18181', HOST: '127.0.0.1', PUBLIC_BASE_URL: '',
|
||||||
|
JUZI_DATA_ROOT: dataRoot, WECHAT_APP_ID: '', WECHAT_APP_SECRET: '',
|
||||||
|
SKIN_API_KEY: 'local-fixture', SKIN_API_SECRET: 'local-fixture',
|
||||||
|
TAOBAO_APP_KEY: 'local-fixture', TAOBAO_APP_SECRET: 'local-fixture-not-a-production-secret' });
|
||||||
|
const context = { window: {} };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), context);
|
||||||
|
const fixture = JSON.parse(JSON.stringify(context.window.MOCK_FIXTURE));
|
||||||
|
global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ request_id: 'local-fixture',
|
||||||
|
result: structuredClone(fixture.result), face_rectangle: fixture.face_rectangle,
|
||||||
|
left_side_result: fixture.side.left, right_side_result: fixture.side.right }) });
|
||||||
|
require(path.join(root, 'h5/server'));
|
||||||
|
const auth = require(path.join(root, 'h5/lib/taobao-auth'));
|
||||||
|
http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
if (url.pathname === '/dev/health') {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
return res.end(JSON.stringify({ ok: true, fixture: true, data_root: dataRoot }));
|
||||||
|
}
|
||||||
|
const chunks = []; let length = 0;
|
||||||
|
for await (const chunk of req) {
|
||||||
|
length += chunk.length;
|
||||||
|
if (length > 128 * 1024) { res.writeHead(413); return res.end(); }
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (url.pathname === '/api/taobao/session') {
|
||||||
|
url.search = new URLSearchParams({ app_key: process.env.TAOBAO_APP_KEY,
|
||||||
|
open_id: 'local-development-user', sign_method: 'md5' }).toString();
|
||||||
|
const params = Object.fromEntries(url.searchParams);
|
||||||
|
url.searchParams.set('sign', auth.signature(params, raw, process.env.TAOBAO_APP_SECRET));
|
||||||
|
}
|
||||||
|
const headers = { ...req.headers, host: '127.0.0.1:18181', 'content-length': Buffer.byteLength(raw) };
|
||||||
|
delete headers.origin;
|
||||||
|
const upstream = http.request({ hostname: '127.0.0.1', port: 18181, path: url.pathname + url.search,
|
||||||
|
method: req.method, headers }, upstreamRes => {
|
||||||
|
res.writeHead(upstreamRes.statusCode, upstreamRes.headers); upstreamRes.pipe(res);
|
||||||
|
});
|
||||||
|
upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
||||||
|
upstream.end(raw);
|
||||||
|
}).listen(18180, '127.0.0.1', () => {
|
||||||
|
console.log('[local] Gateway simulator + H5: http://127.0.0.1:18180');
|
||||||
|
console.log('[local] Fixture data only; account/report files: ' + dataRoot);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
||||||
|
const root = path.resolve(__dirname, '..'), check = process.argv.includes('--check');
|
||||||
|
const files = ['skin-score.js', 'report-ui.js', 'report-copy.js'];
|
||||||
|
const manifest = {};
|
||||||
|
for (const name of files) {
|
||||||
|
const source = fs.readFileSync(path.join(root, '../h5/js', name));
|
||||||
|
const dest = path.join(root, 'vendor', name);
|
||||||
|
// Git may convert line endings on Windows; manifest hashes stay portable.
|
||||||
|
manifest[name] = crypto.createHash('sha256').update(source.toString('utf8').replace(/\r\n/g, '\n')).digest('hex');
|
||||||
|
if (check) {
|
||||||
|
if (!fs.existsSync(dest) || !source.equals(fs.readFileSync(dest))) throw Error('Shared logic drift: run npm run sync:shared (' + name + ')');
|
||||||
|
} else { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, source); }
|
||||||
|
}
|
||||||
|
const dest = path.join(root, 'vendor/manifest.json'), expected = JSON.stringify(manifest, null, 2) + '\n';
|
||||||
|
if (check) { if (fs.readFileSync(dest, 'utf8') !== expected) throw Error('Shared manifest drift'); }
|
||||||
|
else fs.writeFileSync(dest, expected);
|
||||||
|
console.log(check ? 'PASS: scoring, report UI and copy match H5 byte for byte' : 'Synced shared logic from H5 (source files unchanged)');
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||||
|
const { spawn } = require('node:child_process'), { once } = require('node:events');
|
||||||
|
const root = path.resolve(__dirname, '../..'), auth = require('../../h5/lib/taobao-auth');
|
||||||
|
const env = { TAOBAO_APP_KEY: 'fixture-app', TAOBAO_APP_SECRET: 'fixture-secret' };
|
||||||
|
const dir = fs.mkdtempSync(path.join(root, 'temp/taobao-integration-'));
|
||||||
|
const port = 18201, base = 'http://127.0.0.1:' + port;
|
||||||
|
let child, wechat, providerCalls = 0;
|
||||||
|
const fixtureContext = { window: {} };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fixtureContext);
|
||||||
|
const fixture = fixtureContext.window.MOCK_FIXTURE;
|
||||||
|
async function post(route, body = {}, token, params, cookie) {
|
||||||
|
const raw = JSON.stringify(body), q = { ...params };
|
||||||
|
if (params) q.sign = auth.signature(q, raw, env.TAOBAO_APP_SECRET);
|
||||||
|
const res = await fetch(base + route + (params ? '?' + new URLSearchParams(q) : ''), {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(cookie ? { cookie } : {}) }, body: raw });
|
||||||
|
return { response: res, data: await res.json() };
|
||||||
|
}
|
||||||
|
async function login(openId) {
|
||||||
|
const challenge = (await post('/api/taobao/challenge')).data.data.challenge;
|
||||||
|
const body = { consent: true, challenge };
|
||||||
|
const params = { app_key: env.TAOBAO_APP_KEY, open_id: openId, sign_method: 'md5' };
|
||||||
|
const result = await post('/api/taobao/session', body, null, params);
|
||||||
|
assert.equal(result.data.data.ok, true);
|
||||||
|
return { ...result.data.data, body, params };
|
||||||
|
}
|
||||||
|
async function upload(token) {
|
||||||
|
const refs = {};
|
||||||
|
for (const [i, view] of ['front', 'left', 'right'].entries()) {
|
||||||
|
const b64 = (fixture.photos[i] || fixture.photos[0]).split(',')[1], total = Math.ceil(b64.length / 65536), id = view + '-fixture-' + Date.now();
|
||||||
|
for (let index = 0; index < total; index++) {
|
||||||
|
const result = await post('/api/taobao/photo', { view, upload_id: id, index, total, data: b64.slice(index * 65536, (index + 1) * 65536) }, token);
|
||||||
|
assert.equal(result.data.data.ok, true);
|
||||||
|
assert.equal(result.data.data.complete, index === total - 1);
|
||||||
|
}
|
||||||
|
refs[view] = id;
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
async function poll(token, job) {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const result = (await post('/api/taobao/analysis', { job }, token)).data.data;
|
||||||
|
if (result.status === 'complete') return result.payload;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
|
}
|
||||||
|
throw Error('job did not finish');
|
||||||
|
}
|
||||||
|
before(async () => {
|
||||||
|
wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(dir, 'accounts'))[0];
|
||||||
|
child = spawn(process.execPath, ['-e', `
|
||||||
|
const fs=require('fs'),vm=require('vm'),w={};
|
||||||
|
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
|
||||||
|
global.fetch=async()=>{fs.appendFileSync(process.env.JUZI_DATA_ROOT+'/provider-count','1\\n');await new Promise(r=>setTimeout(r,120));return {ok:true,status:200,json:async()=>({request_id:'fixture',result:structuredClone(w.MOCK_FIXTURE.result),face_rectangle:w.MOCK_FIXTURE.face_rectangle,left_side_result:w.MOCK_FIXTURE.side.left,right_side_result:w.MOCK_FIXTURE.side.right})};};
|
||||||
|
require('./server');
|
||||||
|
`], { cwd: path.join(root, 'h5'), env: { ...process.env, ...env, NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1',
|
||||||
|
JUZI_DATA_ROOT: dir, PUBLIC_BASE_URL: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(Error('backend startup timeout')), 15000);
|
||||||
|
child.stdout.on('data', data => { if (data.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
|
||||||
|
child.once('exit', code => { clearTimeout(timer); reject(Error('backend exited ' + code)); });
|
||||||
|
child.stderr.on('data', data => process.stderr.write(data));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
if (child && child.exitCode == null) { const exit = once(child, 'exit'); child.kill(); await exit; }
|
||||||
|
assert(dir.startsWith(path.join(root, 'temp') + path.sep)); fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
test('signed Taobao identities, replay defense, H5 isolation, shared report contract, persisted history and channel stats', async () => {
|
||||||
|
const unsigned = (await post('/api/taobao/session', { consent: true, challenge: 'fake', open_id: 'forged-user' })).data.data;
|
||||||
|
assert.equal(unsigned.http_status, 401);
|
||||||
|
const alice = await login('fixture-alice'), bob = await login('fixture-bob');
|
||||||
|
assert.notEqual(alice.user.id, bob.user.id);
|
||||||
|
assert.equal((await login('fixture-alice')).user.id, alice.user.id, 'same platform account maps to stable identity');
|
||||||
|
assert.equal((await post('/api/taobao/session', alice.body, null, alice.params)).data.data.http_status, 401, 'signed login cannot replay');
|
||||||
|
const h5Session = await (await fetch(base + '/api/auth/session', { headers: { cookie: 'juzi_session=' + alice.token } })).json();
|
||||||
|
assert.equal(h5Session.user, null, 'Taobao credentials cannot become a WeChat/H5 cookie');
|
||||||
|
assert.equal((await post('/api/taobao/me', {}, wechat.token)).data.data.http_status, 401);
|
||||||
|
const refs = await upload(alice.token);
|
||||||
|
assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs }, bob.token)).data.data.http_status, 409, 'photos are owner bound');
|
||||||
|
const profile = { real_age: 44, gender: '女', region: '上海市', daily_skin_type: '干性肌肤' };
|
||||||
|
const started = (await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data;
|
||||||
|
assert.equal(started.http_status, 202);
|
||||||
|
assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data.http_status, 409, 'consumed photos cannot charge twice');
|
||||||
|
assert.equal((await post('/api/taobao/analysis', { job: started.job }, bob.token)).data.data.http_status, 404);
|
||||||
|
const result = await poll(alice.token, started.job);
|
||||||
|
assert.equal(result.ok, true); assert.equal(result.report_saved, true);
|
||||||
|
const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui');
|
||||||
|
const expected = SS.buildViewModel(result.result, result.side, { realAge: 44, skinType: '干性肌肤' });
|
||||||
|
assert.deepEqual(result.vm, expected); assert.deepEqual(result.presentation, UI.build(expected, result.result));
|
||||||
|
assert.equal(result.presentation.views.length, 7); assert.equal(result.presentation.care.length, 3);
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, 1, 'primary report does not start side calls');
|
||||||
|
const history = (await post('/api/taobao/reports', {}, alice.token)).data.data;
|
||||||
|
assert.equal(history.total, 1);
|
||||||
|
assert.equal((await post('/api/taobao/reports', { id: result.record_id }, bob.token)).data.data.http_status, 404);
|
||||||
|
assert.equal((await post('/api/taobao/reports', {}, bob.token)).data.data.total, 0);
|
||||||
|
assert.equal((await post('/api/taobao/reports', { id: result.record_id }, alice.token)).data.data.report.id, result.record_id);
|
||||||
|
const sideJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data;
|
||||||
|
assert.equal((await poll(alice.token, sideJob.job)).ok, true);
|
||||||
|
const count = fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length;
|
||||||
|
const cachedJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data;
|
||||||
|
await poll(alice.token, cachedJob.job);
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, count, 'side requests are cached');
|
||||||
|
const h5Body = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile };
|
||||||
|
const h5Wechat = await post('/api/skin-analyze', h5Body, null, null, 'juzi_session=' + wechat.token);
|
||||||
|
assert.equal(h5Wechat.data.ok, true); assert.equal(h5Wechat.data.report_saved, true);
|
||||||
|
assert.deepEqual(h5Wechat.data.vm, result.vm); assert.deepEqual(h5Wechat.data.presentation, result.presentation);
|
||||||
|
const guestLogin = await post('/api/auth/browser', { consent: true });
|
||||||
|
const cookie = guestLogin.response.headers.getSetCookie().map(c => c.split(';')[0]).join(';');
|
||||||
|
const recordsBefore = fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8');
|
||||||
|
const guest = await post('/api/skin-analyze', { ...h5Body, channel: 'taobao' }, null, null, cookie);
|
||||||
|
assert.equal(guest.data.ok, true); assert.equal(guest.data.report_saved, false);
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8'), recordsBefore, 'spoofed channel cannot change H5 guest retention');
|
||||||
|
const stats = await (await fetch(base + '/api/admin/stats')).json();
|
||||||
|
assert.equal(stats.by_channel.taobao.total, 1); assert.equal(stats.by_channel.h5.total, 1);
|
||||||
|
const filtered = await (await fetch(base + '/api/admin/records?channel=taobao')).json();
|
||||||
|
assert.equal(filtered.total, 1); assert.equal(filtered.items[0].channel, 'taobao');
|
||||||
|
const csv = await (await fetch(base + '/api/admin/export?channel=taobao')).text();
|
||||||
|
const csvLines = csv.trim().split('\n');
|
||||||
|
assert.equal(csvLines.length, 2); assert.equal(csvLines[0].split(',')[2], '年龄', 'existing CSV column positions remain stable');
|
||||||
|
assert.equal(csvLines[0].split(',').at(-1).trim(), '渠道'); assert.equal(csvLines[1].split(',').at(-1).trim(), 'taobao');
|
||||||
|
const oldAccounts = require('../../h5/lib/accounts')(path.join(dir, 'accounts'), {});
|
||||||
|
assert.equal(oldAccounts.taobaoSession({ headers: { authorization: 'Bearer ' + alice.token } }).id, alice.user.id, 'restart reloads account/session');
|
||||||
|
assert.equal(oldAccounts.reports(alice.user.id).length, 1);
|
||||||
|
await post('/api/taobao/logout', {}, bob.token);
|
||||||
|
assert.equal((await post('/api/taobao/me', {}, bob.token)).data.data.http_status, 401);
|
||||||
|
});
|
||||||
|
test('native API client logs in, uploads within gateway limit, polls one detection, restores identity and honors cancellation', async () => {
|
||||||
|
const config = require('../config'), storage = new Map();
|
||||||
|
config.baseUrl = base; config.transport = 'direct'; config.basePath = '';
|
||||||
|
let requests = 0;
|
||||||
|
global.my = {
|
||||||
|
setStorageSync({ key, data }) { storage.set(key, data); },
|
||||||
|
getStorageSync({ key }) { return { data: storage.get(key) }; },
|
||||||
|
removeStorageSync({ key }) { storage.delete(key); },
|
||||||
|
request(options) {
|
||||||
|
requests++; assert(Buffer.byteLength(options.data) < 128 * 1024);
|
||||||
|
const url = new URL(options.url);
|
||||||
|
if (url.pathname === '/api/taobao/session') {
|
||||||
|
const params = { app_key: env.TAOBAO_APP_KEY, open_id: 'native-client-user', sign_method: 'md5' };
|
||||||
|
params.sign = auth.signature(params, options.data, env.TAOBAO_APP_SECRET);
|
||||||
|
url.search = new URLSearchParams(params).toString();
|
||||||
|
}
|
||||||
|
fetch(url, { method: options.method, headers: options.headers, body: options.data }).then(async res => {
|
||||||
|
options.success({ status: res.status, data: await res.json() });
|
||||||
|
}).catch(options.fail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const api = require('../utils/api');
|
||||||
|
const user = await api.login(true); assert.equal(user.kind, 'taobao');
|
||||||
|
const result = await api.analyze([fixture.photos[0], fixture.photos[0], fixture.photos[0]], { real_age: 30 }, { canceled: false });
|
||||||
|
assert.equal(result.ok, true); assert.equal(result.report_saved, true);
|
||||||
|
assert.equal((await api.request('reports')).total, 1);
|
||||||
|
assert.equal((await api.restore()).id, user.id);
|
||||||
|
const before = requests;
|
||||||
|
await assert.rejects(api.analyze(['unused', 'unused', 'unused'], {}, { canceled: true }), err => err.canceled === true);
|
||||||
|
assert.equal(requests, before, 'cancelled pipeline does not send photos or start a paid job');
|
||||||
|
await api.logout(); assert.equal(await api.restore(), null);
|
||||||
|
delete global.my;
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||||
|
const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui');
|
||||||
|
const base = path.resolve(__dirname, '..');
|
||||||
|
function page(file, deps = {}, extra = {}) {
|
||||||
|
let definition; const timers = [];
|
||||||
|
const context = { Page(value) { definition = value; }, require(name) {
|
||||||
|
if (name in deps) return deps[name];
|
||||||
|
return require(path.resolve(path.dirname(path.join(base, file)), name));
|
||||||
|
}, setTimeout(fn) { timers.push(fn); return timers.length; }, clearTimeout(id) { timers[id - 1] = null; },
|
||||||
|
Promise, console, Date, ...extra };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(base, file), 'utf8'), context);
|
||||||
|
const instance = Object.assign({}, definition, { data: JSON.parse(JSON.stringify(definition.data)),
|
||||||
|
setData(patch, cb) {
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
const pieces = key.replace(/\[(\d+)\]/g, '.$1').split('.'); let target = this.data;
|
||||||
|
for (const piece of pieces.slice(0, -1)) target = target[piece];
|
||||||
|
target[pieces.at(-1)] = value;
|
||||||
|
}
|
||||||
|
if (cb) cb();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { instance, timers, context };
|
||||||
|
}
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
test('offline camera debug skips identity/profile, never uploads or plays remote voice, and clears previews on exit', async () => {
|
||||||
|
let backendCalls = 0, voiceCalls = 0, exited = 0, authReads = 0;
|
||||||
|
const voice = { play() { voiceCalls++; }, stop() {}, VOICE: { step: [] } };
|
||||||
|
const camera = page('pages/camera/camera.js', {
|
||||||
|
'../../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/image': { compressToBase64: async (photo, p) => {
|
||||||
|
p.setData({ compressWidth: 1280, compressHeight: 960 }); return 'data:image/jpeg;base64,/9j/';
|
||||||
|
} }
|
||||||
|
}, { getApp() { throw Error('debug must not read/write global report state'); },
|
||||||
|
my: { navigateBack() { exited++; }, redirectTo() { throw Error('debug must not redirect to login/report'); }, showToast() {} } });
|
||||||
|
const p = camera.instance;
|
||||||
|
p.onLoad({ debug: 'camera' }); p.onReady(); p.onShow(); await flush();
|
||||||
|
assert.equal(p.data.debugMode, true); assert.equal(authReads, 0);
|
||||||
|
for (const [i, photo] of ['front.jpg', 'right.jpg', 'left.jpg'].entries()) {
|
||||||
|
p.setData({ previewUrl: photo }); p.onConfirm(); if (i < 2) camera.timers[i]();
|
||||||
|
}
|
||||||
|
await flush(); assert.equal(p.data.debugComplete, true);
|
||||||
|
assert.deepEqual(Array.from(p.data.debugPhotos, v => v.key), ['front', 'right', 'left']);
|
||||||
|
assert(p.data.debugPhotos.every(v => v.width === 1280 && v.height === 960));
|
||||||
|
assert.equal(backendCalls, 0); assert.equal(voiceCalls, 0);
|
||||||
|
p.onCancel(); assert.equal(p.data.debugPhotos.length, 0); assert.equal(p.photos.length, 0); assert.equal(exited, 1);
|
||||||
|
});
|
||||||
|
test('camera debug URL cannot bypass normal login when the development gate is off', () => {
|
||||||
|
let redirected = 0;
|
||||||
|
const p = page('pages/camera/camera.js', {
|
||||||
|
'../../utils/development': { cameraDebugEnabled: () => false },
|
||||||
|
'../../utils/api': { user: () => null }
|
||||||
|
}, { my: { redirectTo() { redirected++; } } }).instance;
|
||||||
|
p.onLoad({ debug: 'camera' }); assert.equal(redirected, 1); assert.equal(p.data.debugMode, false);
|
||||||
|
});
|
||||||
|
test('development entry requires an explicit flag, development environment and local direct transport', () => {
|
||||||
|
const config = require('../config'), original = { ...config }, development = require('../utils/development');
|
||||||
|
try {
|
||||||
|
Object.assign(config, { environment: 'development', cameraDebug: true, transport: 'direct', baseUrl: 'http://127.0.0.1:18180' });
|
||||||
|
assert.equal(development.cameraDebugEnabled(), true);
|
||||||
|
for (const patch of [{ environment: 'production' }, { cameraDebug: false }, { transport: 'cloud' }, { baseUrl: 'https://skin.example.test' }]) {
|
||||||
|
const saved = { ...config }; Object.assign(config, patch); assert.equal(development.cameraDebugEnabled(), false); Object.assign(config, saved);
|
||||||
|
}
|
||||||
|
} finally { Object.assign(config, original); }
|
||||||
|
});
|
||||||
|
test('Taobao report uses current H5 totals, displayed age, names, seven views, care selection and product', () => {
|
||||||
|
const report = require('../utils/report-model');
|
||||||
|
for (const realAge of [24, 44, 60]) {
|
||||||
|
const result = structuredClone(SS.MOCK_RESULT), side = structuredClone(SS.MOCK_SIDE);
|
||||||
|
const model = SS.buildViewModel(result, side, { realAge, skinType: '干性肌肤' });
|
||||||
|
const display = UI.build(model, result), bytes = JSON.stringify(model);
|
||||||
|
const rendered = report.model({ result, side, vm: model, presentation: display }, ['front'], {});
|
||||||
|
assert.equal(rendered.total, SS.weightedTotal(model.dims)); assert.equal(rendered.skinAge, SS.reportSkinAge(model, result));
|
||||||
|
assert.deepEqual(rendered.dims.map(d => d.name), ['紧塑力', '透亮感', '稳韧力']);
|
||||||
|
assert.deepEqual(rendered.views.map(v => v.key), ['contour', 'wrinkles', 'color', 'smooth', 'water', 'sens', 'acne']);
|
||||||
|
assert.deepEqual(rendered.careAdvice, display.care); assert.equal(rendered.product?.tier, display.product?.tier);
|
||||||
|
assert.equal(JSON.stringify(model), bytes, 'rendering does not mutate saved scores');
|
||||||
|
}
|
||||||
|
assert.deepEqual(report.marks({ acne: { rectangle: [{ left: 10, top: 20, width: 20, height: 40 }] } }, 'acne', 100, 200)[0],
|
||||||
|
{ id: 0, left: 10, top: 10, width: 20, height: 20, color: '#D30C0C' });
|
||||||
|
assert.equal(report.mapURL('https://untrusted.example/fake-map'), '');
|
||||||
|
});
|
||||||
|
test('camera blocks duplicate shots/confirm, cancels late callbacks and submits correct left/right order with one shared canvas', async () => {
|
||||||
|
let captured, calls = 0, submitted = 0, ordered, resolver, navigated = 0;
|
||||||
|
const app = { globalData: { profile: { real_age: 30 } } };
|
||||||
|
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 () => {} },
|
||||||
|
'../../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++; } }
|
||||||
|
});
|
||||||
|
const p = camera.instance; p.onLoad(); p.onReady(); p.onShow();
|
||||||
|
await flush();
|
||||||
|
p.onShutter(); p.onShutter(); assert.equal(calls, 1);
|
||||||
|
captured.success({ tempImagePath: 'front' }); captured.complete(); p.onConfirm(); p.onConfirm();
|
||||||
|
assert.equal(p.data.stepIndex, 1); camera.timers[0]();
|
||||||
|
p.setData({ previewUrl: 'right' }); p.onConfirm(); camera.timers[1]();
|
||||||
|
p.setData({ previewUrl: 'left' }); p.onConfirm(); p.onConfirm();
|
||||||
|
await flush(); assert.equal(submitted, 1); assert.deepEqual(Array.from(ordered), ['front-base64', 'left-base64', 'right-base64']);
|
||||||
|
p.onUnload(); resolver({ result: {} }); await flush(); assert.equal(navigated, 0, 'late result cannot navigate after leaving camera');
|
||||||
|
assert.equal(app.globalData.payload, undefined);
|
||||||
|
});
|
||||||
|
test('image pipeline resizes large images to at most 1280 and exports real JPEG before base64 reading', async () => {
|
||||||
|
let module = { exports: {} }, dimensions, exported;
|
||||||
|
const context = { module, Promise, my: {
|
||||||
|
getImageInfo(opts) { opts.success({ width: 4000, height: 3000 }); },
|
||||||
|
createCanvasContext() { return { clearRect() {}, drawImage(src, x, y, w, h) { dimensions = [w, h]; },
|
||||||
|
draw(reserve, cb) { cb(); }, toTempFilePath(opts) { exported = opts; opts.success({ apFilePath: 'resized.jpg' }); } }; },
|
||||||
|
getFileSystemManager() { return { readFile(opts) { assert.equal(opts.filePath, 'resized.jpg'); opts.success({ data: '/9j/test' }); } }; }
|
||||||
|
} };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(base, 'utils/image.js'), 'utf8'), context);
|
||||||
|
const result = await module.exports.compressToBase64('raw.heic', { setData(data, cb) { cb(); } });
|
||||||
|
assert.deepEqual(dimensions, [1280, 960]); assert.equal(exported.fileType, 'jpg'); assert.equal(exported.quality, 0.88);
|
||||||
|
assert.equal(result, 'data:image/jpeg;base64,/9j/test');
|
||||||
|
});
|
||||||
|
test('province changes reset cities, canceled choices do not leak into reopening, and profiles stay account scoped', () => {
|
||||||
|
let saved;
|
||||||
|
const api = { assetUrl: v => v, user: () => ({ id: 'account-a' }) }, app = { globalData: {} };
|
||||||
|
const p = page('pages/profile/profile.js', { '../../utils/api.js': api }, { getApp: () => app,
|
||||||
|
my: { setStorageSync(value) { saved = value; }, navigateTo() {}, getStorageSync() { return {}; } } }).instance;
|
||||||
|
p.onLoad(); p.onOpenRegion(); p.onPickerChange({ detail: { value: [2, 8] } });
|
||||||
|
assert.equal(p.data.pickerValue[1], 0); p.onPickerConfirm(); assert.equal(p.data.regionIndex[1], 0);
|
||||||
|
const committedCities = Array.from(p.data.regionRange[1]);
|
||||||
|
p.onOpenRegion(); p.onPickerChange({ detail: { value: [3, 2] } }); p.onPickerClose();
|
||||||
|
p.onOpenRegion();
|
||||||
|
assert.deepEqual(Array.from(p.data.pickerValue), [2, 0]);
|
||||||
|
assert.deepEqual(Array.from(p.data.regionRange[1]), committedCities, 'reopened city list must match the committed province');
|
||||||
|
p.setData({ birth: '1996-01', age: 30, birthText: '1996年1月', genderText: '女' });
|
||||||
|
p.onStartCamera(); assert.equal(saved.key, 'juzi.taobao.profile.account-a'); assert.equal(app.globalData.profile.real_age, 30);
|
||||||
|
});
|
||||||
|
test('native AXML event handlers resolve and all routes have JS/AXML/ACSS/JSON files', () => {
|
||||||
|
const pages = require('../app.json').pages;
|
||||||
|
for (const route of pages) {
|
||||||
|
for (const ext of ['js', 'axml', 'acss', 'json']) assert(fs.existsSync(path.join(base, route + '.' + ext)), route + '.' + ext);
|
||||||
|
const source = fs.readFileSync(path.join(base, route + '.js'), 'utf8');
|
||||||
|
const markup = fs.readFileSync(path.join(base, route + '.axml'), 'utf8');
|
||||||
|
for (const match of markup.matchAll(/(?:on[A-Z]\w*|catch[A-Z]\w*)="([A-Za-z]\w*)"/g)) {
|
||||||
|
assert(new RegExp('\\b' + match[1] + '\\s*:\\s*function').test(source), route + ' missing ' + match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
const { spawnSync } = require('node:child_process'), path = require('node:path'), fs = require('node:fs');
|
||||||
|
const root = path.resolve(__dirname, '../..'), artifacts = path.join(root, 'temp/taobao-h5-compatibility');
|
||||||
|
const gitOpenSSL = 'C:\\Program Files\\Git\\usr\\bin';
|
||||||
|
const testEnv = { ...process.env, H5_ARTIFACT_DIR: artifacts };
|
||||||
|
if (process.platform === 'win32' && fs.existsSync(path.join(gitOpenSSL, 'openssl.exe'))) testEnv.Path = gitOpenSSL + path.delimiter + (process.env.Path || process.env.PATH);
|
||||||
|
fs.mkdirSync(artifacts, { recursive: true });
|
||||||
|
for (const name of ['guest-privacy.mjs', 'wechat-browser.mjs', 'report-persistence.mjs', 'wrinkle-async-browser.mjs']) {
|
||||||
|
console.log('H5 compatibility: ' + name);
|
||||||
|
const result = spawnSync(process.execPath, ['--require', path.join(__dirname, 'legacy-playwright.cjs'), path.join(root, 'h5/test', name)],
|
||||||
|
{ cwd: root, env: testEnv, stdio: 'inherit', timeout: 120000 });
|
||||||
|
if (result.status !== 0) { if (result.error) console.error(result.error.message); process.exit(result.status || 1); }
|
||||||
|
}
|
||||||
|
console.log('PASS: H5 guest privacy, WeChat /state/ OAuth flow, full report persistence and async wrinkle layers');
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Resolve the repository's unmodified H5 browser tests against this project's
|
||||||
|
// local Playwright installation, instead of requiring a global npm install.
|
||||||
|
const cp = require('node:child_process'), path = require('node:path');
|
||||||
|
const original = cp.execSync;
|
||||||
|
cp.execSync = function (command, options) {
|
||||||
|
if (command === 'npm root -g') {
|
||||||
|
const value = path.resolve(__dirname, '../node_modules') + '\n';
|
||||||
|
return options && options.encoding ? value : Buffer.from(value);
|
||||||
|
}
|
||||||
|
return original.apply(this, arguments);
|
||||||
|
};
|
||||||
|
require('node:module').syncBuiltinESMExports();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test'), assert = require('node:assert/strict'), vm = require('node:vm'), fs = require('node:fs'), path = require('node:path');
|
||||||
|
function setup(my) {
|
||||||
|
const module = { exports: {} };
|
||||||
|
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 () => {
|
||||||
|
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(); },
|
||||||
|
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']);
|
||||||
|
await permissions.open(); await permissions.ensure('scope.camera');
|
||||||
|
assert.equal(calls.length, 2); assert.equal(openCalls, 1);
|
||||||
|
});
|
||||||
|
test('existing denial does not reauthorize; short-form settings keys work and unknown scope requests on demand', 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test'), assert = require('node:assert/strict'), crypto = require('node:crypto');
|
||||||
|
const auth = require('../../h5/lib/taobao-auth');
|
||||||
|
test('gateway signature covers raw JSON, identity, extra query fields and algorithm; duplicates fail closed', () => {
|
||||||
|
const env = { TAOBAO_APP_KEY: 'app', TAOBAO_APP_SECRET: 'secret' }, body = '{"consent":true,"challenge":"nonce"}';
|
||||||
|
const params = { app_key: 'app', open_id: 'open-account', sign_method: 'md5', env: 'online' };
|
||||||
|
const expected = crypto.createHash('md5').update('secretapp_keyappenvonlineopen_idopen-accountsign_methodmd5' + body + 'secret').digest('hex').toUpperCase();
|
||||||
|
assert.equal(auth.signature(params, body, env.TAOBAO_APP_SECRET), expected);
|
||||||
|
const url = '/api/taobao/session?' + new URLSearchParams({ ...params, sign: expected });
|
||||||
|
assert(auth.verify(url, body, env));
|
||||||
|
assert.equal(auth.verify(url, body + ' ', env), null);
|
||||||
|
assert.equal(auth.verify(url.replace('open-account', 'forged-account'), body, env), null);
|
||||||
|
assert.equal(auth.verify(url + '&open_id=open-account', body, env), null);
|
||||||
|
assert.equal(auth.verify(url, body, {}), null);
|
||||||
|
});
|
||||||
|
test('photo chunks are ordered, bounded, deduplicated, owner-bound, expiring and consumed once', async () => {
|
||||||
|
let now = 0;
|
||||||
|
const req = { headers: {}, owner: 'alice' }, other = { headers: {}, owner: 'bob' };
|
||||||
|
const runtime = require('../../h5/lib/taobao-session')({ now: () => now, session: request => ({ id: request.owner, kind: 'taobao' }) });
|
||||||
|
const piece = { view: 'front', upload_id: 'test-upload-id', index: 0, total: 2, data: 'AAAA' };
|
||||||
|
assert.equal(runtime.upload(req, { ...piece, index: 1 }).status, 409);
|
||||||
|
assert.equal(runtime.upload(req, piece).complete, false);
|
||||||
|
assert.equal(runtime.upload(req, piece).complete, false, 'repeated identical chunk is idempotent');
|
||||||
|
assert.equal(runtime.upload(req, { ...piece, data: 'BBBB' }).status, 409);
|
||||||
|
assert.equal(runtime.upload(req, { ...piece, data: 'A'.repeat(65537) }).status, 400);
|
||||||
|
assert.equal(runtime.upload(req, { ...piece, index: 1 }).complete, true);
|
||||||
|
assert.equal(runtime.takePhotos(other, { front: piece.upload_id }), null);
|
||||||
|
for (const view of ['left', 'right']) assert.equal(runtime.upload(req, { ...piece, view, total: 1 }).status, 200);
|
||||||
|
const refs = { front: piece.upload_id, left: piece.upload_id, right: piece.upload_id };
|
||||||
|
assert(runtime.takePhotos(req, refs)); assert.equal(runtime.takePhotos(req, refs), null);
|
||||||
|
assert.equal(runtime.upload(req, piece).status, 409);
|
||||||
|
let complete;
|
||||||
|
const job = runtime.start(req, () => new Promise(resolve => { complete = resolve; }), true);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(runtime.result(other, job), null); assert.equal(runtime.result(req, job).status, 'running');
|
||||||
|
complete({ ok: true }); await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(runtime.result(req, job).status, 'complete');
|
||||||
|
assert.equal(runtime.upload(req, piece).status, 200);
|
||||||
|
now = 5 * 60000 + 1; assert.equal(runtime.takePhotos(req, refs), null);
|
||||||
|
const challenge = runtime.challenge('local'); assert(runtime.consume(challenge)); assert(!runtime.consume(challenge));
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test'), assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||||
|
|
||||||
|
test('logout succeeds for an expired session, while a failed server revocation remains visible', async () => {
|
||||||
|
const storage = new Map(), app = { globalData: {} };
|
||||||
|
let logoutFailure;
|
||||||
|
const module = { exports: {} };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/api.js'), 'utf8'), {
|
||||||
|
module, Promise, Date, setTimeout, getApp: () => app,
|
||||||
|
my: { setStorageSync: ({ key, data }) => storage.set(key, data),
|
||||||
|
getStorageSync: ({ key }) => ({ data: storage.get(key) }), removeStorageSync: ({ key }) => storage.delete(key) },
|
||||||
|
require(name) {
|
||||||
|
if (name === '../config') return { transport: 'direct' };
|
||||||
|
if (name === './transport') return { request: async route => {
|
||||||
|
if (route.endsWith('/challenge')) return { ok: true, challenge: 'challenge' };
|
||||||
|
if (route.endsWith('/session')) return { ok: true, token: 'token', user: { id: 'alice' } };
|
||||||
|
if (route.endsWith('/logout')) {
|
||||||
|
if (logoutFailure === 'network') throw Error('network unavailable');
|
||||||
|
return { ok: false, http_status: 401, message: 'session expired' };
|
||||||
|
}
|
||||||
|
throw Error('unexpected route: ' + route);
|
||||||
|
} };
|
||||||
|
throw Error('unexpected dependency: ' + name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const api = module.exports;
|
||||||
|
await api.login(true);
|
||||||
|
logoutFailure = 'network';
|
||||||
|
await assert.rejects(api.logout(), /network unavailable/);
|
||||||
|
assert.equal(api.user().id, 'alice');
|
||||||
|
app.globalData.photos = ['private-photo']; app.globalData.payload = { private: true };
|
||||||
|
logoutFailure = 'expired';
|
||||||
|
await api.logout();
|
||||||
|
assert.equal(api.user(), null); assert.equal(storage.size, 0);
|
||||||
|
assert.equal(app.globalData.photos, null); assert.equal(app.globalData.payload, null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test'), assert = require('node:assert/strict'), vm = require('node:vm'), fs = require('node:fs'), path = require('node:path');
|
||||||
|
test('cloud transport initializes once, preserves subpath/auth, caps timeout and unwraps gateway envelopes', async () => {
|
||||||
|
const config = { transport: 'cloud', baseUrl: 'https://skin.example.test', basePath: '/state', cloudAppId: 'cloud-id', cloudEnv: 'online' };
|
||||||
|
let initialized = 0, options;
|
||||||
|
const cloud = { init: async ({ env }) => { assert.equal(env, 'online'); initialized++; return true; },
|
||||||
|
application: { httpRequest: async input => { options = input; return { ok: true, http_status: 200 }; } } };
|
||||||
|
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;
|
||||||
|
await transport.request('/api/taobao/me', {}, 'token'); await transport.request('/api/taobao/reports', {}, 'token');
|
||||||
|
assert.equal(initialized, 1); assert.equal(options.path, '/state/api/taobao/reports');
|
||||||
|
assert.equal(options.headers.Authorization, 'Bearer token'); assert.equal(options.exts.timeout, 10000);
|
||||||
|
assert.equal(options.exts.cloudAppId, 'cloud-id'); assert.equal(options.exts.domain, config.baseUrl);
|
||||||
|
assert.equal(transport.unwrap({ success: true, data: { ok: true } }).ok, true);
|
||||||
|
assert.throws(() => transport.unwrap({ success: false, errorMessage: 'gateway rejected' }), /gateway rejected/);
|
||||||
|
config.transport = 'direct';
|
||||||
|
await assert.rejects(transport.request('/api/taobao/me', {}), /仅用于本机/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
var config = require('../config'), transport = require('./transport');
|
||||||
|
var token = '', user = null, storageKey = 'juzi.taobao.session.v1';
|
||||||
|
function assetUrl(path) { return config.assetBase.replace(/\/?$/, '/') + path.replace(/^assets\//, ''); }
|
||||||
|
function clear() {
|
||||||
|
token = ''; user = null;
|
||||||
|
try { my.removeStorageSync({ key: storageKey }); } catch (_) {}
|
||||||
|
var app = typeof getApp === 'function' && getApp();
|
||||||
|
if (app) { app.globalData.profile = null; app.globalData.photos = null; app.globalData.payload = null; }
|
||||||
|
}
|
||||||
|
function request(path, body) {
|
||||||
|
var requestToken = token;
|
||||||
|
return transport.request('/api/taobao/' + path, body || {}, requestToken).then(function (data) {
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
var err = new Error(data && data.message || '服务暂不可用,请稍后重试');
|
||||||
|
err.status = data && data.http_status; err.code = data && data.code;
|
||||||
|
if (err.status === 401 && requestToken === token) clear();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function authorize() {
|
||||||
|
if (config.transport === 'direct') return Promise.resolve();
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
my.authorize({ scopes: 'scope.userInfo', success: resolve, fail: function () { reject(new Error('请授权淘宝账号后继续')); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function login(consent) {
|
||||||
|
if (!consent) return Promise.reject(new Error('请先阅读并同意隐私协议'));
|
||||||
|
return authorize().then(function () { return request('challenge'); }).then(function (data) {
|
||||||
|
return request('session', { consent: true, challenge: data.challenge });
|
||||||
|
}).then(function (data) {
|
||||||
|
token = data.token; user = data.user;
|
||||||
|
my.setStorageSync({ key: storageKey, data: { token: token, user: user } });
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function restore() {
|
||||||
|
try { var saved = my.getStorageSync({ key: storageKey }).data; if (saved) { token = saved.token || ''; user = saved.user || null; } } catch (_) {}
|
||||||
|
if (!token) return Promise.resolve(null);
|
||||||
|
var restoredToken = token;
|
||||||
|
return request('me').then(function (data) { if (token === restoredToken) user = data.user; return user; });
|
||||||
|
}
|
||||||
|
function canceled(signal) { if (signal && signal.canceled) { var e = new Error('已取消检测'); e.canceled = true; throw e; } }
|
||||||
|
function wait(ms, signal) { return new Promise(function (resolve) { setTimeout(resolve, ms); }).then(function () { canceled(signal); }); }
|
||||||
|
function poll(job, signal, deadline) {
|
||||||
|
canceled(signal);
|
||||||
|
if (Date.now() >= deadline) throw new Error('检测仍在处理中,请稍后从历史报告查看');
|
||||||
|
return request('analysis', { job: job }).then(function (data) {
|
||||||
|
canceled(signal);
|
||||||
|
if (data.status !== 'complete') return wait(1000, signal).then(function () { return poll(job, signal, deadline); });
|
||||||
|
if (!data.payload || !data.payload.ok) throw new Error(data.payload && data.payload.message || '检测失败,请重试');
|
||||||
|
return data.payload;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function upload(view, photo, signal) {
|
||||||
|
var b64 = photo.replace(/^data:image\/[^;]+;base64,/, ''), size = 64 * 1024;
|
||||||
|
if (!b64 || b64.length > 4 * 1024 * 1024) return Promise.reject(new Error('照片过大,请重新拍摄'));
|
||||||
|
var id = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2), total = Math.ceil(b64.length / size);
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
for (var i = 0; i < total; i++) (function (index) {
|
||||||
|
chain = chain.then(function () { canceled(signal); return request('photo', { view: view, upload_id: id, index: index, total: total, data: b64.slice(index * size, (index + 1) * size) }); });
|
||||||
|
})(i);
|
||||||
|
return chain.then(function () { return id; });
|
||||||
|
}
|
||||||
|
function analyze(photos, profile, signal) {
|
||||||
|
if (config.mock) {
|
||||||
|
var SS = require('../vendor/skin-score'), UI = require('../vendor/report-ui');
|
||||||
|
var vm = SS.buildViewModel(SS.MOCK_RESULT, SS.MOCK_SIDE, { realAge: profile.real_age, skinType: profile.daily_skin_type });
|
||||||
|
return Promise.resolve({ ok: true, result: SS.MOCK_RESULT, side: SS.MOCK_SIDE, vm: vm, presentation: UI.build(vm, SS.MOCK_RESULT) });
|
||||||
|
}
|
||||||
|
var refs = {};
|
||||||
|
return ['front', 'left', 'right'].reduce(function (chain, view, i) {
|
||||||
|
return chain.then(function () { return upload(view, photos[i], signal); }).then(function (id) { refs[view] = id; });
|
||||||
|
}, Promise.resolve()).then(function () {
|
||||||
|
canceled(signal); return request('skin-analyze', { photo_refs: refs, profile: profile });
|
||||||
|
}).then(function (data) { return poll(data.job, signal, Date.now() + 90000); });
|
||||||
|
}
|
||||||
|
function wrinkleViews(jobToken, signal) {
|
||||||
|
return request('wrinkle-views', { token: jobToken }).then(function (data) { return poll(data.job, signal, Date.now() + 90000); });
|
||||||
|
}
|
||||||
|
function logout() {
|
||||||
|
return request('logout').catch(function (err) {
|
||||||
|
// An expired/revoked session is already signed out; network failures still surface.
|
||||||
|
if (err.status !== 401) throw err;
|
||||||
|
}).then(clear);
|
||||||
|
}
|
||||||
|
module.exports = { assetUrl: assetUrl, analyze: analyze, login: login, restore: restore, request: request,
|
||||||
|
user: function () { return user; }, logout: logout, wrinkleViews: wrinkleViews };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
var config = require('../config');
|
||||||
|
function cameraDebugEnabled() {
|
||||||
|
return config.environment === 'development' && config.cameraDebug === true && config.transport === 'direct' &&
|
||||||
|
/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(config.baseUrl);
|
||||||
|
}
|
||||||
|
module.exports = { cameraDebugEnabled: cameraDebugEnabled };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Always redraw to JPEG: native compressImage changes quality, not dimensions.
|
||||||
|
var MAX_SIDE = 1280;
|
||||||
|
function compressToBase64(src, page) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
my.getImageInfo({ src: src, success: function (info) {
|
||||||
|
if (page.alive === false) { reject({ canceled: true }); return; }
|
||||||
|
if (!(info.width > 0 && info.height > 0)) { reject(new Error('无法读取照片尺寸,请重新选择')); return; }
|
||||||
|
var scale = Math.min(1, MAX_SIDE / Math.max(info.width, info.height));
|
||||||
|
var w = Math.max(1, Math.round(info.width * scale)), h = Math.max(1, Math.round(info.height * scale));
|
||||||
|
page.setData({ compressWidth: w, compressHeight: h }, function () {
|
||||||
|
if (page.alive === false) { reject({ canceled: true }); return; }
|
||||||
|
try {
|
||||||
|
var ctx = my.createCanvasContext('compress-canvas');
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.drawImage(src, 0, 0, w, h);
|
||||||
|
ctx.draw(false, function () {
|
||||||
|
if (page.alive === false) { reject({ canceled: true }); return; }
|
||||||
|
ctx.toTempFilePath({ x: 0, y: 0, width: w, height: h, destWidth: w, destHeight: h, fileType: 'jpg', quality: 0.88,
|
||||||
|
success: function (result) {
|
||||||
|
var file = result.apFilePath || result.tempFilePath;
|
||||||
|
if (!file) { reject(new Error('照片处理失败,请重试')); return; }
|
||||||
|
try {
|
||||||
|
my.getFileSystemManager().readFile({ filePath: file, encoding: 'base64',
|
||||||
|
success: function (r) { resolve('data:image/jpeg;base64,' + r.data); }, fail: reject });
|
||||||
|
} catch (e) { reject(new Error('当前淘宝版本不支持照片读取,请升级后重试')); }
|
||||||
|
}, fail: reject });
|
||||||
|
});
|
||||||
|
} catch (e) { reject(new Error('照片处理不可用,请升级淘宝后重试')); }
|
||||||
|
});
|
||||||
|
}, fail: reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = { MAX_SIDE: MAX_SIDE, compressToBase64: compressToBase64 };
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Taobao scopes are independent: userInfo login does not grant camera/album.
|
||||||
|
var denied = {};
|
||||||
|
function state(settings, scope) {
|
||||||
|
settings = settings || {};
|
||||||
|
var key = scope.replace(/^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({}); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function rejected(scope) {
|
||||||
|
var err = new Error(scope === 'scope.camera' ? '相机权限未开启,可在授权设置中开启,或使用相册' : '相册权限未开启,请在授权设置中开启');
|
||||||
|
err.permission = scope; return 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)); } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function open() {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (typeof my.openSetting !== 'function') { reject(new Error('请在小程序右上角的授权设置中开启权限')); return; }
|
||||||
|
my.openSetting({ success: function (res) {
|
||||||
|
var current = res.authSetting || {};
|
||||||
|
['scope.camera', 'scope.album'].forEach(function (scope) { if (state(current, scope) === true) delete denied[scope]; });
|
||||||
|
resolve(current);
|
||||||
|
}, fail: function () { reject(new Error('无法打开授权设置,请从小程序右上角进入')); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = { ensure: ensure, settings: settings, state: state, open: open };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/* 省份 → 城市(地级)数据。来源:china-division@2.7.0 dist/pc.json(modood/Administrative-divisions-of-China,WTFPL);
|
||||||
|
直辖市第二列只保留本市,另补港澳台与海外。 */
|
||||||
|
// 小程序无 window 对象,改为 CommonJS 导出(数据本身与 H5 完全一致)
|
||||||
|
var REGION_DATA = [
|
||||||
|
["北京市", ["北京市"]],
|
||||||
|
["天津市", ["天津市"]],
|
||||||
|
["河北省", ["石家庄市","唐山市","秦皇岛市","邯郸市","邢台市","保定市","张家口市","承德市","沧州市","廊坊市","衡水市"]],
|
||||||
|
["山西省", ["太原市","大同市","阳泉市","长治市","晋城市","朔州市","晋中市","运城市","忻州市","临汾市","吕梁市"]],
|
||||||
|
["内蒙古自治区", ["呼和浩特市","包头市","乌海市","赤峰市","通辽市","鄂尔多斯市","呼伦贝尔市","巴彦淖尔市","乌兰察布市","兴安盟","锡林郭勒盟","阿拉善盟"]],
|
||||||
|
["辽宁省", ["沈阳市","大连市","鞍山市","抚顺市","本溪市","丹东市","锦州市","营口市","阜新市","辽阳市","盘锦市","铁岭市","朝阳市","葫芦岛市"]],
|
||||||
|
["吉林省", ["长春市","吉林市","四平市","辽源市","通化市","白山市","松原市","白城市","延边朝鲜族自治州"]],
|
||||||
|
["黑龙江省", ["哈尔滨市","齐齐哈尔市","鸡西市","鹤岗市","双鸭山市","大庆市","伊春市","佳木斯市","七台河市","牡丹江市","黑河市","绥化市","大兴安岭地区"]],
|
||||||
|
["上海市", ["上海市"]],
|
||||||
|
["江苏省", ["南京市","无锡市","徐州市","常州市","苏州市","南通市","连云港市","淮安市","盐城市","扬州市","镇江市","泰州市","宿迁市"]],
|
||||||
|
["浙江省", ["杭州市","宁波市","温州市","嘉兴市","湖州市","绍兴市","金华市","衢州市","舟山市","台州市","丽水市"]],
|
||||||
|
["安徽省", ["合肥市","芜湖市","蚌埠市","淮南市","马鞍山市","淮北市","铜陵市","安庆市","黄山市","滁州市","阜阳市","宿州市","六安市","亳州市","池州市","宣城市"]],
|
||||||
|
["福建省", ["福州市","厦门市","莆田市","三明市","泉州市","漳州市","南平市","龙岩市","宁德市"]],
|
||||||
|
["江西省", ["南昌市","景德镇市","萍乡市","九江市","新余市","鹰潭市","赣州市","吉安市","宜春市","抚州市","上饶市"]],
|
||||||
|
["山东省", ["济南市","青岛市","淄博市","枣庄市","东营市","烟台市","潍坊市","济宁市","泰安市","威海市","日照市","临沂市","德州市","聊城市","滨州市","菏泽市"]],
|
||||||
|
["河南省", ["郑州市","开封市","洛阳市","平顶山市","安阳市","鹤壁市","新乡市","焦作市","濮阳市","许昌市","漯河市","三门峡市","南阳市","商丘市","信阳市","周口市","驻马店市","济源市"]],
|
||||||
|
["湖北省", ["武汉市","黄石市","十堰市","宜昌市","襄阳市","鄂州市","荆门市","孝感市","荆州市","黄冈市","咸宁市","随州市","恩施土家族苗族自治州","仙桃市","潜江市","天门市","神农架林区"]],
|
||||||
|
["湖南省", ["长沙市","株洲市","湘潭市","衡阳市","邵阳市","岳阳市","常德市","张家界市","益阳市","郴州市","永州市","怀化市","娄底市","湘西土家族苗族自治州"]],
|
||||||
|
["广东省", ["广州市","韶关市","深圳市","珠海市","汕头市","佛山市","江门市","湛江市","茂名市","肇庆市","惠州市","梅州市","汕尾市","河源市","阳江市","清远市","东莞市","中山市","潮州市","揭阳市","云浮市"]],
|
||||||
|
["广西壮族自治区", ["南宁市","柳州市","桂林市","梧州市","北海市","防城港市","钦州市","贵港市","玉林市","百色市","贺州市","河池市","来宾市","崇左市"]],
|
||||||
|
["海南省", ["海口市","三亚市","三沙市","儋州市","五指山市","琼海市","文昌市","万宁市","东方市","定安县","屯昌县","澄迈县","临高县","白沙黎族自治县","昌江黎族自治县","乐东黎族自治县","陵水黎族自治县","保亭黎族苗族自治县","琼中黎族苗族自治县"]],
|
||||||
|
["重庆市", ["重庆市"]],
|
||||||
|
["四川省", ["成都市","自贡市","攀枝花市","泸州市","德阳市","绵阳市","广元市","遂宁市","内江市","乐山市","南充市","眉山市","宜宾市","广安市","达州市","雅安市","巴中市","资阳市","阿坝藏族羌族自治州","甘孜藏族自治州","凉山彝族自治州"]],
|
||||||
|
["贵州省", ["贵阳市","六盘水市","遵义市","安顺市","毕节市","铜仁市","黔西南布依族苗族自治州","黔东南苗族侗族自治州","黔南布依族苗族自治州"]],
|
||||||
|
["云南省", ["昆明市","曲靖市","玉溪市","保山市","昭通市","丽江市","普洱市","临沧市","楚雄彝族自治州","红河哈尼族彝族自治州","文山壮族苗族自治州","西双版纳傣族自治州","大理白族自治州","德宏傣族景颇族自治州","怒江傈僳族自治州","迪庆藏族自治州"]],
|
||||||
|
["西藏自治区", ["拉萨市","日喀则市","昌都市","林芝市","山南市","那曲市","阿里地区"]],
|
||||||
|
["陕西省", ["西安市","铜川市","宝鸡市","咸阳市","渭南市","延安市","汉中市","榆林市","安康市","商洛市"]],
|
||||||
|
["甘肃省", ["兰州市","嘉峪关市","金昌市","白银市","天水市","武威市","张掖市","平凉市","酒泉市","庆阳市","定西市","陇南市","临夏回族自治州","甘南藏族自治州"]],
|
||||||
|
["青海省", ["西宁市","海东市","海北藏族自治州","黄南藏族自治州","海南藏族自治州","果洛藏族自治州","玉树藏族自治州","海西蒙古族藏族自治州"]],
|
||||||
|
["宁夏回族自治区", ["银川市","石嘴山市","吴忠市","固原市","中卫市"]],
|
||||||
|
["新疆维吾尔自治区", ["乌鲁木齐市","克拉玛依市","吐鲁番市","哈密市","昌吉回族自治州","博尔塔拉蒙古自治州","巴音郭楞蒙古自治州","阿克苏地区","克孜勒苏柯尔克孜自治州","喀什地区","和田地区","伊犁哈萨克自治州","塔城地区","阿勒泰地区","石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]],
|
||||||
|
["香港特别行政区", ["香港特别行政区"]],
|
||||||
|
["澳门特别行政区", ["澳门特别行政区"]],
|
||||||
|
["台湾省", ["台湾省"]],
|
||||||
|
["海外", ["海外"]]
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = REGION_DATA;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
var SS = require('../vendor/skin-score'), UI = require('../vendor/report-ui'), api = require('./api');
|
||||||
|
function mapURL(value) {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
if (/^data:image\/(png|jpeg);base64,/.test(value)) return value;
|
||||||
|
if (value.indexOf('/9j/') === 0) return 'data:image/jpeg;base64,' + value;
|
||||||
|
if (value.indexOf('iVBOR') === 0) return 'data:image/png;base64,' + value;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function model(payload, photos, profile) {
|
||||||
|
var vm = payload.vm || SS.buildViewModel(payload.result, payload.side, { realAge: profile.real_age, skinType: profile.daily_skin_type });
|
||||||
|
vm = SS.withDisplayRecommendation(vm, payload.result);
|
||||||
|
var presentation = UI.forDisplay(vm, payload.result, payload.presentation);
|
||||||
|
var total = SS.weightedTotal(vm.dims), skinAge = SS.reportSkinAge(vm, payload.result);
|
||||||
|
var focus = UI.focusOf(vm);
|
||||||
|
var views = presentation.views.map(function (view) {
|
||||||
|
return { key: view.key, name: view.name, text: UI.customerText(view), score: UI.dimensionScore(view.S), level: view.level || '',
|
||||||
|
dim: view.key === 'wrinkles', alpha: view.key === 'color' ? 0.3 : 1, marks: [] };
|
||||||
|
});
|
||||||
|
var difference = skinAge != null && vm.realAge != null ? skinAge - vm.realAge : null;
|
||||||
|
return { total: total, skinAge: skinAge, ageNote: difference == null ? '' : difference === 0 ? '与实际年龄相当' :
|
||||||
|
difference < 0 ? '比实际年龄年轻' + -difference + '岁' : '比实际年龄大' + difference + '岁',
|
||||||
|
dims: ['firm', 'vita', 'stab'].map(function (key) {
|
||||||
|
var d = vm.dims[key] || {}, value = UI.dimensionScore(d.S), parts = value.split('.');
|
||||||
|
return { key: key, name: UI.dimNames[key], int: parts[0], dec: parts[1] || '',
|
||||||
|
level: UI.levelForScore(d.S) || '', rank: focus && focus.key === key ? 'worst' : '', barWidth: d.S == null ? 0 : Math.min(100, d.S * 10) };
|
||||||
|
}), focusName: focus ? focus.name : '', focusSlogan: focus ? focus.tip : '', views: views, viewIndex: 0, frontPhoto: photos[0] || '', activeLayers: [],
|
||||||
|
careAdvice: presentation.care || [], product: presentation.product ? Object.assign({}, presentation.product, { image: api.assetUrl(presentation.product.image) }) : null };
|
||||||
|
}
|
||||||
|
function marks(result, key, width, height) {
|
||||||
|
if (!(width > 0 && height > 0)) return [];
|
||||||
|
var defs = key === 'acne' ? [['closed_comedones', '#E8A13D'], ['acne', '#D30C0C'], ['acne_pustule', '#D30C0C'],
|
||||||
|
['acne_nodule', '#8E0000'], ['acne_mark', '#8750C8']] : key === 'color' ? [['melanin_mark', '#9F641F'], ['melasma', '#E68A2E'], ['freckle', '#8750C8']] : [];
|
||||||
|
var out = [];
|
||||||
|
defs.forEach(function (def) {
|
||||||
|
var source = result[def[0]] || {}, rectangles = (source.rectangle || []).slice();
|
||||||
|
(source.polygon || []).forEach(function (points) {
|
||||||
|
points = points.filter(function (p) { return p && typeof p.x === 'number' && typeof p.y === 'number'; });
|
||||||
|
if (points.length < 3) return;
|
||||||
|
var xs = points.map(function (p) { return p.x; }), ys = points.map(function (p) { return p.y; });
|
||||||
|
var left = Math.min.apply(Math, xs), top = Math.min.apply(Math, ys);
|
||||||
|
rectangles.push({ left: left, top: top, width: Math.max.apply(Math, xs) - left, height: Math.max.apply(Math, ys) - top });
|
||||||
|
});
|
||||||
|
rectangles.forEach(function (r) {
|
||||||
|
if (![r.left, r.top, r.width, r.height].every(function (n) { return typeof n === 'number' && isFinite(n); }) ||
|
||||||
|
r.left < 0 || r.top < 0 || r.width <= 0 || r.height <= 0 || r.left + r.width > width || r.top + r.height > height) return;
|
||||||
|
out.push({ id: out.length, left: 100 * r.left / width, top: 100 * r.top / height,
|
||||||
|
width: 100 * r.width / width, height: 100 * r.height / height, color: def[1] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function layers(result, key) {
|
||||||
|
var fields = { wrinkles: ['texture_enhanced_lines'], smooth: ['texture_enhanced_pores', 'texture_enhanced_blackheads'], color: ['brown_area'], water: ['water_area'], sens: ['red_area'] };
|
||||||
|
return (fields[key] || []).map(function (field) { return mapURL((result.face_maps || {})[field]); }).filter(Boolean);
|
||||||
|
}
|
||||||
|
module.exports = { model: model, mapURL: mapURL, marks: marks, layers: layers };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('../vendor/skin-score');
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
var config = require('../config');
|
||||||
|
var cloudReady;
|
||||||
|
|
||||||
|
function unwrap(result) {
|
||||||
|
if (typeof result === 'string') result = JSON.parse(result);
|
||||||
|
if (result && typeof result.success === 'boolean') {
|
||||||
|
if (!result.success) throw new Error(result.errorMessage || '服务暂不可用');
|
||||||
|
return typeof result.data === 'string' ? JSON.parse(result.data) : result.data;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function initCloud() {
|
||||||
|
if (!cloudReady) {
|
||||||
|
var sdk = require('@tbmp/mp-cloud-sdk'), cloud = sdk.default || sdk;
|
||||||
|
cloudReady = Promise.resolve(cloud.init({ env: config.cloudEnv })).then(function (ok) {
|
||||||
|
if (!ok) throw new Error('淘宝云服务初始化失败');
|
||||||
|
return cloud;
|
||||||
|
}).catch(function (err) { cloudReady = null; throw err; });
|
||||||
|
}
|
||||||
|
return cloudReady;
|
||||||
|
}
|
||||||
|
function request(path, body, token) {
|
||||||
|
var headers = { 'Content-Type': 'application/json;charset=UTF-8' };
|
||||||
|
if (token) headers.Authorization = 'Bearer ' + token;
|
||||||
|
if (config.transport === 'direct') {
|
||||||
|
if (!/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(config.baseUrl)) return Promise.reject(new Error('直连接口仅用于本机联调'));
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
my.request({ url: config.baseUrl + config.basePath + path, method: 'POST', headers: headers, data: JSON.stringify(body),
|
||||||
|
dataType: 'json', timeout: 10000,
|
||||||
|
success: function (res) {
|
||||||
|
try {
|
||||||
|
if ((res.status || res.statusCode) !== 200) throw new Error('服务暂不可用');
|
||||||
|
resolve(unwrap(res.data));
|
||||||
|
} catch (e) { reject(e); }
|
||||||
|
}, fail: function () { reject(new Error('网络异常,请稍后重试')); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!config.cloudAppId || !/^https:\/\//.test(config.baseUrl)) return Promise.reject(new Error('请先配置淘宝云应用和 HTTPS 服务地址'));
|
||||||
|
return initCloud().then(function (cloud) {
|
||||||
|
return cloud.application.httpRequest({ path: config.basePath + path, method: 'POST', headers: headers,
|
||||||
|
params: {}, body: body, exts: { cloudAppId: config.cloudAppId, domain: config.baseUrl, timeout: 10000 } });
|
||||||
|
}).then(unwrap);
|
||||||
|
}
|
||||||
|
module.exports = { request: request, unwrap: unwrap };
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/* ═════ 语音播报:my.createInnerAudioContext 播放远程 MP3 ═════
|
||||||
|
* 文案/文件名契约:docs/语音播报内容设计文档_V1.0.md(A 步骤引导 / B 姿态提示 / C 流程音)
|
||||||
|
* 播放策略与 H5 一致:单通道;流程音排队顺序播不互截;姿态提示同条 4s 去重、不打断流程音;
|
||||||
|
* 资源远程化:{ASSET_BASE}voice/<id>.mp3,清单 manifest.json 缺失时默认全量尝试(404 静默) */
|
||||||
|
|
||||||
|
var api = require('./api.js');
|
||||||
|
|
||||||
|
// A/B/C 三类清单(与 H5 app.js VOICE 常量一致)
|
||||||
|
var VOICE = {
|
||||||
|
step: ['step1-front', 'step2-rightface', 'step3-leftface'], // A. 步骤引导
|
||||||
|
hint: { // B. 姿态提示(手动快门模式下基本不用,保留契约)
|
||||||
|
'未检测到人脸': 'hint-noface', '请靠近一点': 'hint-near', '请远一点': 'hint-far',
|
||||||
|
'请正对屏幕': 'hint-front', '请向左转头': 'hint-turn-left', '请向右转头': 'hint-turn-right',
|
||||||
|
'保持不动': 'hint-hold'
|
||||||
|
},
|
||||||
|
countdown: 'countdown-321', shot: 'shot-done', // C. 拍摄与流程
|
||||||
|
analyzing: 'analyzing', fail: 'analyze-fail'
|
||||||
|
};
|
||||||
|
|
||||||
|
var DEDUPE_MS = 4000; // 同条提示 4s 内不重复播
|
||||||
|
|
||||||
|
var audio = null; // InnerAudioContext 单实例
|
||||||
|
var files = null; // null=清单未加载;{}=清单不可用(默认全量尝试)
|
||||||
|
var lastAt = {}; // id → 上次开播时间(去重)
|
||||||
|
var cur = null; // { id, kind:'flow'|'hint', token }
|
||||||
|
var queue = []; // 排队中的流程音
|
||||||
|
var pendingHint = null; // 流程音期间挂起的姿态提示(只留最新)
|
||||||
|
var waiters = []; // afterCurrent 回调:全部播报收尾后执行
|
||||||
|
|
||||||
|
// 拉取远程清单;失败则放行全量(语音缺失时 InnerAudioContext onError 静默跳过)
|
||||||
|
function init() {
|
||||||
|
if (audio) return;
|
||||||
|
if (typeof my.createInnerAudioContext !== 'function') return;
|
||||||
|
try { audio = my.createInnerAudioContext(); } catch (_) { return; }
|
||||||
|
audio.onEnded(function () { drain(); });
|
||||||
|
audio.onError(function () { drain(); }); // 单条加载失败:跳过,继续队列
|
||||||
|
files = {}; // Known voice IDs; unavailable files are skipped by onError.
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure() { if (!audio) init(); }
|
||||||
|
|
||||||
|
function playable(id) { return !!id && (files === null || !Object.keys(files).length || files[id]); }
|
||||||
|
|
||||||
|
function start(id, kind) {
|
||||||
|
ensure();
|
||||||
|
if (!audio) { drain(); return; }
|
||||||
|
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();
|
||||||
|
// 播放失败/结束时 cur 清理放在 drain 前,避免排队卡死
|
||||||
|
cur.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCur() {
|
||||||
|
if (!cur) return;
|
||||||
|
cur = null;
|
||||||
|
if (audio) { try { audio.stop(); } catch (e) {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain() {
|
||||||
|
cur = null;
|
||||||
|
if (queue.length) { start(queue.shift(), 'flow'); return; }
|
||||||
|
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
|
||||||
|
var w = waiters;
|
||||||
|
waiters = [];
|
||||||
|
w.forEach(function (fn) { fn(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function busyWithFlow() { return (cur && cur.kind === 'flow') || queue.length > 0; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播报一条语音
|
||||||
|
* @param {string} id 文件名(去 .mp3)
|
||||||
|
* @param {object} [opts] hint:姿态提示(4s 去重);cut:打断在播与排队(失败提示用)
|
||||||
|
*/
|
||||||
|
function play(id, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (!playable(id)) return;
|
||||||
|
ensure();
|
||||||
|
if (opts.hint) {
|
||||||
|
if (lastAt[id] && Date.now() - lastAt[id] < DEDUPE_MS) return;
|
||||||
|
if (busyWithFlow()) { pendingHint = id; return; }
|
||||||
|
start(id, 'hint');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opts.cut) { queue = []; pendingHint = null; stopCur(); start(id, 'flow'); return; }
|
||||||
|
if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; }
|
||||||
|
start(id, 'flow');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropHint() { pendingHint = null; } // 换步/提示清空:挂起的旧提示作废
|
||||||
|
|
||||||
|
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照页),否则让在播自然收尾(出报告)
|
||||||
|
function stop(hard) {
|
||||||
|
queue = [];
|
||||||
|
pendingHint = null;
|
||||||
|
waiters = [];
|
||||||
|
if (hard) stopCur();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部播报(含排队与挂起)自然收尾后执行 cb;空闲则立即执行
|
||||||
|
function afterCurrent(cb) {
|
||||||
|
if (!cur && !queue.length && !pendingHint) { cb(); return; }
|
||||||
|
waiters.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
VOICE: VOICE,
|
||||||
|
init: init,
|
||||||
|
play: play,
|
||||||
|
dropHint: dropHint,
|
||||||
|
stop: stop,
|
||||||
|
afterCurrent: afterCurrent
|
||||||
|
};
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"skin-score.js": "1f3256fd8d5863de3407c2037df30b6a801ac49858a8a0acab7163f83f642abc",
|
||||||
|
"report-ui.js": "2c6acaad7cf6e9c826f062f9e79d419470b37da71ab86a5399897d62fd695caf",
|
||||||
|
"report-copy.js": "402aa543c3437f4e8043e2281b09d7e4d734c0dd2d103636c9f37fe8aa28d6c2"
|
||||||
|
}
|
||||||
Vendored
+141
@@ -0,0 +1,141 @@
|
|||||||
|
// Generated from reference/AI测肤报告页话术修改.md; run tools/build_report_copy.py.
|
||||||
|
(function(root,factory){if(typeof module==="object"&&module.exports)module.exports=factory();else root.ReportCopy=factory();})(typeof window!=="undefined"?window:this,function(){return {
|
||||||
|
"version": "2026-10-09",
|
||||||
|
"conclusion": {
|
||||||
|
"contour": {
|
||||||
|
"改善": "面部轮廓紧致度有待提升,下颌线清晰度不足,左右下颌线角度分别为左 {la}°/ 右 {ra}°;中面部容积略欠饱满,肌肤支撑力偏弱。",
|
||||||
|
"良好": "面部轮廓清晰,下颌线较为清晰,左右下颌线角度左 {la}°/ 右 {ra}°;中面部容积无明显流失;肌肤处于健康状态,支撑力表现良好。",
|
||||||
|
"优秀": "面部轮廓紧致立体,下颌线清晰;中面部容积饱满,肌肤支撑力充足。"
|
||||||
|
},
|
||||||
|
"wrinkles": {
|
||||||
|
"改善": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。",
|
||||||
|
"良好": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。",
|
||||||
|
"优秀": "全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"改善": "检测到当前肌肤色素分 {melanin},存在色素沉着较显著,{dark_circle_note},整体透亮感不足。",
|
||||||
|
"良好": "检测到当前肌肤色素分 {melanin},局部有色素沉积;{dark_circle_note},整体状态稳定,局部透亮感稍弱。",
|
||||||
|
"优秀": "检测到当前肌肤色素分 {melanin},无明显色素沉积,{dark_circle_note};肤色均匀透亮。"
|
||||||
|
},
|
||||||
|
"smooth": {
|
||||||
|
"改善": "当前肌肤平滑度还有提升空间,检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,肤感整体细腻度有提升空间,纹理不均。",
|
||||||
|
"良好": "肌肤整体较为平滑,局部区域细腻度不足;检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,有轻微角质粗糙感。",
|
||||||
|
"优秀": "肌肤质感表现优异,表面细腻平滑,角质层排列整齐,无明显粗大毛孔与黑头。"
|
||||||
|
},
|
||||||
|
"water": {
|
||||||
|
"改善": "角质层含水量较低,缺水面积 {water_area}%、肌肤缺水比较严重 {water_sev},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。",
|
||||||
|
"良好": "角质层含水量适中,缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。",
|
||||||
|
"优秀": "角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。"
|
||||||
|
},
|
||||||
|
"sens": {
|
||||||
|
"改善": "泛红面积达 {sens_area}%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。",
|
||||||
|
"良好": "泛红面积 {sens_area}%,肌肤存在局部轻微泛红;对外界环境有良好的耐受度,仅在接触强刺激物时偶有轻微反应。",
|
||||||
|
"优秀": "泛红面积 {sens_area}%,无明显泛红、敏感迹象;对外界刺激耐受度佳,肌肤处于稳定的健康状态。"
|
||||||
|
},
|
||||||
|
"acne": {
|
||||||
|
"改善": "检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},肌肤状态不稳定,毛孔易堵塞,皮脂分泌波动明显。",
|
||||||
|
"良好": "检测到 {acne_cnt} 处皮损({acne_detail}),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。",
|
||||||
|
"优秀": "未检出明显痘痘、粉刺或闭口,无明显红肿、脓包迹象。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"care": {
|
||||||
|
"contour": {
|
||||||
|
"改善": {
|
||||||
|
"care": "重点进行轮廓紧致管理,关注面中、苹果肌与下颌线区域。日常增加紧致精华步骤,配合由下向上的轻柔提升按摩,帮助肌肤维持弹润和支撑感,让轮廓看起来更紧实。",
|
||||||
|
"lifestyle": "保证充足、规律的睡眠,让肌肤在夜间充分修护;坚持防晒,减少紫外线带来的老化影响;减少熬夜。"
|
||||||
|
},
|
||||||
|
"良好": {
|
||||||
|
"care": "关注面中、苹果肌、下颌线等容易显疲态区域的支撑感。日常可叠加紧致精华步骤,并配合向上提升按摩手法,预防疲态引起的松弛与垮塌累积,稳住面部支撑线。",
|
||||||
|
"lifestyle": "日常注意防晒,保持规律作息;坚持适度运动,帮助肌肤维持活力。"
|
||||||
|
},
|
||||||
|
"优秀": {
|
||||||
|
"care": "保持现有紧致抗衰护理节奏,日常叠加使用提亮精华,坚持防晒,加强光损伤防护与状态管理,持续关注面中疲态与轮廓支撑状态,让紧致状态更稳定、更持久。",
|
||||||
|
"lifestyle": "坚持防晒与规律作息,均衡饮食,保证优质蛋白、新鲜蔬果和健康脂肪摄入,适度运动。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wrinkles": {
|
||||||
|
"改善": {
|
||||||
|
"care": "重视纹路管理,日常可使用含有肽类、胶原类成分的淡纹精华,针对眼周、法令纹等动态纹高发区域配合点按手法,遏制静态纹转化,帮肌肤恢复平滑细腻的状态。",
|
||||||
|
"lifestyle": "注意睡姿,减少面部长期受压;减少夸张表情,避免揉眼、托腮等表情性挤压习惯,减少纹路加深。"
|
||||||
|
},
|
||||||
|
"良好": {
|
||||||
|
"care": "加强日常淡纹与肌肤弹润护理,叠加使用含肽类、胶原类成分的淡纹精华,提升肌肤水润度与饱满感,维持肤表平滑。",
|
||||||
|
"lifestyle": "减少夸张表情反复牵拉,睡前加强保湿修护。"
|
||||||
|
},
|
||||||
|
"优秀": {
|
||||||
|
"care": "以预防初纹为核心,搭配抗初老型精华,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。",
|
||||||
|
"lifestyle": "保持良好作息与运动习惯,坚持防晒,减轻光老化与糖化负担,维持肌肤稳定状态。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"改善": {
|
||||||
|
"care": "重点关照暗沉与色素易沉积区域,强化透亮与肤色均匀,严格防晒,配合温和焕亮或抗氧化精华帮助均匀肤色与透亮感。",
|
||||||
|
"lifestyle": "日常加强防晒与抗氧化护理,减少熬夜与压力引发的暗沉,适当增加富含维C的蔬果摄入。"
|
||||||
|
},
|
||||||
|
"良好": {
|
||||||
|
"care": "加强日常光泽管理与局部暗沉护理,做好防晒与抗氧化打底;叠加使用温和焕亮精华,稳住当前肤色状态并提升整体通透感。",
|
||||||
|
"lifestyle": "持续做好防晒,保持作息规律,避免波动性色沉,守护均匀透亮感。"
|
||||||
|
},
|
||||||
|
"优秀": {
|
||||||
|
"care": "维持现有防晒与抗氧化护肤习惯,叠加使用温和焕亮精华,巩固肤色均匀度与透亮感。",
|
||||||
|
"lifestyle": "坚持日常防晒,保持均衡饮食支持皮肤稳态。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smooth": {
|
||||||
|
"改善": {
|
||||||
|
"care": "关注毛孔可见度与肤表粗糙感改善,加强角质管理,提升肌肤平滑细腻度。",
|
||||||
|
"lifestyle": "减少高糖高脂饮食,降低皮脂分泌刺激,定期温和调理角质,避免用手挤压黑头。"
|
||||||
|
},
|
||||||
|
"良好": {
|
||||||
|
"care": "加强基础清洁与角质管理,关注鼻翼、下颌等易堵塞部位,提升毛孔通畅度与肌肤细腻度。",
|
||||||
|
"lifestyle": "温水洁面避免烫洗,保持床品清洁减少局部刺激。"
|
||||||
|
},
|
||||||
|
"优秀": {
|
||||||
|
"care": "维持当前温和护理节奏,维护角质层健康与肌肤屏障稳定状态。",
|
||||||
|
"lifestyle": "保持规律清洁与保湿习惯,避免频繁更换功效类产品,减少肌肤适应负担。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"water": {
|
||||||
|
"改善": {
|
||||||
|
"care": "强化屏障锁水修复,注意水油平衡,每周2-3次深度补水,深度改善肌肤缺水状态。",
|
||||||
|
"lifestyle": "每日足量饮水,补充身体水分,改善干燥环境湿度,避免长时间空调直吹。"
|
||||||
|
},
|
||||||
|
"良好": {
|
||||||
|
"care": "日常需注重水油平衡调理,避免过度清洁损伤皮肤屏障,注意为肌肤持续补水。",
|
||||||
|
"lifestyle": "每日足量饮水,环境干燥时及时增加肌肤水分补充。"
|
||||||
|
},
|
||||||
|
"优秀": {
|
||||||
|
"care": "持续为皮肤补水与保湿,避免过度清洁与频繁去角质,巩固肌肤水润度与水油平衡。",
|
||||||
|
"lifestyle": "坚持多喝水与规律作息,季节交替时提前调整保湿方案。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sens": {
|
||||||
|
"改善": {
|
||||||
|
"care": "关注屏障修复与舒缓,精简护肤流程,选择温和洁面方式,修复受损角质层。若状态持续无明显好转,可寻求专业护肤建议。",
|
||||||
|
"lifestyle": "减少外界刺激,如酒精、香精、高浓度酸类精华等成分,避免冷热骤变刺激。"
|
||||||
|
},
|
||||||
|
"良好": {
|
||||||
|
"care": "保持温和护理节奏,注意防晒与环境防护,逐步提升皮肤耐受度。",
|
||||||
|
"lifestyle": "避免冷热骤变刺激肌肤屏障,运动后及时清洁降温。"
|
||||||
|
},
|
||||||
|
"优秀": {
|
||||||
|
"care": "持续规律护理并按季节阶段性调整,巩固皮肤耐受稳态。",
|
||||||
|
"lifestyle": "保持健康作息与均衡饮食,维持皮肤屏障稳态。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"acne": {
|
||||||
|
"改善": {
|
||||||
|
"care": "优先针对性调理痘区肌肤状态,精简护肤流程,避免频繁更换护肤方案与叠加功效成分;严重时及时就医。",
|
||||||
|
"lifestyle": "减少高糖高脂饮食与熬夜,避免手挤痘痘;保持床品、毛巾清洁,运动后及时清洁,减少汗液与皮脂堆积。"
|
||||||
|
},
|
||||||
|
"良好": {
|
||||||
|
"care": "保持温和清洁与肌肤炎症调理,关注T区、下颌等易出油区域,提升毛孔通畅度,同时注意防晒与保湿。",
|
||||||
|
"lifestyle": "保持规律作息与清淡饮食,运动后及时清洁,减少汗液与皮脂堆积。"
|
||||||
|
},
|
||||||
|
"优秀": {
|
||||||
|
"care": "坚持定期清洁,维持水油平衡与毛孔通畅,巩固皮肤耐受稳态。",
|
||||||
|
"lifestyle": "保持健康作息与均衡饮食,维持皮肤屏障健康稳定。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};});
|
||||||
Vendored
+199
@@ -0,0 +1,199 @@
|
|||||||
|
/* Presentation only: original scores and recommendation rules remain in SkinScore. */
|
||||||
|
(function(root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory(require('./report-copy.js'));
|
||||||
|
else root.ReportUI = factory(root.ReportCopy);
|
||||||
|
})(typeof window !== 'undefined' ? window : this, function(Copy) {
|
||||||
|
'use strict';
|
||||||
|
var defs = [
|
||||||
|
['contour', '轮廓', 'firm', 'contour'], ['wrinkles', '皱纹', 'firm', 'wrinkles'],
|
||||||
|
['color', '肤色', 'vita', 'tone'], ['smooth', '平滑度', 'vita', 'smooth'], ['water', '水分', 'vita', 'tone'],
|
||||||
|
['sens', '泛红', 'stab', 'sens'], ['acne', '痘痘', 'stab', 'acne']
|
||||||
|
];
|
||||||
|
var products = [
|
||||||
|
{ tier: 1, match: '25+束紧精华', title: "1'me 主场精华 I", name: '焕活光泽 透亮紧致', image: 'assets/img/ui-20261015/product-25.png' },
|
||||||
|
{ tier: 2, match: '35+支撑精华', title: "1'me 主场精华 II", name: '恢复弹性 紧实利落', image: 'assets/img/ui-20261015/product-35.png' },
|
||||||
|
{ tier: 3, match: '50+重塑精华', title: "1'me 主场精华 III", name: '强韧屏障 轮廓向上', image: 'assets/img/ui-20261015/product-50.png' }
|
||||||
|
];
|
||||||
|
var levelMap = { '预警': '改善', '改善': '良好', '良好': '优秀' };
|
||||||
|
var dimNames = { firm: '紧塑力', vita: '透亮感', stab: '稳韧力' };
|
||||||
|
var focusTips = { firm: '强化紧塑力,提拉轮廓抚平细纹。', vita: '提升透亮感,补水提亮加强防晒。', stab: '增强稳韧力,舒缓泛红强韧屏障。' };
|
||||||
|
function numeric(value) { return typeof value === 'number' && isFinite(value); }
|
||||||
|
function dimensionScore(value) {
|
||||||
|
return numeric(value) ? Math.min(9.9, Math.round((value + 1e-9) * 10) / 10).toFixed(1) : '-';
|
||||||
|
}
|
||||||
|
var gradeRule = 'display-score-8-9-v1';
|
||||||
|
var copyRule = 'wrinkles-excellent-acne-detected-water-contour-v4';
|
||||||
|
function scoreOfP(P) {
|
||||||
|
return numeric(P) ? Math.round((Math.max(7, Math.min(10, 7 + 3 * P / 100)) + 1e-9) * 100) / 100 : null;
|
||||||
|
}
|
||||||
|
function levelForScore(S) {
|
||||||
|
if (!numeric(S)) return null;
|
||||||
|
var displayed = Number(dimensionScore(S));
|
||||||
|
return displayed < 8 ? '改善' : displayed < 9 ? '良好' : '优秀';
|
||||||
|
}
|
||||||
|
function internalLevel(S) {
|
||||||
|
return { '改善': '预警', '良好': '改善', '优秀': '良好' }[levelForScore(S)] || null;
|
||||||
|
}
|
||||||
|
// Read saved raw dimension values, never re-score historical reports or use
|
||||||
|
// the legacy focus.name (which contains the old dimension terminology).
|
||||||
|
function focusOf(vm) {
|
||||||
|
var selected = null;
|
||||||
|
['firm', 'vita', 'stab'].forEach(function(key) {
|
||||||
|
var dim = vm.dims && vm.dims[key];
|
||||||
|
if (dim && numeric(dim.P) && (!selected || dim.P < selected.score)) selected = { key: key, score: dim.P, name: dimNames[key], tip: focusTips[key] };
|
||||||
|
});
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
function customerText(view) {
|
||||||
|
// Keep missing-data diagnostics in the saved/admin record, not on the
|
||||||
|
// customer report. Also suppress equivalent text in older snapshots.
|
||||||
|
if (view.valid === false || /本次未检出有效数据|本次未获取轮廓有效数据/.test(view.text || '')) return '';
|
||||||
|
if (view.key === 'wrinkles') return view.text.split('\n分区明细')[0];
|
||||||
|
if (view.key !== 'contour') return view.text;
|
||||||
|
// Also cover immutable old presentations, without modifying stored copy.
|
||||||
|
return view.text.replace(/侧脸角度数据未完整获取[;;]?/g, '');
|
||||||
|
}
|
||||||
|
function scalar(value) { return numeric(value) ? value : value && numeric(value.value) ? value.value : null; }
|
||||||
|
var wrinkleDefs = [
|
||||||
|
['forehead', '抬头纹(额头纹)', ['forehead_wrinkle_info'], 'forehead_count'],
|
||||||
|
['left_nasolabial', '左法令纹', ['left_nasolabial_wrinkle_info', 'left_nasolabial_fold_info'], 'left_nasolabial_count'],
|
||||||
|
['right_nasolabial', '右法令纹', ['right_nasolabial_wrinkle_info', 'right_nasolabial_fold_info'], 'right_nasolabial_count'],
|
||||||
|
['left_crowsfeet', '左鱼尾纹', ['left_crowsfeet_wrinkle_info'], 'left_crowsfeet_count'],
|
||||||
|
['right_crowsfeet', '右鱼尾纹', ['right_crowsfeet_wrinkle_info'], 'right_crowsfeet_count'],
|
||||||
|
['left_eye', '左眼部细纹', ['left_eye_wrinkle_info'], 'left_undereye_count'],
|
||||||
|
['right_eye', '右眼部细纹', ['right_eye_wrinkle_info'], 'right_undereye_count'],
|
||||||
|
['left_mouth', '左嘴角纹', ['left_mouth_wrinkle_info'], 'left_mouth_count'],
|
||||||
|
['right_mouth', '右嘴角纹', ['right_mouth_wrinkle_info'], 'right_mouth_count'],
|
||||||
|
['glabella', '眉间纹', ['glabella_wrinkle_info'], 'glabella_count'],
|
||||||
|
['left_cheek', '左脸颊纹', ['left_cheek_wrinkle_info'], 'left_cheek_count'],
|
||||||
|
['right_cheek', '右脸颊纹', ['right_cheek_wrinkle_info'], 'right_cheek_count']
|
||||||
|
];
|
||||||
|
var wrinkleMetrics = [
|
||||||
|
['wrinkle_score', '皱纹分'], ['wrinkle_severity_level', '严重等级'],
|
||||||
|
['wrinkle_deep_num', '深纹条数'], ['wrinkle_shallow_num', '浅纹条数'],
|
||||||
|
['wrinkle_norm_length', '标准化长度'], ['wrinkle_norm_depth', '标准化深度'],
|
||||||
|
['wrinkle_pixel_density', '像素密度'], ['wrinkle_area_ratio', '面积比例'], ['wrinkle_deep_ratio', '深纹比例']
|
||||||
|
];
|
||||||
|
function wrinkleDetails(result) {
|
||||||
|
result = result || {};
|
||||||
|
return wrinkleDefs.map(function (def) {
|
||||||
|
var node = null;
|
||||||
|
def[2].some(function (key) { if (result[key] && typeof result[key] === 'object') { node = result[key]; return true; } return false; });
|
||||||
|
var values = {};
|
||||||
|
wrinkleMetrics.forEach(function (metric) { values[metric[0]] = scalar(node && node[metric[0]]); });
|
||||||
|
// Region counts are a valid fallback; a missing count is never zero.
|
||||||
|
if (values.wrinkle_deep_num == null) values.wrinkle_deep_num = scalar(result.wrinkle_count && result.wrinkle_count[def[3]]);
|
||||||
|
var fine = result.fine_line || result.fine_line_count;
|
||||||
|
if (values.wrinkle_shallow_num == null) values.wrinkle_shallow_num = scalar(fine && fine[def[3]]);
|
||||||
|
return { key: def[0], name: def[1], metrics: values };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function fill(template, vars) {
|
||||||
|
return template.replace(/\{([a-z_]+)\}/g, function(_, key) {
|
||||||
|
var v = vars[key];
|
||||||
|
return v == null ? '未获取' : numeric(v) ? String(Math.round(v * 100) / 100) : String(v);
|
||||||
|
}).replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
function count(result, key) { var v = result[key]; return v && numeric(v.count) ? v.count : null; }
|
||||||
|
function build(vm, result) {
|
||||||
|
result = result || {};
|
||||||
|
var views = defs.map(function(d) {
|
||||||
|
var cat = vm.cats[d[3]], vars = Object.assign({}, cat.vars || {});
|
||||||
|
var level, valid = numeric(cat.P);
|
||||||
|
var viewP = cat.P;
|
||||||
|
if (d[0] === 'color') {
|
||||||
|
viewP = numeric(vars.color_score) ? vars.color_score : vars.tone_scoring && vars.tone_scoring.color_score;
|
||||||
|
valid = vars.melanin != null && numeric(viewP);
|
||||||
|
}
|
||||||
|
if (d[0] === 'water') {
|
||||||
|
// Water copy/advice is independent of pigment/dark circles. Reuse
|
||||||
|
// the existing water cross-check; never change dimension scoring.
|
||||||
|
var waterScore = vars.water_score;
|
||||||
|
var waterValid = numeric(waterScore) && waterScore >= 0 && waterScore <= 100;
|
||||||
|
viewP = waterValid ? waterScore : null;
|
||||||
|
if (waterValid && numeric(vars.water_sev) && vars.water_sev >= 60 && vars.water_sev <= 100 && waterScore >= 80) viewP = 100 - vars.water_sev;
|
||||||
|
valid = waterValid;
|
||||||
|
}
|
||||||
|
var viewS = valid ? scoreOfP(viewP) : null;
|
||||||
|
level = levelForScore(viewS);
|
||||||
|
var template = valid && level ? Copy.conclusion[d[0]][level] : '';
|
||||||
|
if (d[0] === 'contour') {
|
||||||
|
if (vars.la == null || vars.ra == null) template = template.replace(/左右下颌线角度(?:分别为左|左) \{la\}°\/ 右 \{ra\}°[;;]/g, '');
|
||||||
|
}
|
||||||
|
if (d[0] === 'color') {
|
||||||
|
// Severity is an independent detection, not inferred from tone level
|
||||||
|
// or dark_circle_score. Missing/invalid severity must not imply mild.
|
||||||
|
var colorSeverity = scalar(result.dark_circle_severity);
|
||||||
|
var colorDescriptions = ['无明显黑眼圈', '仅存在轻度黑眼圈', '存在中度黑眼圈', '可见明显黑眼圈'];
|
||||||
|
if (numeric(colorSeverity) && colorSeverity % 1 === 0 && colorSeverity >= 0 && colorSeverity <= 3) {
|
||||||
|
vars.dark_circle_note = colorDescriptions[colorSeverity];
|
||||||
|
} else {
|
||||||
|
template = template.replace(/\{dark_circle_note\}[,;]/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d[0] === 'acne') {
|
||||||
|
var pustules = vars.acne_counts ? vars.acne_counts.acne_pustule : count(result, 'acne_pustule');
|
||||||
|
var nodules = vars.acne_counts ? vars.acne_counts.acne_nodule : count(result, 'acne_nodule');
|
||||||
|
var heavy = [pustules > 0 ? '脓包' : '', nodules > 0 ? '结节' : ''].filter(Boolean);
|
||||||
|
vars.heavy_note = heavy.length ? ',含' + heavy.join('、') + '等中重度皮损,炎症反应明显' : '';
|
||||||
|
// Never downgrade the grade independently of the displayed score,
|
||||||
|
// but never claim absence when the API detected lesions either.
|
||||||
|
if (vars.acne_cnt > 0) template = '检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},局部皮损仍需关注。';
|
||||||
|
if (vars.acne_cnt == null) template = '本次未获取完整皮损计数,请结合检测图片查看。';
|
||||||
|
else if (vars.acne_cnt === 0) template = '本次未检出明显皮损,请结合检测图片查看。';
|
||||||
|
if (vars.acne_cnt != null && vars.acne_counts_complete === false) {
|
||||||
|
template = vars.acne_cnt > 0
|
||||||
|
? '本次已获取数据中检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},部分类型计数未获取,请结合检测图片查看。'
|
||||||
|
: '本次已获取的皮损计数为0,部分类型数据缺失,请结合检测图片查看。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Missing counts are unknown, not zero: remove only the unavailable metric.
|
||||||
|
if (vars.fl_cnt == null) template = template.replace(/、细纹 \{fl_cnt\} 条/g, '(细纹计数未获取)');
|
||||||
|
if (vars.wr_cnt == null) template = template.replace(/皱纹 \{wr_cnt\} 条/g, '皱纹计数未获取');
|
||||||
|
if (vars.water_area == null) template = template.replace(/缺水面积 \{water_area\}%[、,]/g, '缺水面积未获取;');
|
||||||
|
if (vars.water_sev == null) template = template.replace(/肌肤缺水比较严重 \{water_sev\},/g, '');
|
||||||
|
if (vars.sens_area == null) template = template.replace(/泛红面积(?:达 | )\{sens_area\}%[,;]/g, '泛红面积未获取;');
|
||||||
|
if (vars.pore_cnt == null) template = template.replace(/粗大毛孔 \{pore_cnt\} 颗/g, '毛孔计数未获取');
|
||||||
|
if (vars.blackhead_cnt == null) template = template.replace(/黑头 \{blackhead_cnt\} 颗/g, '黑头计数未获取');
|
||||||
|
if (d[0] === 'smooth' && valid && level === '优秀') {
|
||||||
|
// A high aggregate score does not mean every localized pore is absent.
|
||||||
|
if (vars.pore_cnt == null || vars.blackhead_cnt == null) template = '肌肤质感整体较好,但毛孔或黑头计数未完整获取,请结合检测图查看。';
|
||||||
|
else if (vars.pore_cnt > 0 || vars.blackhead_cnt > 0) template = '肌肤质感整体较好;本次检测到粗大毛孔 {pore_cnt} 颗、黑头 {blackhead_cnt} 颗,局部仍有可改善之处。';
|
||||||
|
}
|
||||||
|
var details = d[0] === 'wrinkles' ? wrinkleDetails(result) : null;
|
||||||
|
var advice = level ? Copy.care[d[0]][level] : null;
|
||||||
|
return { key: d[0], name: d[1], dim: d[2], source: d[3], P: viewP, S: viewS, level: level || null,
|
||||||
|
text: valid ? fill(template, vars) : '本次未检出有效数据,请重新拍摄后查看。',
|
||||||
|
details: details, valid: valid, advice: valid ? advice : null };
|
||||||
|
});
|
||||||
|
var focus = focusOf(vm);
|
||||||
|
function priority(view) { return focus && view.dim === focus.key ? 0 : 1; }
|
||||||
|
function order(a, b) { return a.P - b.P || priority(a) - priority(b) || views.indexOf(a) - views.indexOf(b); }
|
||||||
|
// Select globally weakest three FIRST, then move selected focus members
|
||||||
|
// to the front. Never insert an unselected stronger focus-category view.
|
||||||
|
var chosen = views.filter(function(v) { return v.valid && numeric(v.P) && v.advice; }).sort(order).slice(0,3);
|
||||||
|
chosen.sort(function(a,b) { return priority(a) - priority(b) || order(a,b); });
|
||||||
|
var care = chosen.map(function(v) { return { key: v.key, name: v.name, dim: v.dim, P: v.P, level: v.level,
|
||||||
|
priority: priority(v) === 0, text: v.advice.care + v.advice.lifestyle }; });
|
||||||
|
var product = products.filter(function(p) { return p.match === vm.essence.name; })[0] || null;
|
||||||
|
return { version: Copy.version + '+feedback-2026-10-12+care-2026-10-15+grade-8-9+two-copy-fixes', gradeRule: gradeRule, copyRule: copyRule, careRule: 'seven-lowest-three-focus-first-v1', dims: dimNames, views: views, care: care, product: product, focus: focus };
|
||||||
|
}
|
||||||
|
// Refresh only presentation for old snapshots: scores, selected care order,
|
||||||
|
// product and original stored objects are never rewritten/recomputed.
|
||||||
|
function forDisplay(vm, result, saved) {
|
||||||
|
if (saved && vm.recommendation && vm.recommendation.ageBasis === 'display') {
|
||||||
|
var product=products.filter(function(p){return p.tier === vm.recommendation.tier;})[0] || null;
|
||||||
|
if(saved.product!==product)saved = Object.assign({}, saved, { product: product });
|
||||||
|
}
|
||||||
|
if (!saved || (saved.gradeRule === gradeRule && saved.copyRule === copyRule)) return saved || build(vm, result);
|
||||||
|
var current = build(vm, result);
|
||||||
|
var care = (saved.care || []).map(function(item) {
|
||||||
|
// Pre-seven-item snapshots contain name/text only, without a stable key.
|
||||||
|
// Match exact known names; never infer from array position or insert items.
|
||||||
|
var view = current.views.filter(function(v) { return item.key ? v.key === item.key : v.name === item.name; })[0];
|
||||||
|
return view && view.advice ? Object.assign({}, item, { key: view.key, level: view.level, text: view.advice.care + view.advice.lifestyle }) : item;
|
||||||
|
});
|
||||||
|
return Object.assign({}, saved, { gradeRule: gradeRule, copyRule: copyRule, views: current.views, care: care });
|
||||||
|
}
|
||||||
|
return { dimensionScore: dimensionScore, scoreOfP: scoreOfP, levelForScore: levelForScore, internalLevel: internalLevel, gradeRule: gradeRule, copyRule: copyRule, build: build, forDisplay: forDisplay, products: products, levelMap: levelMap, dimNames: dimNames, wrinkleDetails: wrinkleDetails, focusOf: focusOf, customerText: customerText };
|
||||||
|
});
|
||||||
Vendored
+1265
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user