:為下拉選項(xiàng)添加 icon 與 description 元數(shù)據(jù))
Halo 自定義 FormKit select 組件增強(qiáng)為下拉選項(xiàng)添加 icon 與 description 元數(shù)據(jù)【免費(fèi)下載鏈接】haloHalo 是一款強(qiáng)大易用的開(kāi)源建站工具從個(gè)人博客、知識(shí)庫(kù)到企業(yè)官網(wǎng)、在線商城Halo 都能助您輕松實(shí)現(xiàn)一站式滿足您的多樣化建站需求。項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ha/halo導(dǎo)讀Halo 控制臺(tái)Console內(nèi)置了基于 FormKit 的自定義select選擇器組件用于在插件、主題、文章作者等場(chǎng)景中提供單選、多選、靜態(tài)數(shù)據(jù)源與遠(yuǎn)程動(dòng)態(tài)數(shù)據(jù)源等能力。早期版本的下拉選項(xiàng)只以純文本標(biāo)簽label渲染當(dāng)多個(gè)選項(xiàng)名稱相似時(shí)難以區(qū)分。本文基于 Halo 倉(cāng)庫(kù)中的功能提案 proposal.md 及其配套規(guī)范 spec.md深入講解 Halo 如何為自定義select選項(xiàng)引入可選的icon與description元數(shù)據(jù)涵蓋選項(xiàng)契約、下拉渲染細(xì)節(jié)、action requestOption字段映射、遠(yuǎn)程數(shù)據(jù)源兼容、本地搜索匹配規(guī)則以及完整文檔示例幫助讀者在插件/主題 Schema 中直接落地這一能力。背景為什么需要選項(xiàng)元數(shù)據(jù)Halo 的自定義 FormKitselect輸入此前將下拉選項(xiàng)渲染為純 label 文本行。在如下場(chǎng)景中這種展示方式存在明顯的可用性問(wèn)題插件列表中多個(gè)插件名稱相似僅靠名稱無(wú)法快速分辨主題、文章等配置項(xiàng)需要展示封面圖或摘要信息來(lái)輔助選擇選項(xiàng)附帶說(shuō)明文字時(shí)用戶需要在展開(kāi)下拉與查看說(shuō)明之間來(lái)回切換。因此該提案的目標(biāo)是讓選項(xiàng)支持可視化的、帶解釋性的元數(shù)據(jù)圖標(biāo) 描述同時(shí)完全保留既有表單提交值的契約。最終確定的改動(dòng)范圍包括為 Halo 自定義select選項(xiàng)新增可選的icon與description元數(shù)據(jù)下拉列表中icon以圖片img渲染description以標(biāo)簽下方的次級(jí)文本渲染選中態(tài)保持緊湊閉合狀態(tài)下只展示 label擴(kuò)展action requestOption解析新增可選的iconField與descriptionField字段映射允許remoteOption.search與remoteOption.findOptionsByValues返回帶元數(shù)據(jù)的選項(xiàng)本地選項(xiàng)搜索同時(shí)匹配label與descriptionFormKit 節(jié)點(diǎn)值不變單選提交字符串多選提交字符串?dāng)?shù)組同步更新自定義 FormKit 輸入文檔與前端聚焦測(cè)試。該改動(dòng)不涉及后端 API、數(shù)據(jù)庫(kù) Schema、OpenAPI、生成的 API Client、i18n 鍵或 npm 依賴變更屬于純前端能力增強(qiáng)。選項(xiàng)元數(shù)據(jù)契約SelectOption 類型選項(xiàng)契約在 types.ts 中定義核心接口如下export interface SelectOptionValue string extends Recordstring, unknown { label: string; value: Value; icon?: string; description?: string; attrs?: { disabled?: boolean; } Recordstring, unknown; }契約要點(diǎn)label與value為必填字段是所有選項(xiàng)的兜底基礎(chǔ)icon可選值為圖片資源地址可以是相對(duì)路徑如/assets/flags/cn.svg也可以是插件靜態(tài)資源地址渲染為imgdescription可選作為 label 下方的次級(jí)說(shuō)明文字同時(shí)參與本地靜態(tài)選項(xiàng)搜索attrs可選其中attrs.disabled用于禁用選項(xiàng)該能力在元數(shù)據(jù)加入前后保持不變——即使選項(xiàng)同時(shí)攜帶icon/descriptionattrs.disabled依然生效對(duì)應(yīng)規(guī)范中的Disabled option metadata remains supported場(chǎng)景。對(duì)于action requestOption模式types.ts 在SelectActionRequest中新增了兩個(gè)可選映射字段/** * Field name for option icon image source. */ iconField?: PropertyPath; /** * Field name for secondary option description. */ descriptionField?: PropertyPath;它們與既有的labelField、valueField、itemsField、pageField、sizeField、totalField、fieldSelectorKey等字段一樣都支持lodash-es風(fēng)格的PropertyPath例如spec.displayName、status.logo這類點(diǎn)路徑。下拉選項(xiàng)渲染圖標(biāo)與說(shuō)明文字的呈現(xiàn)渲染實(shí)現(xiàn)下拉行渲染由 SelectOptionItem.vue 完成模板結(jié)構(gòu)如下template div classflex min-h-8 w-full items-center gap-3 rounded px-3 py-1.5 img v-ifoption.icon :srcoption.icon alt aria-hiddentrue classshrink-0 rounded object-contain :classoption.description ? h-8 w-8 : h-5 w-5 loadinglazy referrerpolicyno-referrer errorhandleIconLoadError / span classmin-w-0 flex-1 span classblock truncate text-sm leading-5 text-gray-900 {{ option.label }} /span span v-ifoption.description classblock truncate text-xs leading-4 text-gray-500 {{ option.description }} /span /span /div /template渲染規(guī)則的細(xì)節(jié)值得注意圖標(biāo)尺寸自適應(yīng)當(dāng)選項(xiàng)同時(shí)包含description時(shí)圖標(biāo)為h-8 w-832px僅有icon無(wú)description時(shí)為h-5 w-520px避免無(wú)說(shuō)明文字時(shí)圖標(biāo)過(guò)大圖片加載細(xì)節(jié)設(shè)置alt與aria-hiddentrue裝飾性圖片不影響無(wú)障礙閱讀、loadinglazy懶加載、referrerpolicyno-referrer防止跨域圖片請(qǐng)求泄漏來(lái)源信息說(shuō)明文字為次級(jí)文本text-xs leading-4 text-gray-500與主 labeltext-sm形成層級(jí)區(qū)分并使用truncate防止超長(zhǎng)文本撐破布局圖標(biāo)加載失敗兜底handleIconLoadError將失敗的img元素hidden置為true保證選項(xiàng)仍可選中且不顯示破圖占位——對(duì)應(yīng)規(guī)范中Icon image fails to load場(chǎng)景const handleIconLoadError (event: Event) { const target event.target as HTMLImageElement; target.hidden true; };無(wú)元數(shù)據(jù)完全兼容只有l(wèi)abel/value的選項(xiàng)渲染行為與舊版一致不會(huì)預(yù)留空白圖標(biāo)位或空次級(jí)文本行。選中態(tài)保持緊湊下拉項(xiàng)渲染增強(qiáng)后閉合狀態(tài)下的選中展示并不跟隨變化單選模式的閉合顯示與多選模式的 chips 均只展示 label不顯示圖標(biāo)與描述對(duì)應(yīng)的測(cè)試用例在 select-option-rendering.spec.ts 中驗(yàn)證keeps selected display label-only——斷言選中態(tài)文本包含 label、不包含 description、且不存在img元素。這樣既在下拉展開(kāi)時(shí)提供豐富信息又避免了閉合狀態(tài)下標(biāo)簽過(guò)高、信息冗余的問(wèn)題。值契約不變單選字符串多選字符串?dāng)?shù)組雖然選項(xiàng)對(duì)象可以攜帶完整元數(shù)據(jù)但提交到表單的值契約保持原樣。核心邏輯位于 SelectMain.vue 的handleSetNodeValueconst handleSetNodeValue (value: SelectOption[]) { const values value.map((item) item.value); selectOptions.value value; if (selectProps.multiple) { props.context.node.input(values); return; } if (values.length 0) { props.context.node.input(); return; } props.context.node.input(values[0]); };可以看到多選模式node.input(values)節(jié)點(diǎn)值為字符串?dāng)?shù)組單選模式node.input(values[0])節(jié)點(diǎn)值為單個(gè)字符串空選擇時(shí)單選模式回落到空字符串。而完整選項(xiàng)對(duì)象含icon、description則通過(guò)handleUpdate中的回調(diào)暴露給使用方const handleUpdate async (value: SelectOption[]) { // ... handleSetNodeValue(value); await props.context.node.settled; props.context.attrs.onChange?.(value); };也就是說(shuō)表單值保持純值字符串onChange回調(diào)卻可以拿到包含icon/description的完整選項(xiàng)對(duì)象這正好對(duì)應(yīng)規(guī)范中Change callback receives metadata的場(chǎng)景讓父組件在回調(diào)里也能按需展示元數(shù)據(jù)。action requestOption元數(shù)據(jù)字段映射對(duì)于通過(guò)action遠(yuǎn)程接口地址加載選項(xiàng)的場(chǎng)景新增iconField與descriptionField用于把接口響應(yīng)中的任意字段映射為選項(xiàng)的icon與description。映射實(shí)現(xiàn)在 option-utils.ts 的mapItemsToSelectOptions中export function mapItemsToSelectOptions( items: Arrayobject, requestOption: Pick SelectActionRequest, labelField | valueField | iconField | descriptionField ): SelectOption[] { const { descriptionField, iconField, labelField label, valueField value, } requestOption; // ... return items.map((item) { // labelField / valueField 缺失時(shí)輸出 console.error 并兜底 const option: SelectOption { label: get(item, labelField) as string, value: get(item, valueField) as string, }; setStringMetadata(option, icon, item, iconField); setStringMetadata(option, description, item, descriptionField); return option; }); } function setStringMetadata( option: SelectOption, key: description | icon, item: object, field?: PropertyPath ) { if (!field || !has(item, field)) { return; } const value get(item, field); if (typeof value string value) { option[key] value; } }實(shí)現(xiàn)要點(diǎn)未配置iconField/descriptionField時(shí)setStringMetadata直接返回行為與舊版完全一致向后兼容配置了字段但響應(yīng)中不存在該字段時(shí)同樣安全跳過(guò)僅當(dāng)字段值是非空字符串時(shí)才寫(xiě)入元數(shù)據(jù)避免null、數(shù)字等異常類型污染選項(xiàng)對(duì)象與mapItemsToSelectOptions相同parseSelectResponse中parseData自定義解析返回的選項(xiàng)若已含icon/description也會(huì)原樣保留。默認(rèn)值一覽在 SelectMain.vue 的initSelectProps中requestOption的默認(rèn)值如下selectProps.requestOption { ...{ method: GET, itemsField: items, labelField: label, valueField: value, totalField: total, fieldSelectorKey: metadata.name, pageField: page, sizeField: size, iconField: undefined, descriptionField: undefined, parseData: undefined, }, ...(nodeProps.requestOption ?? {}), };也就是說(shuō)labelField、valueField、itemsField、pageField、sizeField、totalField都有默認(rèn)值而iconField、descriptionField默認(rèn)未啟用需要顯式配置。測(cè)試印證option-utils.spec.ts 覆蓋了兩種關(guān)鍵場(chǎng)景帶元數(shù)據(jù)映射響應(yīng)項(xiàng)形如{ metadata: { name }, spec: { description, displayName }, status: { logo } }通過(guò)descriptionField: spec.description、iconField: status.logo、labelField: spec.displayName、valueField: metadata.name映射后得到{ description, icon, label, value }完整選項(xiàng)無(wú)元數(shù)據(jù)兼容空requestOption{}下簡(jiǎn)單{ label, value }選項(xiàng)原樣映射。遠(yuǎn)程數(shù)據(jù)源元數(shù)據(jù)保留remoteOption 接口對(duì)于由插件/主題完全自定義的遠(yuǎn)程數(shù)據(jù)源remote: truetypes.ts 定義了SelectRemoteOptionexport interface SelectRemoteOption { search: ({ keyword, page, size, }: SelectRemoteRequest) PromiseSelectResponse; findOptionsByValues: (values: string[]) PromiseSelectOption[]; }search用于關(guān)鍵詞搜索findOptionsByValues用于把已選值反查為完整選項(xiàng)例如默認(rèn)值不在當(dāng)前頁(yè)時(shí)回填。兩者返回的SelectOption[]中若包含icon/description都會(huì)被保留用于下拉渲染與選擇回調(diào)無(wú)需額外配置。已選值回填鏈路當(dāng)已選值無(wú)法在當(dāng)前已加載選項(xiàng)中匹配到時(shí)SelectMain.vue 會(huì)走fetchSelectedOptions - mapUnresolvedOptions鏈路action模式發(fā)起帶fieldSelector: ${fieldSelectorKey}(v1,v2,...)的二次查詢GET 走 params、POST 走 data響應(yīng)經(jīng)parseSelectResponse解析此時(shí)配置的iconField/descriptionField會(huì)同樣作用于回填數(shù)據(jù)對(duì)應(yīng)規(guī)范中Action value lookup maps metadata場(chǎng)景remote模式直接調(diào)用remoteOption.findOptionsByValues獲取完整選項(xiàng)若開(kāi)啟了remoteOptimize且total size所有選項(xiàng)會(huì)被緩存cacheAllOptions后續(xù)回填直接走內(nèi)存緩存過(guò)濾不再發(fā)請(qǐng)求。無(wú)論走哪條鏈路最終selectOptions中都會(huì)保留元數(shù)據(jù)保證閉合狀態(tài)下也能通過(guò)回調(diào)拿到完整對(duì)象。本地搜索label 與 description 雙匹配靜態(tài)數(shù)據(jù)源的本地過(guò)濾邏輯在 option-utils.ts 的isSelectOptionMatchedexport function isSelectOptionMatched(option: SelectOption, keyword: string) { const normalizedKeyword keyword.toLocaleLowerCase(); return [option.label, option.description] .filter(Boolean) .some((text) text?.toString().toLocaleLowerCase().includes(normalizedKeyword) ); }規(guī)則非常明確關(guān)鍵詞命中l(wèi)abel或description中的任意一個(gè)即視為匹配大小寫(xiě)不敏感命中icon 圖片地址不會(huì)使選項(xiàng)被匹配——圖標(biāo)源路徑如/assets/shortcut.svg不參與搜索。這一點(diǎn)在測(cè)試中得到了直接驗(yàn)證expect(isSelectOptionMatched(option, quick)).toBe(true); // 命中 label expect(isSelectOptionMatched(option, dashboard)).toBe(true); // 命中 description expect(isSelectOptionMatched(option, shortcut)).toBe(false); // icon 源不參與匹配需要特別說(shuō)明的是該匹配規(guī)則僅作用于本地靜態(tài)選項(xiàng)的過(guò)濾遠(yuǎn)程數(shù)據(jù)源的搜索關(guān)鍵詞始終原樣透?jìng)鹘oremoteOption.search或action接口由服務(wù)端/提供方?jīng)Q定過(guò)濾邏輯Halo 不會(huì)對(duì)遠(yuǎn)程返回結(jié)果再做本地 description 過(guò)濾對(duì)應(yīng)規(guī)范中Remote search remains provider-driven場(chǎng)景。另外在remoteOptimize已緩存全部選項(xiàng)的場(chǎng)景下緩存數(shù)據(jù)的模糊檢索使用useFuse且keys: [label, value]該路徑不參與 description 匹配——這與規(guī)范要求并不沖突因?yàn)榇寺窂奖举|(zhì)上是已加載數(shù)據(jù)的本地快速檢索而非過(guò)濾語(yǔ)義。實(shí)戰(zhàn)在 Vue SFC 與 FormKit Schema 中使用Halo 的官方文檔 ui/docs/custom-formkit-input/README.md 的 select 章節(jié)已經(jīng)同步更新給出 Vue SFC 與 FormKit Schema 兩種用法。Vue SFC靜態(tài)數(shù)據(jù)源FormKit typeselect labelWhat country makes the best food? namecountries placeholderSelect a country allow-create clearable sortable multiple searchable :options[ { label: China, value: China, icon: /assets/flags/cn.svg, description: Chinese cuisine with rich regional styles, }, { label: USA, value: USA, icon: /assets/flags/us.svg, description: American cuisine with diverse influences, }, { label: Japan, value: Japan }, { label: Korea, value: Korea }, // ... ] helpDon’t worry, you can’t get this one wrong. /靜態(tài)選項(xiàng)直接在每個(gè)對(duì)象上寫(xiě)icon與description即可未攜帶元數(shù)據(jù)的選項(xiàng)如 Japan、Korea照常渲染。Vue SFC遠(yuǎn)程數(shù)據(jù)源remotescript langts setup const handleSelectPostAuthorRemote { search: async ({ keyword, page, size }) { const { data } await consoleApiClient.user.listUsers({ page, size, keyword, fieldSelector: [ name!anonymousUser, name!ghost, ], }); return { options: data.items.map((item) ({ label: item.user.spec.displayName, value: item.user.metadata.name, icon: item.user.spec.avatar, description: item.user.spec.email, })), total: data.total, page: data.page, size: data.size, }; }, findOptionsByValues: () { return []; }, }; /script template FormKit typeselect labelThe author of the post is? namepost_author placeholderSelect a user searchable remote :remote-optionhandleSelectPostAuthorRemote / /template該示例展示了一個(gè)非常典型的落地場(chǎng)景以用戶頭像作為icon、用戶郵箱作為description幫助在多名作者中快速定位。FormKit Schema靜態(tài)數(shù)據(jù)源- $formkit: select name: countries label: What country makes the best food? sortable: true multiple: true clearable: true placeholder: Select a country options: - label: China value: cn icon: /assets/flags/cn.svg description: Chinese cuisine with rich regional styles - label: Greece value: grFormKit Schemaaction requestOption 元數(shù)據(jù)映射- $formkit: select name: postName label: Choose an post clearable: true action: /apis/api.console.halo.run/v1alpha1/posts requestOption: method: GET pageField: page sizeField: size totalField: total itemsField: items labelField: post.spec.title valueField: post.metadata.name iconField: post.spec.cover descriptionField: post.status.excerpt fieldSelectorKey: metadata.name這里的關(guān)鍵是接口自身無(wú)需任何改動(dòng)只需通過(guò)iconField: post.spec.cover把文章封面映射為圖標(biāo)、descriptionField: post.status.excerpt把文章摘要映射為說(shuō)明文字即可。遠(yuǎn)程接口會(huì)自動(dòng)拼接page、size、keyword參數(shù)當(dāng)已選值不在第一頁(yè)時(shí)Select 組件會(huì)攜帶fieldSelector: ${requestOption.fieldSelectorKey}(v1,v2,v3)發(fā)起二次查詢并用同一requestOption解析回填數(shù)據(jù)。兼容性與影響范圍前端文件改動(dòng)集中在ui/src/formkit/inputs/select/目錄類型、工具函數(shù)、選項(xiàng)行渲染以及 FormKit 數(shù)組展示復(fù)用的 select label 渲染邏輯文檔ui/docs/custom-formkit-input/README.md 的 select 章節(jié)向后兼容icon、description、iconField、descriptionField全部可選既有的靜態(tài)與遠(yuǎn)程選項(xiàng)無(wú)需任何改動(dòng)即可繼續(xù)工作舊選項(xiàng)僅labelvalue的渲染與選中行為與舊版一致存量 Schema 兼容插件與主題 Schema 可通過(guò)兩種方式接入新能力——在選項(xiàng)對(duì)象中直接加icon/description或在action requestOption中配置iconField/descriptionField禁用態(tài)不受影響attrs.disabled選項(xiàng)在有無(wú)元數(shù)據(jù)時(shí)均保持禁用邏輯??偨Y(jié)Halo 自定義 FormKitselect的這次增強(qiáng)在不改變提交值契約、不引入后端依賴的前提下為下拉選項(xiàng)補(bǔ)齊了可視化辨識(shí)能力icon以圖片形式強(qiáng)化視覺(jué)區(qū)分description以次級(jí)文本承載解釋信息同時(shí)本地搜索順帶覆蓋說(shuō)明文字、遠(yuǎn)程數(shù)據(jù)源完整保留元數(shù)據(jù)。對(duì)插件與主題開(kāi)發(fā)者而言只需在選項(xiàng)對(duì)象或requestOption中補(bǔ)充少量配置即可顯著提升配置界面的可用性對(duì)使用者而言相似名稱的選項(xiàng)從此可以靠圖標(biāo)與描述快速區(qū)分。更多細(xì)節(jié)可進(jìn)一步閱讀功能提案openspec/changes/archive/2026-06-11-enhance-formkit-select-options/proposal.md行為規(guī)范含全部 WHEN/THEN 場(chǎng)景openspec/specs/formkit-select-options/spec.md類型定義types.ts映射與搜索實(shí)現(xiàn)option-utils.ts下拉行渲染SelectOptionItem.vue核心邏輯SelectMain.vue單元測(cè)試option-utils.spec.ts 與 select-option-rendering.spec.ts用戶文檔ui/docs/custom-formkit-input/README.md【免費(fèi)下載鏈接】haloHalo 是一款強(qiáng)大易用的開(kāi)源建站工具從個(gè)人博客、知識(shí)庫(kù)到企業(yè)官網(wǎng)、在線商城Halo 都能助您輕松實(shí)現(xiàn)一站式滿足您的多樣化建站需求。項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ha/halo創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考