简历模板的三层渲染协议与迁移

**prototype 不能直接粘进模板文件。模板要按 data / layout / render 三层落地;section 只按内容形态分「block | list」两类,不按 experience / education / projects 逐模块枚举;迁移先补齐通用协议,只迁一个模板当样板;双栏结构不要交给 DB 的 layout 配置。**

06-11 定稿的边界:renderer / protocol 只管结构稳定性(slot 渲染、icon 尺寸、空值隐藏、flex 不撑爆),视觉(加粗 / 字号 / 颜色 / 间距)归模板 CSS;CSS 的内容语义命名也必须统一为协议 class,模板自定义只留布局外壳。

一、三层的职责边界

层职责禁区
data只提供语义不写视觉
layout(模板 html/css)只描述「这个模板怎么排」不在模板里查简历数据
render(SlotRenderer)把 HTML 变成 React不靠模块名枚举分发

prototype 的结构可以迁进 layout,但必须换成 slot(<slot data-bind="basics.name">、<slot data-bind="section.items" data-template="item-tpl">)。

需要补的能力放在 render 层:例如让 <slot data-bind="section.icon"> 直接渲染图标,而不是允许在 HTML 里塞一堆 inline SVG;图标继续来自 section-meta.ts。

二、section 只分两种内容形态(关键抽象)

不要一上来就按 experience / education / projects / skills 每个都定制 —— 那会把 render 协议变成「模块枚举」,长期很重。通用抽象是内容形态:

形态内容HTML 需要
block只有一段:个人总结、技能、荣誉奖项、自定义富文本section.title + section.body
list多个条目:工作 / 项目 / 教育 / 社团 / 实习 / 科研经历section.title + section.items(item-tpl)

render 层只需要识别:

section.kind = "block" | "list"

而不是识别一长串模块名。同一形态在不同模块下复用同一套模板,荣誉奖项 这类看数据结构决定归 block 还是 list。

三、迁移策略:先补协议,再用一个模板做样板

  1. 补 render 层通用协议:profile.*、profile.contacts 循环(contact.icon / contact.label)、sectionOrder、section.kind / icon / body / items、item.title / subtitle / meta / dateRange / bullets
  2. 只迁 classic:把确认过的 prototype 迁进 templates/html/classic.html + classic.css,里面必须用 slot,不写死 demo 数据
  3. 验证 classic:走 [render] source: unified id: classic,视觉接近旧 React,图标与各类 section 结构正常
  4. classic 稳了再迁其它:用同一套协议迁 professional / modern

不要同时碰三个模板。一句话:先把 render 的通用协议补齐,再用 classic 做第一个迁移样板。

四、影响面收窄(排版混乱的来源)

本地 HTML fallback 若扫所有内置模板(professional / classic / modern),不该迁的模板也会走 unified —— 这才是排版混乱的根因,不是简历数据坏了:

  • classic:有 templates/html/classic.html/css → source: uploaded → 经 toSerializable() 变成 source: unified
  • professional / modern:继续 source: builtin,走旧 React Layout

典型症状是「section 标题的灰条/蓝标签尺寸、间距、行高没重新校准」和「双栏内容全挤在左列、右侧空白」——都是模板 CSS / 版心列宽协议没和新 SlotRenderer 接好。

五、双栏布局与 DB 定档

  • 双栏不要依赖 DB 的 layout.sidebar.sections 来拆 sidebarSections / mainSections。两栏结构由模板 HTML/CSS 自己定义:sidebar 放 profile.*,main 里跑 sectionOrder。layout 保留为兼容旧 v1 uploaded 模板的字段,不再扩展。
  • DB schema 可以定档:customHtml / customCss / layout 已经够表达 unified render。profile.*、section.kind、section.body、section.items 都是 render 层从 ResumeContent 派生出来的协议,不需要新增字段。
  • 若上线前要统一,只是「把本地 HTML/CSS 写回 DB 的 customHtml / customCss」的数据 seed / 同步,不是 schema migration。

六、渲染缺字段怎么排查

要求是逐字段对照:把「表单 / schema 里有什么」与「SlotRenderer 实际消费了什么」一条条比,产出缺失清单;先定位 bug,不要立刻改。

排查时同时拿表现正常的模板做对照(「为什么专业这一个模板又是正常的」)——同一字段在不同模板下表现不同,说明差异在模板结构而非数据本身。

七、表单字段 → slot 契约(逐字段清单)

排查「表单里有、预览里没有」时,先照这张表对,再看模板 HTML 有没有写对应 slot。表单字段没丢,丢的是模板没用这个 slot。

基础信息 basics.*(别名 profile.*)

name → 姓名        status → 求职状态   title → 求职方向
email / phone / location / website / summary / photo

为什么会有两套名字(basics 与 profile)

