feat(h5): align October UI and add WeChat auth with saved reports

This commit is contained in:
xsl
2026-10-11 17:28:11 +08:00
parent ee744b6b5f
commit ce8d0ff1f7
126 changed files with 18711 additions and 1898 deletions
+30
View File
@@ -0,0 +1,30 @@
"""Generate versioned presentation copy directly from the approved reference."""
import json
import re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
text = (root / 'reference/AI测肤报告页话术修改.md').read_text()
text = re.sub(r'\\([.{}_&-])', r'\1', text)
keys = ['contour', 'wrinkles', 'color', 'smooth', 'water', 'sens', 'acne']
data = {'version': '2026-10-09', 'conclusion': {}, 'care': {}}
section, key = None, None
for line in text.splitlines():
heading = re.match(r'#### ([56])\.(\d)', line)
if heading:
section = 'conclusion' if heading[1] == '5' else 'care'
key = keys[int(heading[2]) - 1]
data[section][key] = {}
conclusion = re.match(r'\*\*【(改善|良好|优秀)】\*\*\s*(.+)', line)
care = re.match(r'【(改善|良好|优秀)·(护理|生活方式)】\s*(.+)', line)
if section == 'conclusion' and conclusion:
data[section][key][conclusion[1]] = conclusion[2].strip()
if section == 'care' and care:
data[section][key].setdefault(care[1], {})['care' if care[2] == '护理' else 'lifestyle'] = care[3].strip()
for category in keys:
assert len(data['conclusion'][category]) == len(data['care'][category]) == 3
payload = json.dumps(data, ensure_ascii=False, indent=2)
(root / 'h5/js/report-copy.js').write_text(
'// Generated from reference/AI测肤报告页话术修改.md; run tools/build_report_copy.py.\n'
'(function(root,factory){if(typeof module==="object"&&module.exports)module.exports=factory();else root.ReportCopy=factory();})(typeof window!=="undefined"?window:this,function(){return '
+ payload + ';});\n')
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Export the October UI PSD without changing existing application assets.
Requires: pip install psd-tools Pillow aggdraw
"""
import argparse
import hashlib
import json
from pathlib import Path
from PIL import Image, ImageDraw
from psd_tools import PSDImage
ROOT = Path(__file__).resolve().parents[1]
DESIGN = ROOT / "design/ui-20261009"
ASSETS = ROOT / "h5/assets/img/ui-20261009"
# PSD layer indices are recorded alongside descriptive filenames for traceability.
ASSET_SPECS = [
("bg-splash.jpg", "0/0", "0"),
("bg-profile.jpg", "3/0/0", "3"),
("bg-login.jpg", "9/0/0", "9"),
("bg-report.jpg", "8/0", "8"),
("bg-history.jpg", "2/0", "2"),
("brand-logo.png", "0/1/0", None),
("brand-login.png", "9/7/1", None),
("icon-close.png", "1/6", None),
("icon-dropdown.png", "3/1/2", None),
("icon-back.png", "3/6/0", None),
("icon-album.png", "5/1/1", None),
("icon-switch-camera.png", "5/1/0", None),
("icon-wechat.png", "9/7/6", None),
("icon-guest.png", "9/7/7", None),
("icon-shutter.png", "5/1/2", None),
("skin-0-dry.png", "3/3/1/0/2", None),
("skin-1-mixeddry.png", "3/3/1/1/2", None),
("skin-2-neutral.png", "3/3/1/2/2", None),
("skin-3-oily.png", "3/3/1/3/2", None),
("skin-4-mixedoily.png", "3/3/1/4/2", None),
("product-25.png", "8/1/0/1", None),
("product-35.png", "8/1/1/2", None),
("product-50.png", "8/1/2/4", None),
("product-badge.png", "8/1/0/3", None),
("score-ring-base.png", "8/12/5", None),
("score-ring-value.png", "8/12/6", None),
("sample-contour.png", "8/9/8", None),
("sample-lines.png", "8/9/10", None),
("sample-tone.png", "8/10/10", None),
("sample-redness.png", "8/11/8", None),
("camera-mask-front.png", "5/0", "5"),
("camera-mask-right.png", "6/0", "6"),
("camera-mask-left.png", "7/0", "7"),
("camera-guide-front.png", ["5/8", "5/9", "5/10"], "5"),
("camera-guide-right.png", ["6/8", "6/9", "6/10"], "6"),
("camera-guide-left.png", ["7/8", "7/9", "7/10"], "7"),
]
def layer_at(psd, indices):
layer = psd
for part in indices.split("/"):
layer = layer[int(part)]
return layer
def bounds(layer):
# Hidden groups can report an empty bbox; union their children explicitly.
if layer.is_group():
boxes = [bounds(child) for child in layer]
boxes = [b for b in boxes if b[2] > b[0] and b[3] > b[1]]
if boxes:
return (min(b[0] for b in boxes), min(b[1] for b in boxes),
max(b[2] for b in boxes), max(b[3] for b in boxes))
return tuple(layer.bbox)
def save_image(image, target):
target.parent.mkdir(parents=True, exist_ok=True)
if target.suffix == ".jpg":
background = Image.new("RGB", image.size, "white")
background.paste(image, mask=image.getchannel("A") if image.mode == "RGBA" else None)
background.save(target, quality=92, optimize=True, progressive=True)
else:
image.save(target, optimize=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("psd", type=Path)
parser.add_argument("--assets-only", action="store_true", help="Reuse existing page exports")
args = parser.parse_args()
psd = PSDImage.open(args.psd)
DESIGN.mkdir(parents=True, exist_ok=True)
manifest = {"source": args.psd.name, "document_size": psd.size,
"pages": [], "assets": [], "layers": []}
def inventory(group, prefix=""):
for index, layer in enumerate(group):
key = f"{prefix}/{index}".strip("/")
entry = {"path": key, "name": layer.name, "kind": layer.kind,
"bbox": list(layer.bbox), "visible": layer.visible}
if layer.kind == "type":
entry["text"] = layer.text
manifest["layers"].append(entry)
if layer.is_group():
inventory(layer, key)
inventory(psd)
previews = []
for index, page in enumerate(psd):
print(f"Page {index}: {page.name}", flush=True)
# Cached layer pixels preserve Photoshop text appearance without local fonts.
target = DESIGN / "pages" / f"{index + 1:02d}-{page.name}.png"
if args.assets_only:
image = Image.open(target)
else:
image = page.composite(viewport=page.bbox)
save_image(image, target)
preview = image.copy()
preview.thumbnail((216, 1553))
previews.append((page.name, preview))
manifest["pages"].append({"file": str(target.relative_to(ROOT)),
"name": page.name, "size": image.size,
"bbox": list(page.bbox)})
thumbs = []
for filename, indices, crop_page in ASSET_SPECS:
source_indices = indices if isinstance(indices, list) else [indices]
layers = [layer_at(psd, key) for key in source_indices]
layer = layers[0]
viewport = layer_at(psd, crop_page).bbox if crop_page else bounds(layer)
print(f"Asset: {filename}", flush=True)
# A Photoshop clipping layer composites to empty if its base is omitted.
# Disable only this root layer's clipping while exporting it independently.
image = Image.new("RGBA", (viewport[2]-viewport[0], viewport[3]-viewport[1]))
for source in layers:
was_clipping = source.clipping
if was_clipping:
source.clipping = False
part = source.composite(viewport=viewport, layer_filter=lambda _: True)
if was_clipping:
source.clipping = True
if part is not None:
image = Image.alpha_composite(image, part.convert("RGBA"))
if image is None:
raise RuntimeError(f"No image for {indices}: {layer.name}")
if image.mode == "RGBA" and image.getchannel("A").getextrema()[1] == 0:
raise RuntimeError(f"Empty transparent export for {indices}: {filename}")
target = ASSETS / filename
save_image(image, target)
manifest["assets"].append({"file": str(target.relative_to(ROOT)),
"source_layer": indices, "layer_name": layer.name,
"size": image.size, "bbox": list(viewport),
"bytes": target.stat().st_size,
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
"original_visible": layer.is_visible(),
"usage": "reference-only" if filename.startswith("sample-")
else "frontend-asset"})
thumb = image.copy()
thumb.thumbnail((180, 190))
thumbs.append((filename, thumb))
sheet = Image.new("RGB", (5 * 236, 2 * 1620), "#eeeeee")
draw = ImageDraw.Draw(sheet)
for i, (_, preview) in enumerate(previews):
x, y = (i % 5) * 236 + 10, (i // 5) * 1620 + 35
draw.text((x, y - 25), f"Page {i+1:02d}", fill="black")
sheet.paste(preview, (x, y), preview if preview.mode == "RGBA" else None)
save_image(sheet, DESIGN / "pages-overview.jpg")
sheet = Image.new("RGB", (6 * 205, ((len(thumbs)+5)//6)*230), "#e3e3e3")
draw = ImageDraw.Draw(sheet)
for i, (name, thumb) in enumerate(thumbs):
x, y = (i % 6)*205 + 10, (i // 6)*230 + 30
draw.text((x, y - 22), name, fill="black")
sheet.paste(thumb, (x, y), thumb if thumb.mode == "RGBA" else None)
save_image(sheet, DESIGN / "assets-overview.jpg")
(DESIGN / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+"\n")
print(f"Exported {len(previews)} pages and {len(thumbs)} assets", flush=True)
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Derive H5 crops from exported PSD assets; never crop the original exports."""
from pathlib import Path
from PIL import Image
import json
import cv2
import numpy as np
root = Path(__file__).resolve().parents[1] / 'h5/assets/img/ui-20261009'
for name in ['bg-splash', 'bg-login', 'bg-profile', 'bg-history', 'bg-report']:
source = Image.open(root / (name + '.jpg'))
# Native status/navigation area belongs to the host, not the H5 document.
top = 253 if name == 'bg-report' else 248
source.crop((0, top, source.width, source.height)).save(root / (name + '-h5.jpg'), quality=92, optimize=True)
geometry = []
for direction in ['front', 'left', 'right']:
alpha = np.array(Image.open(root / ('camera-mask-' + direction + '.png')).getchannel('A'))
contours, _ = cv2.findContours((alpha < 100).astype('uint8'), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contour = max((c for c in contours if 500 < cv2.boundingRect(c)[1] < 1600), key=cv2.contourArea)
points = cv2.approxPolyDP(contour, 1.4, True).reshape(-1, 2)
outline = 'M' + 'L'.join(f'{x/2.88:.2f},{y/2.88:.2f}' for x, y in points) + 'Z'
guide_alpha = np.array(Image.open(root / ('camera-guide-' + direction + '.png')).getchannel('A'))
count, labels, stats, _ = cv2.connectedComponentsWithStats((guide_alpha > 100).astype('uint8'))
guides = []
for i in range(1, count):
x, y, w, h, area = stats[i]
if area < 50:
continue
points = []
if w > h:
for col in range(x, x+w, 3):
rows = np.where(labels[:, col] == i)[0]
if len(rows): points.append((col, rows.mean()))
else:
for row in range(y, y+h, 3):
cols = np.where(labels[row, :] == i)[0]
if len(cols): points.append((cols.mean(), row))
guides.append('M' + 'L'.join(f'{px/2.88:.2f},{py/2.88:.2f}' for px, py in points))
geometry.append({'outline': outline, 'guides': guides})
(root.parents[2] / 'js/camera-geometry.js').write_text('window.CameraGeometry = ' + json.dumps(geometry) + ';\n')