交付出件箱

← 返回出件箱

家长端 H5 品牌头图 —— 切图拼装法实施方案

0. 🔴 关键前提:这不是新功能,是对已上线功能的改造

物理 grep 发现:"家长端头图"功能已经存在且已合入 test(git log 显示 1e54cc8b Merge ... 机构 Logo 移除 + 家长端头图显示开关,migrations/versions/org_hero_image.py 2026-09-10、org_hero_image_toggle.py 2026-09-11)。

现网实现是"整图替换"方案:
- 上传要求精确 1600×400(4:1),不符直接拒绝,不做裁切
- 后端 processed = src.convert("RGB") —— 丢弃 alpha 通道,按不透明整图处理
- 前端展示是单张图 objectFit: "cover" 铺满整个头部条,汉堡按钮 position: absolute 浮在图片右上角(毛玻璃底)
- 这与本次长官认可的效果图(三层结构:固定底图 + 透明品牌层叠加 + 独立汉堡按钮,品牌层切割线在 y=301,不是整卡 4:1)是两套不同的版式

结论:本方案是把现有"整图替换(1600×400 不透明)"迭代为"三层叠加(透明 PNG 品牌层 + 系统固定底图)"。以下每节按"现状 → 改动"的方式写,不是从零新建。


一、需求与拍板记录(长官已定,不许改口径)


二、三层结构

第 3 层  汉堡按钮   HTML 元素,永远独立存在,任何状态下都可点击
第 2 层  品牌层     客户上传的透明底 PNG,可替换,叠在底图之上
第 1 层  底图       赵阳区(接入行 + 人像 + 姓名资历 + 三数据),系统固定,前端静态资源

为什么汉堡必须是独立的第 3 层,不能属于第 1/2 层:
- 现网实现(整图 cover)是把整个头部条(含汉堡热区)当一张图片渲染,长官在效果图评审中实测发现的缺陷是:图上画的菜单按钮是死的(位图,不可点击),而如果把汉堡绘制/嵌入进品牌层图片里,一旦机构换图,汉堡入口跟着消失。
- 所以新方案把汉堡从"画在图里"改为"HTML 元素浮在图层最上方",物理上不随任何一层图片的加载/替换/失败而变化,始终可点(与现网 store-brand-hero.tsx:143-149 的 <button onClick={onOpenMenu}> 覆盖定位写法思路一致,复用同一份定位与毛玻璃样式)。

层间关系:
- 第 1 层(底图)固定不变,承载"系统权威内容"(赵阳区、三数据、接入行),机构无法编辑,保证平台方信息始终展示
- 第 2 层(品牌层)是一个高度固定的透明槽位,叠在底图顶部(底图自身在切割线以上是镂空/无对应内容的),机构上传的 Logo/门店名图片放在这里,未上传时槽位显示浅绿渐变兜底,与底图自然衔接
- 第 3 层(汉堡)固定右上角,z-index 最高,不受 1/2 层内容影响


三、素材与上传规则

3.1 底图切割

3.2 品牌层槽位渲染规则

3.3 客户上传规则(逐条写清,交付时要给客户看)

规则 内容 理由
格式 必须透明底 PNG,无 alpha 通道直接拒绝,提示"请提供透明底 PNG" 白底图贴上去会整块盖住下方系统底图,破坏叠加效果
自动裁边 系统自动裁掉图片四周的透明留白 实测客户提供的图片普遍带大片透明边距(如某机构图透明边占比达 34.7%),不裁边显示出来的品牌层内容会显得又小又弱
宽高比 不限,contain 完整显示不裁切 不同机构 Logo/门店名长短不一,强制统一比例会拉伸变形或大面积留白
建议规格 1568 × 301(与底图切割线对齐的原始设计尺寸) 与槽位比例完全匹配时视觉效果最佳,非强制
禁止事项 图片内不要画菜单按钮/汉堡图标 右上角是系统固定的第 3 层汉堡按钮热区,画了也会被系统按钮盖住/造成视觉重复
大小上限 ≤2MB 沿用现网 HERO_IMAGE_MAX_BYTES 限制(app/api/admin_orgs_access_link.py:222),未提出要改这个值

