feat(h5): align October UI and add WeChat auth with saved reports
This commit is contained in:
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user