news 2026/10/23 2:47:43

freehire 岗位页 “See also“ 卡片图标体系:从空白 PNG 到四类可识别标识

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
freehire 岗位页 “See also“ 卡片图标体系:从空白 PNG 到四类可识别标识

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

本文以 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.

具体拆成四条产出:

  1. 技术 / 云 / 工具类集合 → 真实品牌 logo;
  2. 国家类集合 → 该国国旗;
  3. 其余(seniority、work mode、role/category)→ 按家族着色的语义图标;
  4. 不再存在"空白卡片"分支。

核心抽象:三种 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"逐条判断:

  1. Backer 集合(COLLECTIONS.find(c => c.kind === 'backer'))→{ kind: 'image', src: backerBadges([slug])[0].mark },与改造前一致;
  2. 带skills参数的FilterCollection→ 查技术标识注册表;命中 →{ kind: 'logo', ... };未命中 → 回落到家族图标,永远不会渲染成破图;
  3. 带countries参数的FilterCollection(单一 ISO 码,如{ countries: 'de' })→{ kind: 'flag', countryCode };
  4. 其余一切(category、seniority、work_mode+regions、work_mode+countries: 'global'这类不含具体国家的条目)→ 家族图标,家族由"存在哪个参数键"推导。

设计稿特别强调这种做法的经济性:

This mirrors the existingparamsshape 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 askillsparam 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 注释记录了设计评审之后追加的工程经验,值得单独抄录:

  1. 批量扩表的正确做法:初版是约 40 个手工精选条目,之后"用完整技能字典(SKILL_LABELS)与 simple-icons 的每个slug求精确交集,再逐个核对候选条的sourceURL 是否真的指向我们标签所指的工具/语言/公司"。
  2. 精确 slug 匹配不等于同一身份,有三个候选被刻意剔除:
    • elk→ 指向 Elk(Mastodon 客户端),而非 Elastic ELK 栈;
    • hive→ 指向 Hive 智能温控器品牌,而非 Apache Hive;
    • backbone→ 指向 backbone.com 的媒体资料包,而非 Backbone.js。
  3. 上游商标下架导致永久缺口: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 ownhex(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 presentFamilyLucide iconColor
skills(no tech-mark hit)tech (generic)Code2indigo
categoryroleLayersviolet
seniorityseniorityTrendingUpemerald
work_mode+regions/globalcountriesremoteGlobe2cyan

落地后家族扩为五个,并新增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, socities=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 fromsize-6(24px) tosize-7(28px) to keep a padded logo legible.
  • Shape:rounded-full(wasrounded-sm) for every kind, backer PNG included …
  • Render branches oncard.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-7badgeClass与<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 forbuildSeeAlso()/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.

这个例子之所以精妙,是因为它一次命中了四个分支的全部边角:

卡片来源解析结果验证点
pythonlogo(siPython,蓝色底 + 白字)logo 底色/字形对比度
awsfamily: tech(#4f46e5靛蓝 +Code)品牌库缺口 → 非空降级,无破图
seniorfamily: 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

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载
上一篇:brpc 从零开始:构建指南、支持环境与快速验证(Getting Started 全解析)
下一篇:pstack贡献指南:如何提交PR并通过生成器与测试检查

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 2:46:11

Agents API 支持 computer use 了:让 AI 点界面之前,这笔账得先算清楚

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 2:46:10

Token成本如何归因?从调用记录到预算复核

企业收到 AI 账单时&#xff0c;常见的问题并不是只看不懂总金额&#xff0c;而是无法判断费用来自哪个团队、哪类任务、哪个模型和哪些重试。只有将支出与业务场景和责任人关联&#xff0c;成本统计才可能支持预算和优化决策。Token 成本归因是一种管理方法&#xff1a;以可取…

作者头像 李华
网站建设 2026/10/23 2:43:33

Aquilon操作指南(第2篇):Aquilon系统介绍

引言 在第一篇中&#xff0c;我们对Aquilon的硬件规格与系统架构进行了总览性分析&#xff0c;涵盖了产品系列、环境参数、安全规范等硬件层面的知识。 本文作为操作指南系列的第二篇&#xff0c;将视角从"硬件规格"转向"系统操作"&#xff0c;深入解析Aqu…

作者头像 李华
网站建设 2026/10/23 2:43:29

基于 Java Spring Boot 的大学生竞赛管理系统设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华