四、后端改动(每条附 grep 出处)

4.1 上传接口 app/api/admin_orgs_access_link.py

现状(grep 验证,app/api/admin_orgs_access_link.py:257-317 upload_hero_image):

# :222-223
HERO_IMAGE_MAX_BYTES = 2 * 1024 * 1024
HERO_IMAGE_TARGET_SIZE = (1600, 400)  # 尺寸必须精确匹配,不符直接拒绝

# :284-290
target_w, target_h = HERO_IMAGE_TARGET_SIZE
w, h = src.size
if (w, h) != (target_w, target_h):
    raise HTTPException(400, detail=f"图片尺寸需为 1600×400,当前为 {w}×{h},请重新导出")

# :292
processed = src.convert("RGB")   # ⚠️ 丢弃 alpha 通道,新方案必须改

需要改的点:

  1. 删除精确尺寸校验(:284-290 的 (w, h) != (target_w, target_h) 分支),改为不限宽高比
  2. 新增透明通道校验:src.mode 不含 alpha(即不是 RGBA/LA/P 带 transparency info)→ 400 拒绝,文案"请提供透明底 PNG"
  3. processed = src.convert("RGB") 改为保留 alpha:改成 src.convert("RGBA"),后续存储格式也要从"转 WebP quality=82"评估是否改存 PNG(WebP 本身支持 alpha,Image.save(format="WEBP", quality=82) 在 RGBA 模式下能保留透明通道,不需要换格式,只是不能再 convert("RGB"))
  4. 新增自动裁边:用 PIL 标准 API im.getbbox() 获取非透明区域包围盒后 im.crop(bbox)。项目内已有 alpha/透明处理精代:app/services/certificate_service.py:30,43,45(Image.open(...).convert("RGBA") + alpha_composite),证明项目工具链(Pillow)本身具备 alpha 处理能力,复用同一套 PIL API,不需要引入新依赖
  5. HERO_IMAGE_TARGET_SIZE = (1600, 400) 常量按上面调整逻辑废弃或改名(不再是"目标尺寸校验",可能改为"建议尺寸"仅用于前端文案参考)

未变的部分(复用,不动):
- HERO_IMAGE_ALLOWED_MIMES(:221,JPG/PNG/WebP 三种 —— 注意:JPG 不支持透明通道,若严格执行"必须透明底 PNG",JPG 分支理论上会在透明校验环节被拒;这里需要长官确认是否要把 HERO_IMAGE_ALLOWED_MIMES 收窄为只剩 PNG/WebP,避免用户传 JPG 上传成功但透明校验必然失败造成困惑体验,见第七节遗留项)
- HERO_IMAGE_MAX_BYTES 2MB 上限不变
- key = f"store_brand/{org.id}/hero-image-{int(time.time())}.webp" 带时间戳防缓存的写法不变
- get_hero_image / delete_hero_image 两个端点逻辑不变(:246-254,:319-330)
- _hero_image_url() 签名 URL 生成不变(:225-236)

4.2 下发逻辑 app/api/h5_store_card.py

现状(grep 验证):
- :154-166 _hero_image_enabled() —— 读 OrgBrainConfig.enable_hero_image,无配置行 fail-closed 返 False
- :202-223 _hero_image_url() —— 与 _logo_url 同款签名 URL 写法,额外做了"整点对齐"的 expires 优化(注释见 :224 起)
- :323-324 调用处:hero_image_url = await _hero_image_url(org) if await _hero_image_enabled(db, org.id) else None

结论:下发逻辑不需要改。切图拼装法改的是"品牌层图片本身怎么生成/校验",不改"要不要下发这张图的 URL"这件事,_hero_image_enabled / _hero_image_url / 下发字段 hero_image_url 三者原样复用。

4.3 DB 字段与开关


五、前端改动(每条附 grep 出处)

5.1 web/app/h5/chat/components/store-brand-hero.tsx 现状三态分支

