feature/h5-brand-hero-v2(基线 = 最新 origin/test)物理 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 品牌层 + 系统固定底图)"。以下每节按"现状 → 改动"的方式写,不是从零新建。
<img> 叠加)第 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 层内容影响
/zhaoyang-portrait.png 的做法,web/public/ 下新增一张 PNG/WebP)aspect-ratio: 1568 / 301object-fit: contain(完整显示,不裁切,机构上传的图可能宽高比不一,cover 会裁掉内容)#ecf7f1 → #e6f3ec(区别于现网整图方案的深墨绿 HERO_GRADIENT,因为品牌层槽位本身较浅,深绿兜底会与下方第 1 层深色底图撞色;具体色值待出图阶段核实,这里先按效果图给出的口径记录,不是凭空定色)| 规则 | 内容 | 理由 |
|---|---|---|
| 格式 | 必须透明底 PNG,无 alpha 通道直接拒绝,提示"请提供透明底 PNG" | 白底图贴上去会整块盖住下方系统底图,破坏叠加效果 |
| 自动裁边 | 系统自动裁掉图片四周的透明留白 | 实测客户提供的图片普遍带大片透明边距(如某机构图透明边占比达 34.7%),不裁边显示出来的品牌层内容会显得又小又弱 |
| 宽高比 | 不限,contain 完整显示不裁切 |
不同机构 Logo/门店名长短不一,强制统一比例会拉伸变形或大面积留白 |
| 建议规格 | 1568 × 301(与底图切割线对齐的原始设计尺寸) | 与槽位比例完全匹配时视觉效果最佳,非强制 |
| 禁止事项 | 图片内不要画菜单按钮/汉堡图标 | 右上角是系统固定的第 3 层汉堡按钮热区,画了也会被系统按钮盖住/造成视觉重复 |
| 大小上限 | ≤2MB | 沿用现网 HERO_IMAGE_MAX_BYTES 限制(app/api/admin_orgs_access_link.py:222),未提出要改这个值 |
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 通道,新方案必须改
需要改的点:
:284-290 的 (w, h) != (target_w, target_h) 分支),改为不限宽高比src.mode 不含 alpha(即不是 RGBA/LA/P 带 transparency info)→ 400 拒绝,文案"请提供透明底 PNG"processed = src.convert("RGB") 改为保留 alpha:改成 src.convert("RGBA"),后续存储格式也要从"转 WebP quality=82"评估是否改存 PNG(WebP 本身支持 alpha,Image.save(format="WEBP", quality=82) 在 RGBA 模式下能保留透明通道,不需要换格式,只是不能再 convert("RGB"))im.getbbox() 获取非透明区域包围盒后 im.crop(bbox)。项目内已有 alpha/透明处理精代:app/services/certificate_service.py:30,43,45(Image.open(...).convert("RGBA") + alpha_composite),证明项目工具链(Pillow)本身具备 alpha 处理能力,复用同一套 PIL API,不需要引入新依赖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)
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 三者原样复用。
hero_image_oss_key(app/models/organization.py:32)、enable_hero_image(app/models/database.py:147-153,挂在 OrgBrainConfig 表)均已存在,直接复用,不新增字段migrations/versions/org_hero_image.py(加列)、migrations/versions/org_hero_image_toggle.py(加开关),均已在 test 分支历史中(git log 显示已合并)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(色值不同,复用会导致品牌层槽位显示成深墨绿,视觉上盖住底图)
store-brand-hero.tsx:366 直接 <img src="/zhaoyang-portrait.png"> 引用 web/public/ 下的静态文件(grep 验证 web/public/ 目录已有 zhaoyang-portrait.png、zhaoyang-cutout-v4.png、xiaotong-avatar.png 等同类精抠图素材)web/public/,命名建议 hero-base-v1.png(或按项目现有命名习惯,长官切图包给什么格式就存什么格式,PNG/WebP 均可,优先 WebP 控制体积)grep 验证:
- store-brand-hero.tsx:143 汉堡按钮 onClick={onOpenMenu}
- web/app/h5/chat/page.tsx:2469 调用处 onOpenMenu={() => setMenuOpen(true)} —— 就是简单打开会话历史侧栏的 state setter
新版式直接复用同一个 onOpenMenu prop,不新增任何逻辑,汉堡按钮从"showHeroImage 分支内部"移到"三层容器的公共外层",prop 传递路径不变。
web/app/(dashboard)/admin/orgs/page.tsx 上传 UIgrep 验证现状:
- :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 点的遗留问题)、删除"避开右上角/底部正中"提示(不再适用)、新增"必须透明底""系统会自动裁边"提示
三数据文案不一致:grep 验证 store-brand-hero.tsx:34-38 现网常量 STATS 是:
["全国上万", "", "案例库"]
["万", "篇", "专家科普"]
["20", "年", "的临床思维"]
长官提供的设计稿文案是「真实案例库 / 专家科普内容 / 临床实践经验」。切图方案下这三行文字是系统底图里的图片内容(切图包已经把文案画进图里了),与代码里的 STATS 常量是两套独立的东西 —— STATS 常量只在默认内容态(未上传头图 / 开关关闭时)生效。
需要长官明确:默认内容态的代码常量 STATS 是否要跟着改成设计稿文案口径,使两种状态下文案一致?(若不改,机构在"有头图"和"无头图"两种状态间切换时,家长会看到两套不同措辞的三数据文案)
JPG 格式是否保留:见 4.1 第 5 点,HERO_IMAGE_ALLOWED_MIMES 现含 JPG,但 JPG 不支持透明通道,新校验规则下传 JPG 必然在"透明通道校验"环节被拒。是否要把允许格式收窄为 PNG/WebP(WebP 需确认是否为 PNG 格式的透明版本),减少用户"选了允许的格式却还是传不上去"的困惑?
底图裁切/展示方式:第 1 层底图在 object-fit 上选 cover 还是 contain,取决于底图 1568×875 与实际渲染宽度(如 360px 移动端)的比例是否与设计稿完全吻合。需要长官提供的切图包到位后实测确认,当前方案先按 contain 假设(避免裁到人像/文字),若视觉上留白过多需要调整。
是否要给客户出一份《品牌头图制作规范》:把第三节的上传规则整理成一份给机构管理员看的操作指南(可含"什么是透明底 PNG"的图文说明,避免不熟悉设计软件的客户不理解术语)。
参照项目协议(no-mock e2e 真打铁律 + 数据完整性 SQL 真打铁律):
tests/integration/test_admin_orgs_access_link.py(grep 已确认该文件存在,是 admin_orgs_access_link.py 现有集成测试所在位置)覆盖:hero_image_oss_key 落库,签名 URL 可访问tests/contract/(grep 已确认目录存在同类 admin_orgs contract 测试)按项目惯例补对应 contract 用例localhost:8001),真渲染 StoreBrandHero 三态(loading / showHeroImage 新三层 / 默认内容态),断言三层 DOM 结构与汉堡按钮可点击(onOpenMenu 真触发)sudo db-query test "SELECT hero_image_oss_key FROM organizations WHERE id=<测试机构id>" 确认落库非空,org_brain_config.enable_hero_image 开关状态与 PC 后台勾选一致/h5/chat:上传头图前后对比截图,确认三层叠加视觉效果与效果图一致,汉堡按钮在两种状态(有图/无图)下都可点击打开会话历史sudo release-to-test --yes 到 test.tongban.love 让长官 UAT,不本机自称"已验收"$ 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