两者不是两份数据,读的是同一份 ResumeContent.basics:

basics.*  = 表单 / 数据库真实字段名(也是 render 允许直接读的名字)
profile.* = 模板 slot 协议层的语义别名(「个人头部信息」视角)

冗余来自演进:先有表单结构 basics,模板层后来为写 profile.contacts 这种语义化循环又加了 profile.*。basics 恰好两层同名,所以看起来像同一层。

结论:render 继续兼容两套,但 skill 只教一套。

对外协议的最终划分:basic.* + profile.contacts

命名按语义分组(用户定义):

basic.*   姓名 / 求职岗位 / 求职状态     ← 头部身份,可固定样式
profile.contacts → contact.icon / label / href   ← 带 icon 的联系方式
  • basics.*(含 basics.icon.*)对外不再暴露,属于 deprecated;profile.name/title/status/summary 也不是新标准。
  • 底层存储字段可以仍是 content.basics,但那是内部适配层,不能因此说「协议里还留着 basics」。
  • 判定标准是「对外协议有没有别名」,不是「代码里还有没有旧名字」。

一个名字被复用到多层,是混乱的真源

basics 同时表示:表单数据组、编辑器固定模块、sectionOrder 里的正文 section、completeness 的基础信息桶、模板旧 binding 命名空间。这些不是同一层概念却共用一个名字 —— 排查时容易把「协议冗余」误判成「两个 render」。

抽象不因局部清理而退化

清 basic / profile 只是头部区的数据分组,正文仍必须走统一的 sectionOrder / section.body / section.items / item.*。不允许为了让基础信息好改,把正文格式特殊化成一堆专用字段。

文档分层:DB schema 不靠文档镜像

docs/schema-v2/ 混了三层东西,需要分开对待:

文件实际是什么
resume-table.json / templates-table.json偏 DB schema
resume-content.jsonresume.content 这个 jsonb 的内容协议,不是 DB schema
template-slot-fields.* / html-slot-protocol.json模板 slot / HTML 写法协议
style-settings.jsoncontent.styleSettings 的排版协议

DB schema 的真源是 db/schema.ts + Drizzle migration,不要再起一份文档镜像它;内容协议与模板协议则必须随架构更新(旧文档里 profile.name/title/status、兼容 basics.* 的中间态内容与「无兼容」目标冲突)。

更新顺序:先对齐上游 skill,再叠加本地协议

模板 skill 的真源在上游仓库(比本地新一点),schema 协议的真源在本地(改造最新)。所以做法是拉上游 skill 作基线,再在其上应用本地协议改造,而不是整体选一边 —— 详见 多源更新按维度取真源。

required 是模板覆盖,不是用户必填

「必含字段」要说清指谁:content_required(用户表单必填,当前基本没有)≠ template_coverage_required(模板 HTML 必须预留的 slot)。用户没填 → 渲染为空是正常的;模板没写 slot → 用户填了也永远不显示。

profile 与 contact 的显示语义分工

  • profile 管「这个人是谁」:头像、姓名、目标岗位、求职状态、自我介绍。
  • contact 管「怎么联系这个人」:电话、邮箱、所在地、个人主页。它不是表单字段,是 render 从 profile 派生出的列表。
  • 两者位置常挨在一起(都在简历头部),所以看着重叠。location 是唯一真正的重叠点 —— 既可当 profile 的所在地,也可当 contact 的地址项;这是产品决定,两边都允许就会持续有重叠感。

slot / data-bind / render 三种说法不要混

slot      = 模板 HTML 里的占位符语法(<slot data-bind="...">)
slot 协议 = data-bind 允许写哪些名字(profile.name / item.title ...)
render    = 按 data-bind 去 ResumeContent 取值的引擎

所以「basics 和 profile 并存」是协议命名冗余,不是 slot 机制的问题,也不是两个 render。

列表条目:item.* 由不同模块的字段共同供给

模块titlesubtitlelocationdateRange其他
工作 experience[]companytitlelocationstart/endcontent → item.bullets;无 meta / link
教育 education[]schooldegree+major+gpalocationstart/endhighlights → item.bullets;无 meta / link
项目 projects[]namerolelocationstart/endstack → item.meta(join ·)与 item.tags;link → item.link;content → item.bullets
研究 research[]namerole无此字段start/endlink → item.link;content → item.bullets;无 location / meta

块内容不走 item.*:技能 skills → section.body;自定义模块 custom[].title → section.title、custom[].content → section.body。

同一语义字段可能有多个入口(静默不显示的来源)

「城市」有两条独立渲染路径,漏掉任一条,对应模板就永远不显示城市,而且不报错:

  1. 个人信息头部:模板写联系方式循环 profile.contacts,城市需要由代码把 basics.location 派进 contacts 列表(原本只放了 phone / email / website)。
  2. 条目内:模板必须自己写 <slot data-bind="item.location">,CSS 不能凭空渲染字段。