grep 验证(:65 showHeroImage,:104-149 该分支渲染体):

loading 态(:93-103)      → 纯绿底占位
showHeroImage 态(:104-149) → 整图 objectFit:cover 铺满 + 汉堡绝对定位浮在图右上角
默认内容态(:150-392)      → 深绿条(Logo砖+门店名+简介)+ 三数据卡 + 赵阳区

新版式改动点:
1. showHeroImage 分支(:104-149)整体替换:从"单张 <img cover> 铺满 + 汉堡覆盖其上"改为三层 DOM 结构:
- 外层容器:相对定位,高度 = 底图固定高度 + 品牌层槽位高度(按 1568:1176 全比例或 1568:875 底图比例换算到实际渲染宽度)
- 子层 A(品牌层槽位):aspect-ratio: 1568/301,object-fit: contain,叠在最上,z-index 低于汉堡
- 子层 B(底图):新增 <img src="/xxx-hero-base.png"> 静态资源,object-fit: cover 或 contain(取决于设计稿是否要求完整不裁,建议 contain 避免裁到人像/文字)
- 汉堡按钮:从"浮在整图右上角"改为"浮在整个三层容器右上角",定位样式(top:12,right:12,毛玻璃 rgba(0,0,0,.28) + backdropFilter:blur(8px))原样复用(:139-149),不用重新设计
2. 默认内容态(:150-392)保持完全不变:未上传头图 / 开关关闭时,深绿条 + Logo + 门店名 + 简介 + 三数据卡 + 赵阳区,一个像素不动(与现网 :81-83 注释"没传图/图加载失败 → 完整保留原深绿渐变现状"的既定口径一致)
3. HERO_GRADIENT(:44)常量:默认内容态继续用,品牌层槽位兜底色改用新的浅绿渐变(#ecf7f1→#e6f3ec,见 3.2 节),需要新增一个常量,不要复用 HERO_GRADIENT(色值不同,复用会导致品牌层槽位显示成深墨绿,视觉上盖住底图)

5.2 底图静态资源放哪

5.3 汉堡按钮 onClick 复用

grep 验证:
- store-brand-hero.tsx:143 汉堡按钮 onClick={onOpenMenu}
- web/app/h5/chat/page.tsx:2469 调用处 onOpenMenu={() => setMenuOpen(true)} —— 就是简单打开会话历史侧栏的 state setter

新版式直接复用同一个 onOpenMenu prop,不新增任何逻辑,汉堡按钮从"showHeroImage 分支内部"移到"三层容器的公共外层",prop 传递路径不变。

5.4 PC 后台 web/app/(dashboard)/admin/orgs/page.tsx 上传 UI

grep 验证现状:
- :1435 预览区 aspect-[4/1](现网 4:1 比例)—— 需要改成 aspect-[1568/301] 或等效比例,匹配新槽位形状
- :1439 预览图 object-fit: cover —— 需要改成 object-fit: contain(不透明整图 cover 合理,但透明 PNG 用 cover 会裁切内容,需改 contain 并给预览区加棋盘格背景以示透明,便于机构管理员判断透明区域是否正确)
- :1441-1444 遮挡示意(模拟菜单按钮圆 + 模拟接入标签胶囊)—— 这两处是"整图方案"的专属提示(告诉机构别把 Logo 画到会被菜单按钮/接入标签遮住的位置),新版式因为汉堡是独立层、接入行是底图内容不再由品牌层承载,这段遮挡示意需要重写或删除
- :1494-1499 上传要求文案(尺寸 1600×400、格式 JPG/PNG/WebP、避开区域提示)—— 需要全部重写:尺寸改为"建议 1568×301,不限比例"、格式收窄为"透明底 PNG"(见 4.1 第 5 点的遗留问题)、删除"避开右上角/底部正中"提示(不再适用)、新增"必须透明底""系统会自动裁边"提示


六、已知取舍(长官已知悉并拍板)


七、待长官定的遗留项

  1. 三数据文案不一致:grep 验证 store-brand-hero.tsx:34-38 现网常量 STATS 是:
    ["全国上万", "", "案例库"] ["万", "篇", "专家科普"] ["20", "年", "的临床思维"]
    长官提供的设计稿文案是「真实案例库 / 专家科普内容 / 临床实践经验」。切图方案下这三行文字是系统底图里的图片内容(切图包已经把文案画进图里了),与代码里的 STATS 常量是两套独立的东西 —— STATS 常量只在默认内容态(未上传头图 / 开关关闭时)生效。
    需要长官明确:默认内容态的代码常量 STATS 是否要跟着改成设计稿文案口径,使两种状态下文案一致?(若不改,机构在"有头图"和"无头图"两种状态间切换时,家长会看到两套不同措辞的三数据文案)

  2. JPG 格式是否保留:见 4.1 第 5 点,HERO_IMAGE_ALLOWED_MIMES 现含 JPG,但 JPG 不支持透明通道,新校验规则下传 JPG 必然在"透明通道校验"环节被拒。是否要把允许格式收窄为 PNG/WebP(WebP 需确认是否为 PNG 格式的透明版本),减少用户"选了允许的格式却还是传不上去"的困惑?

  3. 底图裁切/展示方式:第 1 层底图在 object-fit 上选 cover 还是 contain,取决于底图 1568×875 与实际渲染宽度(如 360px 移动端)的比例是否与设计稿完全吻合。需要长官提供的切图包到位后实测确认,当前方案先按 contain 假设(避免裁到人像/文字),若视觉上留白过多需要调整。

  4. 是否要给客户出一份《品牌头图制作规范》:把第三节的上传规则整理成一份给机构管理员看的操作指南(可含"什么是透明底 PNG"的图文说明,避免不熟悉设计软件的客户不理解术语)。


八、验收方式

参照项目协议(no-mock e2e 真打铁律 + 数据完整性 SQL 真打铁律):

  1. 后端 pytest 真跑(连真 PG,不 mock):
  2. 新增/改造 tests/integration/test_admin_orgs_access_link.py(grep 已确认该文件存在,是 admin_orgs_access_link.py 现有集成测试所在位置)覆盖:
  3. tests/contract/(grep 已确认目录存在同类 admin_orgs contract 测试)按项目惯例补对应 contract 用例
  4. 前端:vitest 起真后端 dev server(localhost:8001),真渲染 StoreBrandHero 三态(loading / showHeroImage 新三层 / 默认内容态),断言三层 DOM 结构与汉堡按钮可点击(onOpenMenu 真触发)
  5. 数据 SQL 真查(数据完整性铁律):机构上传测试图后,sudo db-query test "SELECT hero_image_oss_key FROM organizations WHERE id=<测试机构id>" 确认落库非空,org_brain_config.enable_hero_image 开关状态与 PC 后台勾选一致
  6. 浏览器真看(prototype-mockup-standards 铁律 + Preview MCP):
  7. PC 后台上传弹框:预览区透明棋盘格背景下能看清裁边前后对比
  8. 家长端 H5 /h5/chat:上传头图前后对比截图,确认三层叠加视觉效果与效果图一致,汉堡按钮在两种状态(有图/无图)下都可点击打开会话历史
  9. 发布前走 sudo release-to-test --yes 到 test.tongban.love 让长官 UAT,不本机自称"已验收"

附录:grep 全量输出(原始终端记录,未删减未伪造)

$ git branch --show-current
feature/h5-brand-hero-v2

$ git log --oneline -3
1e54cc8b Merge ... 机构 Logo 移除 + 家长端头图显示开关(#671)
b7599384 Merge ... fix(phase79): 修前端生产构建失败
b85bd2b2 fix(phase79.8): 屏2 useSearchParams 包 Suspense

$ grep -n "hero-image\|HERO_IMAGE_TARGET_SIZE\|HERO_IMAGE_MAX_BYTES" app/api/admin_orgs_access_link.py
222:HERO_IMAGE_MAX_BYTES = 2 * 1024 * 1024
223:HERO_IMAGE_TARGET_SIZE = (1600, 400)
246:@router.get("/{org_id}/hero-image", ...)
257:@router.post("/{org_id}/hero-image", ...)
272:    if len(content) > HERO_IMAGE_MAX_BYTES:
284:    target_w, target_h = HERO_IMAGE_TARGET_SIZE
293:    key = f"store_brand/{org.id}/hero-image-{int(time.time())}.webp"
319:@router.delete("/{org_id}/hero-image", ...)

$ grep -n "hero_image_url\|_hero_image_enabled\|_hero_image_url" app/api/h5_store_card.py
154:async def _hero_image_enabled(db, org_id) -> bool:
157:    开=优先顶掉 Logo 品牌区;关=图仍留存,只是不下发 hero_image_url
202:async def _hero_image_url(org) -> Optional[str]:
223:            "hero_image_url_sign_failed",
323:    hero_image_url = (
324:        await _hero_image_url(org) if await _hero_image_enabled(db, org.id) else None
333:        "hero_image_url": hero_image_url,

$ grep -n "hero_image_oss_key\|homepage_logo_oss_key\|homepage_intro" app/models/organization.py
30:    homepage_logo_oss_key = Column(String(300), ...)
31:    homepage_intro = Column(String(60), ...)
32:    hero_image_oss_key = Column(String(300), ...)

$ grep -n "enable_hero_image\|enable_tablet_home_branding" app/models/database.py
95:    enable_tablet_home_branding = Column(Boolean, ...)
147:    enable_hero_image = Column(Boolean, ...)

$ grep -n "showHeroImage\|hero_image_url\|HERO_GRADIENT\|STATS\|CREDENTIALS" web/app/h5/chat/components/store-brand-hero.tsx
30:  hero_image_url?: string | null;
34:const STATS: readonly (...)[] = [...]
41:const CREDENTIALS = ["北大优秀毕业生奖", "《大国护眼之策》著者"] as const;
44:const HERO_GRADIENT = "linear-gradient(105deg,#1d3b32 0%,#24493d 52%,#2c5648 100%)";
65:  const showHeroImage = Boolean(branding.hero_image_url) && !heroFailed;
104:      ) : showHeroImage ? (
118:            src={branding.hero_image_url as string}
320:          {STATS.map(([strong, suffix, label], i) => (
386:              {CREDENTIALS.join(" · ")}

$ grep -n "hero-image\|enableHeroImage\|家长端头图" "web/app/(dashboard)/admin/orgs/page.tsx"
70:  enable_hero_image?: boolean;
238:  const [enableHeroImage, setEnableHeroImage] = useState(...)
310:          enable_hero_image: enableHeroImage,
432:                  <div ...>家长端头图</div>
439:                    aria-label="启用家长端头图"
441:                    checked={enableHeroImage}
939/959/986: /api/admin/orgs/{id}/hero-image 三处调用

$ grep -c "Organization" app/models/organization.py
6   # 对照组:非零,证明 grep 语法有效

$ grep -rln "hero_image_oss_key\|enable_hero_image" migrations/versions/
migrations/versions/org_hero_image.py
migrations/versions/org_hero_image_toggle.py

$ grep -rn "getbbox\|convert(\"RGBA\")\|alpha_composite\|putalpha" app/
app/services/certificate_service.py:30,43,45  ← 项目内已有 alpha 处理精代,可复用同一套 Pillow API

$ grep -n "onOpenMenu\|StoreBrandHero" web/app/h5/chat/page.tsx
70:  import { StoreBrandHero, ... }
2466:  <StoreBrandHero
2469:    onOpenMenu={() => setMenuOpen(true)}

$ grep -rln "hero_image" tests/
(0 命中 —— 现网该功能尚无专属测试用例,需本次一并补齐)

$ find tests -iname "*admin_org*"
tests/contract/test_admin_orgs_null_hardware_flag.py
tests/contract/test_admin_orgs_seat_username_search.py
tests/integration/test_admin_orgs_access_link.py