iter(g326): ua search 补同义词回退
This commit is contained in:
@@ -9,6 +9,7 @@ import { auditContrast, formatReport, PAIRS } from '../lib/contrast-audit.mjs';
|
||||
import { shellPalette } from '../lib/shell-palette.mjs';
|
||||
import { auditMotion, formatMotionReport } from '../lib/motion-audit.mjs';
|
||||
import { auditSurface, formatSurfaceReport, LADDER } from '../lib/surface-audit.mjs';
|
||||
import { SYNONYMS, synonymIds } from '../lib/synonyms.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -258,6 +259,20 @@ function matchOf(kw, comp) {
|
||||
return best;
|
||||
}
|
||||
|
||||
/*** 同义回退:整组关键词在 id / 标题 / 参数 / 步骤与配方里都落空时,按同义词表换算出候选组件
|
||||
* CLI search 与 MCP search_components 共用这一份,避免两处口径漂移;返回空数组表示「回退也无解」 ***/
|
||||
function fallbackBySynonym(kws) {
|
||||
const sets = kws.map((kw) => synonymIds(kw));
|
||||
if (sets.some((s) => !s)) return []; // 有词未被同义词表覆盖:整组回退无从谈起
|
||||
const all = collectComponents();
|
||||
let cands = all.filter((x) => sets.every((s) => s.has(x.comp.id))); // 多词取交集,与搜索的 AND 语义保持一致
|
||||
if (!cands.length) { // 交集为空(两个词各指不同组件)退化为并集:说了两个诉求就把相关项都给到
|
||||
const union = new Set(sets.flatMap((s) => [...s]));
|
||||
cands = all.filter((x) => union.has(x.comp.id));
|
||||
}
|
||||
return cands;
|
||||
}
|
||||
|
||||
/*** 按关键词搜组件:多词取最弱命中(AND 语义),并标出该词命中的字段,便于判断为何被召回 ***/
|
||||
/*** 单个配方对多关键词的命中:标题 3 / 组件 2 / 要点 1,返回最弱档(与组件搜索同一排序口径),未命中返回 null ***/
|
||||
function recipeMatch(kws, r) {
|
||||
@@ -292,6 +307,10 @@ function cmdSearch(words = [], args = []) {
|
||||
.map((r) => ({ r, m: recipeMatch(kws, r) }))
|
||||
.filter((x) => x.m)
|
||||
.sort((a, b) => b.m.weight - a.m.weight || Number(a.r.id) - Number(b.r.id));
|
||||
// 常规两域全空才动用同义词:命中正常时不掺水,免得「搜 select 冒出一堆 drawer」
|
||||
if (!hits.length && !recipeHits.length) {
|
||||
for (const x of fallbackBySynonym(kws)) hits.push({ pkg: x.pkg, comp: x.comp, score: 1, field: '同义词', keyword: kws.join(' ') });
|
||||
}
|
||||
if (!hits.length && !recipeHits.length) {
|
||||
const near = nearIds(kws.join(''));
|
||||
console.error(`no match: ${words.join(' ')}${near.length ? `(相近:${near.join(' / ')})` : ''}`);
|
||||
@@ -310,7 +329,7 @@ function cmdSearch(words = [], args = []) {
|
||||
);
|
||||
}
|
||||
for (const h of hits) {
|
||||
const why = h.field === 'id' || h.field === '标题' ? '' : ` ← ${h.field}含「${h.keyword}」`;
|
||||
const why = h.field === '同义词' ? ` ← 同义词「${h.keyword}」` : h.field === 'id' || h.field === '标题' ? '' : ` ← ${h.field}含「${h.keyword}」`;
|
||||
console.log(`${String(h.comp.id).padEnd(16)} ${h.comp.title ?? ''} (${h.pkg.name})${why}`);
|
||||
}
|
||||
for (const { r, m } of recipeHits) {
|
||||
@@ -320,7 +339,8 @@ function cmdSearch(words = [], args = []) {
|
||||
const parts = [];
|
||||
if (hits.length) parts.push(`${hits.length} 个组件`);
|
||||
if (recipeHits.length) parts.push(`${recipeHits.length} 条配方`);
|
||||
console.log(`\n共 ${parts.join('、')}命中;查参数:ua info <组件id>,看骨架:ua recipes <编号>`);
|
||||
const viaSyn = hits.length && hits.every((h) => h.field === '同义词'); // 全靠同义回退才算「口语命中」
|
||||
console.log(`\n共 ${parts.join('、')}命中${viaSyn ? '(常规字段无命中,按同义词表回退)' : ''};查参数:ua info <组件id>,看骨架:ua recipes <编号>`);
|
||||
}
|
||||
|
||||
/*** 输出包或组件信息:默认人读摘要,--json 输出原始 JSON;name 支持逗号分隔一次查多个(单值保持原输出结构不破坏脚本) ***/
|
||||
@@ -910,7 +930,12 @@ function cmdMcp() {
|
||||
.map((r) => ({ r, m: recipeMatch([q], r) }))
|
||||
.filter((x) => x.m)
|
||||
.map(({ r, m }) => ({ id: r.id, title: r.title, components: recipeComps(r), matched: { field: m.label, keyword: m.keyword } }));
|
||||
if (!pkgs.length && !comps.length && !recs.length) throw new Error(`no match: ${args.query}`);
|
||||
if (!pkgs.length && !comps.length && !recs.length) {
|
||||
// 三域全空时与 CLI 同源回退:Agent 常拿到中文口语词(「弹窗」),不兜底就只能反复试错
|
||||
const syn = fallbackBySynonym([q]).map(({ pkg, comp }) => ({ package: pkg.name, id: comp.id, title: comp.title, matched: { field: '同义词', keyword: q } }));
|
||||
if (!syn.length) throw new Error(`no match: ${args.query}`);
|
||||
return { packages: pkgs, components: syn, recipes: recs };
|
||||
}
|
||||
return { packages: pkgs, components: comps, recipes: recs };
|
||||
}
|
||||
if (name === 'get_tokens') {
|
||||
@@ -1605,6 +1630,24 @@ function checkVisualChecklist() {
|
||||
}
|
||||
|
||||
/*** 清单一致性校验:组件文档块的 id / props 说明 / demo 锚点必须与代码和演示产物对齐 ***/
|
||||
/*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/
|
||||
function checkSynonyms(ids) {
|
||||
const issues = [];
|
||||
const seen = new Map(); // 词 → 已登记的组件 id(用于报重复)
|
||||
for (const group of SYNONYMS) {
|
||||
const tag = `同义词「${group.words[0]}」`;
|
||||
if (!(group.words ?? []).length) issues.push('同义词表有一组缺少 words');
|
||||
if (!(group.ids ?? []).length) issues.push(`${tag}: 缺少 ids`);
|
||||
for (const id of group.ids ?? []) if (!ids.has(id)) issues.push(`${tag}: 目标组件 ${id} 不在清单里(改名后忘改同义词表)`);
|
||||
for (const w of group.words ?? []) {
|
||||
if (seen.has(w)) issues.push(`${tag}: 词「${w}」已归入同义词「${seen.get(w)}」(一词只许一组,否则回退结果随表序变化)`);
|
||||
seen.set(w, group.words[0]);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/*** 校验清单 ↔ 文档 ↔ 演示一致,任一项不符即退出码 1(口径见 AGENTS.md 「门禁一条龙」) ***/
|
||||
function cmdVerify() {
|
||||
const issues = [];
|
||||
const pkgs = collectManifests(); // 一次收集,供组件校验与 README 区块校验共用
|
||||
@@ -1667,6 +1710,7 @@ function cmdVerify() {
|
||||
.flatMap((p) => p.docs?.components ?? []).length;
|
||||
issues.push(...checkCountDocs(skinTotal));
|
||||
issues.push(...checkReadme(pkgs));
|
||||
issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在
|
||||
checkDarkTokens(issues);
|
||||
checkTokenParity(issues);
|
||||
checkLlmsFull(pkgs, issues);
|
||||
@@ -1678,7 +1722,7 @@ function cmdVerify() {
|
||||
console.error(`${issues.length} violation(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00),组合配方结构完整,MCP 工具清单三处一致,视觉回归抽查清单十八组件 × 五维度齐全)');
|
||||
console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00),组合配方结构完整,MCP 工具清单三处一致,搜索同义词表目标存在且无歧义,视觉回归抽查清单十八组件 × 五维度齐全)');
|
||||
}
|
||||
|
||||
/*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*** 中文口语词 → 组件 id 同义词表:人(与 Agent)习惯说「弹窗」「下拉」「转圈」,而库内 id 是英文(modal / select / spinner)。
|
||||
* search 全部落空时按本表回退一次,避免「库里有组件却搜不到」。ids 必须真实存在于清单(ua verify 校验),
|
||||
* 同一个词只允许归一组(歧义会让回退结果随表序漂移)。新增只要追加一行即可,无需改命令实现。 ***/
|
||||
export const SYNONYMS = [
|
||||
{ words: ['弹窗', '对话框', '模态框', '模态弹窗'], ids: ['modal', 'popconfirm', 'drawer'] },
|
||||
{ words: ['抽屉', '侧滑', '侧边面板'], ids: ['drawer'] },
|
||||
{ words: ['下拉', '下拉框', '选择器', '选择框'], ids: ['select', 'multiSelect', 'cascader', 'treeSelect', 'autoComplete'] },
|
||||
{ words: ['下拉菜单', '菜单', '导航菜单'], ids: ['menu', 'select'] },
|
||||
{ words: ['日期', '日期选择', '日历'], ids: ['datePicker', 'calendar'] },
|
||||
{ words: ['时间', '时刻', '时钟'], ids: ['timePicker', 'relativeTime'] },
|
||||
{ words: ['开关', '切换开关', '布尔开关'], ids: ['switchButton', 'segmented'] },
|
||||
{ words: ['单选', '单选框', '单选组'], ids: ['radioGroup', 'select'] },
|
||||
{ words: ['多选', '复选框', '勾选'], ids: ['checkbox', 'checkboxGroup', 'multiSelect'] },
|
||||
{ words: ['输入框', '文本框', '输入'], ids: ['textField', 'textarea', 'inputNumber', 'inputTag'] },
|
||||
{ words: ['表格', '数据表', '表格排序'], ids: ['dataTable'] },
|
||||
{ words: ['分页', '翻页', '页码'], ids: ['pagination'] },
|
||||
{ words: ['标签', '小标签', '标记'], ids: ['tag'] },
|
||||
{ words: ['徽标', '角标', '红点'], ids: ['badge', 'avatarGroup'] },
|
||||
{ words: ['头像', '用户头像'], ids: ['avatar', 'avatarGroup'] },
|
||||
{ words: ['气泡', '浮层', '提示浮层'], ids: ['tooltip', 'popover', 'popconfirm'] },
|
||||
{ words: ['通知', '消息提示', '提示条'], ids: ['toast', 'notification', 'noticeBar', 'alert'] },
|
||||
{ words: ['加载', '载入', '转圈', '等待'], ids: ['spinner', 'loading', 'progressRing', 'skeleton'] },
|
||||
{ words: ['骨架屏', '骨架', '占位块'], ids: ['skeleton', 'empty'] },
|
||||
{ words: ['空态', '空状态', '无数据'], ids: ['empty', 'result'] },
|
||||
{ words: ['进度', '进度条', '完成度'], ids: ['progress', 'progressRing'] },
|
||||
{ words: ['步骤', '步骤条', '流程条'], ids: ['stepper', 'timeline'] },
|
||||
{ words: ['折叠', '手风琴', '折叠面板'], ids: ['accordion', 'tree'] },
|
||||
{ words: ['树', '树形', '层级树'], ids: ['tree', 'treeSelect', 'cascader'] },
|
||||
{ words: ['上传', '文件上传', '附件'], ids: ['upload'] },
|
||||
{ words: ['轮播', '走马灯', '幻灯片'], ids: ['carousel'] },
|
||||
{ words: ['穿梭', '穿梭框', '转移'], ids: ['transfer'] },
|
||||
{ words: ['锚点', '目录导航', '页内导航'], ids: ['anchor', 'affix', 'backTop'] },
|
||||
{ words: ['返回顶部', '回顶'], ids: ['backTop', 'affix'] },
|
||||
{ words: ['面包屑', '路径导航'], ids: ['breadcrumb'] },
|
||||
{ words: ['时间线', '时间轴', '事件流'], ids: ['timeline'] },
|
||||
{ words: ['评分', '星级', '打分'], ids: ['rate'] },
|
||||
{ words: ['滑块', '滑动条', '拖拽条'], ids: ['slider', 'splitter'] },
|
||||
{ words: ['分割', '分栏', '左右分栏'], ids: ['splitter', 'grid', 'layout'] },
|
||||
{ words: ['统计', '数值统计', '指标'], ids: ['statistic', 'inputNumber'] },
|
||||
{ words: ['倒计时', '剩余时间'], ids: ['countdown', 'relativeTime'] },
|
||||
{ words: ['复制', '复制按钮', '一键复制'], ids: ['copyButton'] },
|
||||
{ words: ['快捷键', '按键', '键盘键'], ids: ['kbd'] },
|
||||
{ words: ['高亮', '关键词高亮', '标红'], ids: ['highlight'] },
|
||||
{ words: ['搜索', '命令面板', '快捷搜索'], ids: ['commandPalette', 'autoComplete', 'mentions'] },
|
||||
{ words: ['代码', '代码块', '源码'], ids: ['codeBlock', 'diffView', 'jsonViewer'] },
|
||||
{ words: ['差异', '对比', '差异对比'], ids: ['diffView'] },
|
||||
{ words: ['签名', '手写', '签字'], ids: ['signaturePad'] },
|
||||
{ words: ['热力图', '日历热图', '活跃图'], ids: ['heatmap'] },
|
||||
{ words: ['折线', '迷你图', '走势'], ids: ['sparkline'] },
|
||||
{ words: ['水印'], ids: ['watermark'] },
|
||||
{ words: ['引导', '新手引导', '导览'], ids: ['tour', 'popover'] },
|
||||
{ words: ['拖拽排序', '排序列表', '拖动'], ids: ['sortableList'] },
|
||||
{ words: ['瀑布流', '不规则网格', '卡片墙'], ids: ['masonry', 'grid'] },
|
||||
{ words: ['虚拟列表', '长列表', '海量列表'], ids: ['virtualList', 'list'] },
|
||||
{ words: ['图片', '图片预览', '大图'], ids: ['image', 'avatar'] },
|
||||
{ words: ['验证码', '密码框', '分格输入'], ids: ['pinInput'] },
|
||||
{ words: ['标签输入', '多值输入', '输入标签'], ids: ['inputTag', 'mentions'] },
|
||||
{ words: ['描述列表', '详情列表', '键值对'], ids: ['descriptions'] },
|
||||
{ words: ['结果页', '成功页', '失败页'], ids: ['result'] },
|
||||
{ words: ['卡片', '面板'], ids: ['card'] },
|
||||
{ words: ['按钮', '操作按钮'], ids: ['button', 'floatButton'] },
|
||||
{ words: ['悬浮按钮', '浮动按钮', '快速操作'], ids: ['floatButton'] },
|
||||
{ words: ['排版', '标题', '文本'], ids: ['typography'] },
|
||||
{ words: ['链接', '超链接', '跳转'], ids: ['link'] },
|
||||
{ words: ['分段控制器', '分段', '分段控制'], ids: ['segmented', 'tabs'] },
|
||||
{ words: ['页签', '标签页', '选项卡'], ids: ['tabs'] },
|
||||
{ words: ['取色', '颜色选择', '调色'], ids: ['colorPicker'] },
|
||||
{ words: ['布局', '页面框架', '骨架布局'], ids: ['layout', 'grid'] },
|
||||
{ words: ['间距', '空隙', '间隔'], ids: ['space', 'divider'] },
|
||||
{ words: ['分割线', '分隔线'], ids: ['divider'] },
|
||||
{ words: ['二次确认', '确认气泡'], ids: ['popconfirm'] },
|
||||
{ words: ['自动补全', '联想', '输入联想'], ids: ['autoComplete'] },
|
||||
{ words: ['树选择', '树形选择'], ids: ['treeSelect'] },
|
||||
{ words: ['级联', '级联选择', '省市'], ids: ['cascader'] },
|
||||
{ words: ['筛选', '过滤'], ids: ['dataTable'] },
|
||||
{ words: ['表单', '表单校验'], ids: ['form', 'textField', 'checkboxGroup'] },
|
||||
];
|
||||
|
||||
/*** 查一个词的同义组件 id 集合;未收录返回 null——与「收录但命中为空」区分,调用方据此判断能否整组回退 ***/
|
||||
export function synonymIds(word) {
|
||||
const w = String(word ?? '').toLowerCase().trim();
|
||||
const hit = SYNONYMS.find((g) => g.words.includes(w));
|
||||
return hit ? new Set(hit.ids) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user