所以「有的模板正常、有的不显示」时,差异在模板结构,不在数据。

缺字段怎么补:改模板,不要代码兜底

曾经加过「条目有城市但模板没写 location slot 就自动补 · 城市」的兜底,随后撤掉了。理由:协议缺字段应在模板 HTML/CSS 层补齐(含位置、字号、颜色、间距),代码兜底会与已写 slot 的模板重复,且把「模板缺什么」这个信息掩盖掉。

八、schema / protocol / renderer 三层区分(06-11 定稿)

排查「协议」问题时先分清说的是哪一层,否则会把「命名冗余」误判成「机制坏了」:

schema   = 模板能写什么(词汇表 / 类型定义:slot 名、字段名、数据结构、允许的 HTML/CSS 约束)
protocol = 运行时语义 / 行为契约(这些 slot 被怎么解释、保证什么)
renderer = protocol 的执行器
  • schema 回答:模板作者可以声明哪些槽位、数据从哪来、哪些写法合法。
  • protocol 回答:contact.icon 一定渲染成 lucide icon 且按正文比例、item.link 为空不占位、section.items 循环自动加 data-pagination-item、sectionOrder 循环按 list/block 选模板、模板 CSS 被 scope 到 data-template-id。
  • 本项目的典型 bug 正是「schema 里规定了 contact.icon,protocol 没保证它的尺寸」—— 于是每个模板 CSS 自己写一份,漏一个模板就炸(如 modern 的 .modern-contact-item 被换成通用 .contact-item 后图标巨大化)。修法是把运行时默认行为放进 renderer 的 protocol base CSS,而不是改 schema。

九、render 与模板 CSS 的职责边界(用户定稿)

renderer / protocol 只管「结构稳定性」;视觉表现归模板 CSS。 用户明确纠正过两次方向:

  1. 视觉属性不上收 renderer。 加粗(font-weight)、字号、颜色、间距细节属于「这个模板里这个角色长什么样」,不该进 renderer 默认。曾把 .item-title { font-weight: 700 } 加进 renderer,被要求撤掉。renderer 的职责是 slot 渲染、icon 尺寸兜底、空字段隐藏、flex 不撑爆这类「基础可用行为」。

  2. CSS 的内容语义命名也必须统一,自定义只留布局。 用户原话:「css 的自定义只有排版布局啊」。正确形态是内容语义一律绑协议 class:

    <span class="item-title"><slot data-bind="item.title"></slot></span>
    
    .item-title { font-weight: 700; }                                /* 视觉可改,selector 仍绑协议 class */
    [data-template-id="abbey-blue"] .item-title { font-size: 13px; } /* 特异性靠作用域,不靠私有命名 */
    

    不应该再出现 .pro-item-title / .entry-title / .modern-item-primary / .classic-body 这类每个模板一套的内容角色命名。模板自定义 class 只用于真正的布局外壳(.modern-layout、.abbey-blue-banner、.pro-page)。

边界一句话:render/protocol 管「这个东西是什么角色,以及基础可用行为」;template CSS 管「这个模板里这个角色长什么样」。

注意区分两种「不通用」:item.title / item.dateRange 这类 slot binding 是通用协议,而 HTML class / CSS selector 曾经不通用(abbey-blue 用 .abbey-blue-entry-header .entry-title、professional 用 .pro-item-title)。「能兼容」只是现状描述,不是理想架构 —— 兼容机制让 renderer 只看 slot、外层叫什么都能填值,所以看起来没事,但每改一次结构就要扫所有模板 CSS。

十、迁移与兜底的两个机制细节

  • HTML 与 CSS selector 必须成对迁移。 只改 DB HTML(.pro-item-title → .item-title)而 CSS 还写旧 selector,视觉必丢;同时改 DB CSS,浏览器只看最终 DOM 与 CSS 是否匹配,所以线上旧 renderer 完全不知道新协议也不影响视觉。这正是「DB 改了、线上 render 没改、视觉却不受影响」的原因,也说明这次 DB 迁移本身是自洽的。
  • renderer protocol CSS 是兜底基础行为,不是模板样式生效的唯一来源。 DB 模板 CSS 负责「这个模板的视觉不要断」,renderer protocol CSS 负责「所有模板都该有的最低行为」(空值隐藏、日期不换行、链接换行、icon 尺寸、正文行高),避免每个模板复制一份。
  • 模板默认样式(defaultStyleSettings)在创建 / 切换模板时写进简历内容,不在 render 阶段自动合并;渲染器对内容里的 styleSettings 生效。所以「默认样式不生效」要分「没写进内容」还是「渲染器没消费」两条链路查。

相关