【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本文以 freehire 仓库中的设计方案docs/superpowers/specs/2026-08-13-see-also-marks-design.md为主线,完整梳理岗位详情页 "See also" 横滑卡片的图标体系改造:如何把"标题 + 岗位数"的纯文本墙,变成由品牌 logo、国家旗帜、语义化家族图标与原有 backer 图片共同构成的四类标识,并给出已落地实现(web/src/lib/seeAlsoMark.ts、web/src/lib/components/CollectionMark.svelte、web/src/lib/techmarks.ts)的源码级说明、参数解析链路与测试验证方式。读完后你可以独立新增一个带品牌图标的 skill 集合、排查"图标为什么不显示",并理解解析优先级为何不能随意调整。
问题:一整排长得一模一样的卡片
JobSeeAlso组件负责岗位页底部的 "See also" 区块,它渲染一行指向既有/collections/:sluglanding 页的链接(例如 "Python jobs"、"Senior-Level jobs"、"AWS jobs"),每张卡片带一个实时开放岗位数 pill。
设计方案对改造前的状态描述得很直接:
The "See also" row (
web/src/lib/components/JobSeeAlso.svelte) on a job page links to related/collections/:slugpages… Today every card is visually identical — a title and a job-count pill — except the rare backer-collection card (YC, Techstars) that gets a small square PNG mark. The row reads as a flat wall of text and is hard to scan.
也就是说:
- 除极少数 backer 集合(YC、Techstars 等)有一枚小方形 PNG 之外,每张卡片视觉完全相同;
- 整行读起来像"一堵平铺的文字墙",横向滚动时缺乏可扫描的视觉锚点;
SeeAlsoCard上的mark字段是string | null,为null时组件用{#if card.mark}直接不渲染图标槽位,于是"空白卡片"成了常态。
目标:每张卡片都必须有一个可识别的圆形标识
设计方案的 Goal 部分界定了改造边界,这一点很关键——这是标识与细节的改造,不是布局重构:
Give every card a recognizable circular mark: a real brand logo for technology/cloud/tool collections, the country's flag for country collections, and a color-coded family icon for everything else (seniority, work mode, role/category). Keep the row's current horizontal-scroll layout — this is a mark/detail change, not a layout redesign.
具体拆成四条产出:
- 技术 / 云 / 工具类集合 → 真实品牌 logo;
- 国家类集合 → 该国国旗;
- 其余(seniority、work mode、role/category)→ 按家族着色的语义图标;
- 不再存在"空白卡片"分支。
核心抽象:三种 kind,一个字段
设计方案把SeeAlsoCard.mark从"图片 URL 字符串或 null"升级为可辨识联合(discriminated union):
export type SeeAlsoMark = | { kind: 'image'; src: string } // unchanged: existing backer PNG (backers.ts) | { kind: 'logo'; path: string; hex: string } // brand SVG path data + brand color, from simple-icons | { kind: 'flag'; countryCode: string } // renders <CountryFlag code={countryCode} /> | { kind: 'family'; icon: FamilyIconName; color: string }; // Lucide icon + family color这段类型最终落在 collections.ts,并比设计稿多带一个title(用于 logo 的无障碍名称):
export type SeeAlsoMark = | { kind: 'image'; src: string } | { kind: 'logo'; title: string; path: string; hex: string } | { kind: 'flag'; countryCode: string } | { kind: 'family'; icon: FamilyIconName; color: string };类型被放在collections.ts而不是路由文件里,注释给出了原因:服务端 load 计算它、组件渲染它,双方都必须从同一处导入,避免类型漂移。承载它的卡片类型 SeeAlsoCard 仍是{ slug, title, count, mark }四字段。
由于mark变为必有(不再可空),组件里原来的{#if card.mark}守卫也随之消失。这一点在设计中写得很明确:
Every card gets a mark now — there is no longer a "blank card" case.
JobSeeAlso.svelte's{#if card.mark}guard goes away.
解析顺序:四个分支的优先级与理由
设计稿规定的解析顺序按"已解析的集合 slug"逐条判断:
- Backer 集合(
COLLECTIONS.find(c => c.kind === 'backer'))→{ kind: 'image', src: backerBadges([slug])[0].mark },与改造前一致; - 带
skills参数的FilterCollection→ 查技术标识注册表;命中 →{ kind: 'logo', ... };未命中 → 回落到家族图标,永远不会渲染成破图; - 带
countries参数的FilterCollection(单一 ISO 码,如{ countries: 'de' })→{ kind: 'flag', countryCode }; - 其余一切(
category、seniority、work_mode+regions、work_mode+countries: 'global'这类不含具体国家的条目)→ 家族图标,家族由"存在哪个参数键"推导。
设计稿特别强调这种做法的经济性:
This mirrors the existing
paramsshape onFilterCollection— no new metadata field on the 90-entry registry, family is derived from the param key already there.
即不新增元数据字段,家族由params的键直接推导。
落地实现:纯函数resolveSeeAlsoMark
实现阶段把这段解析从+page.server.ts内联逻辑中抽成了可单测的纯函数,见 seeAlsoMark.ts:
export function resolveSeeAlsoMark( params: Record<string, string> | null, backerImageSrc: string | null ): SeeAlsoMark { if (backerImageSrc) return { kind: 'image', src: backerImageSrc }; if (!params) return familyMark('tech'); if (params.skills) { const tech = TECH_MARKS[params.skills]; if (tech) return { kind: 'logo', title: tech.title, path: tech.path, hex: tech.hex }; return familyMark('tech'); } if (params.countries) return { kind: 'flag', countryCode: params.countries }; if (params.ai_archetype || params.employment_type || params.relocation) return familyMark('role'); if (params.domains || params.company_type) return familyMark('company'); if (params.seniority) return familyMark('seniority'); if (params.category) return familyMark('role'); if (params.work_mode) return familyMark('remote'); if (params.collections) return familyMark('company'); return familyMark('tech'); }几点值得注意的实现细节:
- backer 图片拥有最高优先级。函数签名把
backerImageSrc作为入参而不是在内部查backers.ts,这样解析函数对"品牌图片从哪来"零耦合,测试也能直接注入。 params === null回落tech:注释说明这是 backer-only 集合的情况(这类集合不带任何 facet 参数)。- 判断顺序即优先级。
skills在countries之前,seniority在category之前。因为一个集合可能同时带多个键(例如senior-backend是{ category: 'backend', seniority: 'senior' }),谁先被读到决定了卡片形态;把seniority放在category之前,使"Senior Backend"这类落地页画成TrendingUp的绿色晋升图标,而不是紫色的角色分层图标。 - 比设计稿多出的两类键:
ai_archetype/employment_type/relocation归入role,domains/company_type/collections归入新增的company家族。collections分支的注释解释得很清楚——backer 类在上面已经通过backerImageSrc返回了,走到这里说明这个公司归属类集合没有真实品牌标识。
familyMark只是一层薄封装,把家族名换成带颜色的联合成员:
function familyMark(icon: FamilyIconName): SeeAlsoMark { return { kind: 'family', icon, color: FAMILY_MARKS[icon].color }; }服务端调用点:buildSeeAlso()
解析函数在 web/src/routes/jobs/[slug]/+page.server.ts 的buildSeeAlso()中被调用,位置与设计稿所述一致——正是在它"今天已经在解析图标的地方":
return resolvedLinks.map(({ link, resolved }): SeeAlsoCard => { const backerImageSrc = backerBadges([link.slug])[0]?.mark ?? null; if (!resolved) { return { slug: link.slug, title: link.title, count: null, mark: resolveSeeAlsoMark(null, backerImageSrc), }; } const { filter, lastKey, lastValue } = splitParams(resolved.params); const groupKey = new URLSearchParams(filter).toString(); const count = readCount(groupedFacets.get(groupKey) ?? null, lastKey, lastValue); const mark = resolveSeeAlsoMark(resolved.params, backerImageSrc); return { slug: link.slug, title: link.title, count, mark }; });可以看到mark与count在同一趟 map 里算完,且backerBadges([link.slug])[0]?.mark ?? null是唯一的图片来源。count的取值逻辑还隐藏着一个与本主题相关的设计约束:readCount在 facets 分布缺失时返回null而不是 0,注释说明缺失条目意味着 Meilisearch 的MaxValuesPerFacet上限把该值挤掉了——"降级为未解析,而不是伪造 0 个岗位"。这与图标"缺失即降级、绝不破图"是同一种产品姿态。
技术标识注册表:techmarks.ts
设计稿要求新建web/src/lib/techmarks.ts,"形状与精神"对齐backers.ts的MARKS:一张手工维护的Record,键是我们自己的集合 slug,值来自simple-icons的具名导入。
import { siPython, siAmazonaws, /* … */ } from 'simple-icons'; export const TECH_MARKS: Record<string, { path: string; hex: string }> = { python: siPython, aws: siAmazonaws, azure: siMicrosoftazure, gcp: siGooglecloud, csharp: siCsharp, cpp: siCplusplus, nodejs: siNodedotjs, nextjs: siNextdotjs, dotnet: siDotnet, // … one entry per `skills` value in FILTER_COLLECTIONS (~44 today) };为什么必须逐条具名导入
设计稿对此有硬性要求:
Import icons individually (
import { siPython, siAmazonaws, siMicrosoftazure, ... } from 'simple-icons') so the bundler tree-shakes everything unused — do not import the package's default export.
如果导入包的默认导出(一个包含全部图标的巨大对象),tree-shaking 就无法裁掉未使用的图标,客户端包体会被整套品牌库撑大。因此 techmarks.ts 里是两百多行具名 import,随后在TECH_MARKS中逐一映射。simple-icons在 web/package.json 中作为新依赖加入,与既有的@lucide/svelte并列。
缺失即降级,不需要覆盖率测试
A collection slug with a
skillsparam and no entry here silently falls back to the family icon — same "we don't have a verified mark, so show nothing brand-specific" posture asbackers.ts, just with a non-empty fallback instead of a blank card. No test needs to enforce full coverage; missing entries degrade, they don't break.
这个姿态与backers.ts完全同源。后者注释写着"a plausible-looking wrong mark is worse than none",并且给出了真实案例:logo 代理按公司名匹配时,"a16z" 会指向一个无关的 Substack 页面,所以 backer 图片改为人工提交到web/static/brands。
落地后的三条重要事实
实际落地的 techmarks.ts 注释记录了设计评审之后追加的工程经验,值得单独抄录:
- 批量扩表的正确做法:初版是约 40 个手工精选条目,之后"用完整技能字典(
SKILL_LABELS)与 simple-icons 的每个slug求精确交集,再逐个核对候选条的sourceURL 是否真的指向我们标签所指的工具/语言/公司"。 - 精确 slug 匹配不等于同一身份,有三个候选被刻意剔除:
elk→ 指向 Elk(Mastodon 客户端),而非 Elastic ELK 栈;hive→ 指向 Hive 智能温控器品牌,而非 Apache Hive;backbone→ 指向 backbone.com 的媒体资料包,而非 Backbone.js。
- 上游商标下架导致永久缺口:
simple-icons中不含 AWS、Azure、Java、C#(因商标权利人下架请求被移除),所以这四个skillsslug没有条目,在 "See also" 卡片里显示通用 tech 家族图标(在 skill chip 中则什么都不画)——这正是"未来任何新技能在有人补录前"的默认行为。
对照仓库现状可以确认缺口是真实存在的:TECH_MARKS有 209 个条目,而带skills参数的FilterCollection有 45 个,其中aws、azure这类注定命中不到。测试里也把aws作为"回落"用例的输入,见后文。
图标对比度:用 YIQ 一次算好,不写例外清单
设计稿里这条要求很具体:
Icon fill contrast: background is the brand's own
hex(a filled circle); the SVG path is drawn white or near-black depending on the background's luminance (YIQ formula, threshold ~128), computed once per mark at render time — not hardcoded per brand. This is whyreact's pale cyan background gets a dark glyph andaws's orange gets a white one, without a manual exception list.
实现落在 markColor.ts:
// Which glyph fill to draw over a brand's own background color, so a logo or // family icon stays legible regardless of how light or dark that brand's hex is. const LUMINANCE_THRESHOLD = 128; export function glyphColorFor(hex: string): string { const value = hex.replace('#', ''); const r = parseInt(value.slice(0, 2), 16); const g = parseInt(value.slice(2, 4), 16); const b = parseInt(value.slice(4, 6), 16); const yiq = (r * 299 + g * 587 + b * 114) / 1000; return yiq >= LUMINANCE_THRESHOLD ? '#16181d' : '#ffffff'; }YIQ 中绿色权重最高(587)、蓝色最低(114),符合人眼亮度感知。阈值 128 恰好切在中间灰度,因此亮色底(React 的浅青)得到近黑字形#16181d,深色底得到白色字形。这就是为什么不需要为任何品牌维护"文字用什么颜色"的例外表——新增一个品牌只要给出hex,对比度自动成立。
这一函数同时服务于logo与(若需要)家族图标,保证深色主题下也不会出现"深底深字"。
家族图标:五个家族、一张固定查找表
设计稿给出的映射表(并说明颜色取自设计系统语义色阶,而非新造颜色):
| Param key present | Family | Lucide icon | Color |
|---|---|---|---|
skills(no tech-mark hit) | tech (generic) | Code2 | indigo |
category | role | Layers | violet |
seniority | seniority | TrendingUp | emerald |
work_mode+regions/globalcountries | remote | Globe2 | cyan |
落地后家族扩为五个,并新增company(代码评审时发现公司归属类集合无标识可画)。数据表见 familymarks.ts:
export type FamilyIconName = 'tech' | 'role' | 'seniority' | 'remote' | 'company'; export const FAMILY_MARKS: Record<FamilyIconName, { color: string }> = { tech: { color: '#4f46e5' }, role: { color: '#7c3aed' }, seniority: { color: '#059669' }, remote: { color: '#0891b2' }, company: { color: '#d97706' }, };该文件的头部注释修正了设计稿的一处假设。设计稿称颜色取自"design-system 色权重的既有语义色阶",而实际注释写道:这些颜色是交互式 mockup 评审时批准的(外加评审后补的company),不是设计系统 token——因为设计系统的调色板里没有可供取用的分类多色阶,所以这里是一个小的本地例外,而不是硬造一个 token。实际图标也从Code2/Globe2调整为Code/Globe。
为什么数据与图标组件必须分离
这是落地时一个关键的架构决定,也是阅读这份代码最容易踩坑的地方。familymarks.ts 的注释说得很清楚:
Pure data only, no
@lucide/svelteimport: this is imported by seeAlsoMark.ts, which vitest runs in plain Node — a Lucide icon component is a.sveltefile the Svelte compiler must transform, so the icon components themselves are looked up in CollectionMark.svelte instead, keyed by the same FamilyIconName.
也就是说:
familymarks.ts只放纯数据(家族名 → 颜色);seeAlsoMark.ts是纯函数模块,被纯 Node 环境的 vitest 直接 import;- 如果把
import { Code } from '@lucide/svelte'写进数据模块,测试进程会尝试加载.svelte文件而失败; - 因此组件映射放在
.svelte侧,用同一个FamilyIconName做键:
const FAMILY_ICONS: Record<FamilyIconName, LucideIcon> = { tech: Code, role: Layers, seniority: TrendingUp, remote: Globe, company: Building2, };这是一个"纯逻辑与视图分层以便测试"的典型案例:把可测的判定逻辑推向 Node 侧,把需要编译器的部分留在.svelte侧。
国旗:复用既有CountryFlag,不新增组件
{ kind: 'flag', countryCode }renders the existingCountryFlag.svelte… unchanged — it already fills its circular container edge-to-edge viaflag-icons'fi fisclasses. No new component.
国旗原语位于 design-system/src/country-flag.svelte(通过#lib/ui/index.js整体再导出freehire-design-system)。它的实现细节解释了为何"无需改动即可圆形铺满":
.fis选中方形(1:1)变体,因此rounded-full得到正圆;ring-1 ring-black/10 dark:ring-white/15让日本、尼日利亚这类白底旗在浅色背景下仍有边线可见;flag-icons以em计量,所以旗子随外层字号缩放;- 非两位 ASCII 字母的 code 没有对应旗帜,会退化成大写code 纯文本,而不是一个空盒子:
const renderable = $derived(/^[a-z]{2}$/.test(cc));调用处还顺带传入了countryLabel(mark.countryCode)作为aria-label与title,来源是 facets.ts 的countryLabel。
国旗之外的稳健性:FilterCollection.flag
落地后FilterCollection多出一个可选字段(见 collections.ts):
// ISO 3166-1 alpha-2 code whose flag marks the card, for a collection whose params // name a place without pinning its country (a city). Display only — never a filter. flag?: string;城市类落地面(singapore、new-york、london…)的params是{ cities: 'Singapore' },并不包含countries键,因此会掉进"家族图标"分支而失去地域感。注释解释为何不能顺手把国家塞进params:
The country is NOT pinned beside it: the location facets combine with OR, so
cities=Austin&countries=usis every US job, not the Austin ones.
位置类 facet 之间是 OR 关系,加countries=us会把"奥斯汀"变成"全美"。所以国家只能作为纯展示的flag字段存在。这条设计是原方案出稿后为覆盖城市集合补上的,也说明"图标解析应跟随集合语义、而不是跟随 facet 语义"。
组件改造:JobSeeAlso.svelte与抽出的CollectionMark.svelte
设计稿对组件层的要求是"卡片的骨架一律不动,只动标识槽位":
The card layout is untouched (horizontal scroll strip,
w-40 shrink-0, border,hover:bg-muted). Only the mark slot changes:
- Size bumps from
size-6(24px) tosize-7(28px) to keep a padded logo legible.- Shape:
rounded-full(wasrounded-sm) for every kind, backer PNG included …- Render branches on
card.mark.kind
落地时这一槽位被抽成了独立组件 CollectionMark.svelte,JobSeeAlso.svelte只负责布局与链接:
<a href={resolve('/collections/[slug]', { slug: card.slug })} class="flex w-40 shrink-0 flex-col items-start gap-2 rounded-lg border border-border p-3 transition-colors hover:bg-muted" > <div class="flex w-full items-center justify-between gap-2"> <CollectionMark mark={card.mark} /> {#if card.count !== null} <span class="shrink-0 rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-muted-foreground"> {card.count.toLocaleString()} jobs </span> {/if} </div> <span class="text-sm font-medium text-foreground">{card.title} jobs</span> </a>可以看到w-40 shrink-0、边框、hover:bg-muted、no-scrollbar overflow-x-auto的横向滚动条都按设计稿原样保留,count === null时 pill 整体消失(与"缺失计数不伪造成 0"一致)。
标识组件本体实现四个分支:
{#if mark.kind === 'image'} <img src={mark.src} alt="" class="size-7 shrink-0 rounded-full object-contain" /> {:else if mark.kind === 'logo'} <span class={badgeClass} style:background-color="#{mark.hex}"> <svg viewBox="0 0 24 24" class="size-4" role="img" aria-label={mark.title}> <path d={mark.path} fill={glyphColorFor(`#${mark.hex}`)} /> </svg> </span> {:else if mark.kind === 'flag'} <CountryFlag code={mark.countryCode} label={countryLabel(mark.countryCode)} class="shrink-0 text-3xl" /> {:else} {@const Icon = FAMILY_ICONS[mark.icon]} <span class={badgeClass} style:background-color={mark.color}> <Icon class="size-4 text-white" /> </span> {/if}以及几何尺寸:
// Shared by the logo and family mark kinds — both are an icon centered on a // colored circle, differing only in the color and what's drawn inside. const badgeClass = 'flex size-7 shrink-0 items-center justify-center rounded-full';逐条对照设计稿与实现:
| 设计要求 | 实现 |
|---|---|
size-6→size-7 | badgeClass与<img>均为size-7 |
所有 kind 统一rounded-full(含 backer PNG) | <img>用rounded-full object-contain |
image→ 圆形化后的<img> | 分支一 |
logo→ 品牌底色圆 + 内联 SVG + 对比色填充 | 分支二,glyphColorFor计算 fill |
flag→<CountryFlag code={...} /> | 分支三 |
family→ 家族底色圆 + 居中 Lucide 白色图标 | {:else}分支,size-4 text-white |
| 不新增组件 | 抽出CollectionMark.svelte复用,而非在JobSeeAlso内联 |
logo分支的<svg>使用role="img"+aria-label={mark.title},这是设计稿没写但实现补上的无障碍细节:圆形标识对读屏用户没有意义,必须靠title才能被正确朗读。image分支则用alt=""——backer 的品牌语义已由卡片标题承载,避免重复播报。
组件抽出的连带收益
CollectionMark.svelte还被 CollectionRowMark.svelte 复用:
{#if row.companies.length > 0} <CompanyLogoStack names={row.companies} /> {:else if row.flags.length > 1} <CountryFlagStack codes={row.flags} max={4} class="shrink-0 text-3xl" /> {:else} <CollectionMark mark={row.mark} /> {/if}也就是说/collections枢纽页的行数据在"没有公司 logo 堆叠、也没有多国旗堆叠"时,会退回同一个mark。这本来被设计稿列为"out of scope"(不改/collections卡片),但抽出组件后,标识渲染这一层自然被复用。需要区分清楚:枢纽页的卡片样式、图标种类仍是它自己的逻辑,本改造没有去动它,符合"presentation-only"的边界。
测试:按集合家族断言mark.kind
设计稿的验收方式:
Existing coverage for
buildSeeAlso()/SeeAlsoCardresolution (if any) extends to assert the rightmark.kindper collection family — backer slug →image, askills-registry hit →logo, acountriesslug →flag, everything else →family.
由于解析被抽成纯函数,测试可以完全脱离 Svelte 与 HTTP,见 seeAlsoMark.test.ts:
test('a backer collection gets its brand image, regardless of params', () => { const mark = resolveSeeAlsoMark({ skills: 'python' }, '/brands/yc.png'); expect(mark).toEqual({ kind: 'image', src: '/brands/yc.png' }); }); test('a skills param with a curated tech mark gets that brand logo', () => { const mark = resolveSeeAlsoMark({ skills: 'python' }, null); expect(mark.kind).toBe('logo'); if (mark.kind === 'logo') { expect(mark.hex).toMatch(/^[0-9a-f]{6}$/i); expect(mark.path.length).toBeGreaterThan(0); } }); test('a skills param with no curated tech mark falls back to the tech family icon', () => { const mark = resolveSeeAlsoMark({ skills: 'aws' }, null); expect(mark).toEqual({ kind: 'family', icon: 'tech', color: expect.any(String) }); });这几条用例的信息量值得拆开看:
- 第一条同时证明优先级:即便
params.skills === 'python'能命中 logo,只要传入 backer 图片,结果仍是image。 - 第二条不只断言
kind,还校验hex是合法的六位十六进制、path非空——这两条恰好是simple-icons条目被误接(例如把hex写成带#的形式)时最早暴露的信号。 - 第三条用
aws做输入,正对应techmarks.ts注释中"AWS 因商标下架而不在库中"的现实缺口,把"已知必然落空"的场景固化成回归测试。
其余用例覆盖countries → flag(输入{ work_mode: 'remote', countries: 'de' },断言countryCode: 'de')、seniority → seniority、category → role、work_mode + regions → remote、collections → company,以及resolveSeeAlsoMark(null, null) → tech。注意{ work_mode: 'remote', countries: 'de' }这条恰好验证了顺序 3 优先于顺序 4:同时含work_mode与具体国别时输出国旗而非远程图标。
说明:
markColor.ts也有独立测试(web/src/lib/markColor.test.ts),用于固定阈值两侧的取色行为;resolveSeeAlsoMark的用例不涉及它,因为颜色计算发生在渲染前的最后一刻。
视觉验收:一行里凑齐四类标识
设计稿给的验收动作可直接照做:
Visual check: load a job page whose facets hit all four kinds in one row (e.g. a senior Python AWS remote-Germany job) and confirm no broken images, readable contrast on both light and dark brand-color backgrounds.
这个例子之所以精妙,是因为它一次命中了四个分支的全部边角:
| 卡片来源 | 解析结果 | 验证点 |
|---|---|---|
python | logo(siPython,蓝色底 + 白字) | logo 底色/字形对比度 |
aws | family: tech(#4f46e5靛蓝 +Code) | 品牌库缺口 → 非空降级,无破图 |
senior | family: seniority(#059669绿 +TrendingUp) | 家族推导取seniority键 |
remote-germany({ work_mode: 'remote', countries: 'de' }) | flag: 'de' | 国旗正圆、白底旗有描边 |
再切到深色主题检查一遍,即可覆盖设计稿要求的"light and dark brand-color backgrounds 都可读"。若要额外验证image分支,可找一个带有 YC / Techstars 等 backer 集合的公司的岗位页,确认方形 PNG 在圆形裁切下仍居中可辨。
明确不做的事:改造边界
设计稿的 Out of scope 一节把范围钉得很死,这对判断 PR 是否越界非常有用:
- 不改变哪些集合出现在 "See also" 里(
relatedCollectionSlugs、POPULAR_COLLECTION_FALLBACK)——本改造只是对既有卡片集合做展示层变更。仓库中 relatedCollectionSlugs 仍是"任务 facet 命中 → 公司归属 → 热门池补齐"的三段式,函数签名与target = 10的默认值均未变。 - 不改
/collections枢纽页自身的卡片,只改岗位页的 "See also" 行。(后续可以复用TECH_MARKS/ 家族图标,但不是本次改动。)上文的CollectionMark复用属于组件层复利,不是对枢纽页视觉的重新设计。 - 不新增 backer 标识、不改
backers.ts。(实际落地时MARKS表看起来扩展到 13 个品牌,但那是独立的编辑性补充,不是本设计的解析逻辑改动。) - 公司页若复用
JobSeeAlso,自动继承本次改动——同一组件、同一SeeAlsoCard类型。
关于最后一点,有一点必须点明:设计稿写作时认为buildSeeAlso()内联在路由中,所以"同一组件自动继承";而落地后解析被抽到seeAlsoMark.ts,任何调用方(岗位页buildSeeAlso、枢纽页loadCollectionSections)只要走resolveSeeAlsoMark,就共享同一套优先级与降级行为。这正是把判定逻辑做成纯函数的长期价值。
踩坑与维护指南
结合设计稿与源码注释,可以把这份体系的维护要点归纳为四条:
1. 新增一个技术集合时,先确认商标可用。在TECH_MARKS里加条目之前,必须核对simple-icons的sourceURL 是否指向你要表达的那个实体。elk/hive/backbone三个精确 slug 匹配就是反例;AWS / Azure / Java / C# 则是上游已下架的"永久缺口"。漏加不会报错——卡片会自动落到 tech 家族图标。
2. 不要试图用测试强制TECH_MARKS全覆盖。设计稿明确说 "No test needs to enforce full coverage; missing entries degrade, they don't break." 强行加覆盖率断言只会把商标下架的既成事实变成 CI 红灯。
3. 调整解析顺序要非常小心。resolveSeeAlsoMark里每个if的先后都是有语义的:backer 优先于一切、skills优先于countries、seniority优先于category;把一个键上移就可能让"Senior Backend"从绿色晋升图标变成紫色角色图标。修改后必须同步跑seeAlsoMark.test.ts。
4. 颜色与图标组件不要跨层引用。颜色放familymarks.ts(纯数据,Node 可跑),Lucide 组件放.svelte,品牌 SVG 放techmarks.ts;对比度统一走glyphColorFor,不要给个别品牌写死字形颜色。
小结
freehire 的这次改造把"岗位页底部一排无法扫描的文字卡片"升级为一套由数据驱动、可降级、可测试的标识体系:
- 用一个四成员的可辨识联合(
image/logo/flag/family)替换可空的图片 URL,从类型层面消灭"空白卡片"; - 用
params的键推导家族,避免了给上百条集合注册表新增元数据字段; - 品牌色底 + YIQ 阈值取字形色,让 200+ 品牌无需维护对比度例外表;
- 纯数据 / 纯函数 /
.svelte组件的三层切分,使解析逻辑在纯 Node 的 vitest 下即可完整覆盖; - 每一处缺失(无品牌条目、无 facet、无计数)都收敛到"降级而非破损",与仓库里
backers.ts、readCount的一贯姿态一致。
需要进一步深入时,建议按 seeAlsoMark.ts → techmarks.ts → CollectionMark.svelte → seeAlsoMark.test.ts 的顺序阅读;若关心卡片集合是怎么选出来的,再看 collections.ts 的relatedCollectionSlugs与 岗位页 load 中的buildSeeAlso。
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
freehire 程序化 SEO 合集实战:实时职位数标题与职位页 See also 内链设计
freehire 程序化 SEO 合集实战:实时职位数标题与职位页 See also 内链设计 本文以 freehire 仓库中的《Programmatic S
freehire 背书徽章(Backer Badge)设计与实现:在职位卡片上渲染 Y Combinator、Techstars 与 a16z 品牌标识
freehire 背书徽章(Backer Badge)设计与实现:在职位卡片上渲染 Y Combinator、Techstars 与 a16z 品牌标识 fre
OpenCvSharp车牌识别系统:从定位到字符识别
OpenCvSharp车牌识别系统:从定位到字符识别 1. 引言:车牌识别的技术挑战与解决方案 你是否曾在停车场出入口等待识别系统卡顿而焦躁?是否在开发车辆管理
计算机视觉图像处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考