fix(skills): 修复 design-handoff 医生端原型输出质量

- match-tokens: alias 增加值条件映射,支持 doctor/patient variant 自动检测
- parse-prototype: 支持 IosFrame label prop 提取(JSX 属性模式)
- interaction-rules: login-cta 增加 exclude_patterns 避免"立即关注"误报
- tokens.yml: T.pri/T.priL/T.priD alias 改为数组格式,按值匹配 variant

验证: 患者端 T.pri→--tk-pri 确认,医生端 T.pri→--tk-pri.doctor 确认
This commit is contained in:
iven
2026-05-18 01:00:20 +08:00
parent c26ca9088b
commit 6151fde7c4
6 changed files with 138 additions and 40 deletions

View File

@@ -327,16 +327,34 @@ shadow_unmapped:
aliases:
prototype_keys:
T.pri:
token: --tk-pri
status: exact_match
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
token: --tk-pri-l
status: exact_match
- value: "#F0DDD4"
token: --tk-pri-l
status: exact_match
variant: patient
- value: "#D4E5F0"
token: --tk-pri-l.doctor
status: exact_match
variant: doctor
T.priD:
token: --tk-pri-d
status: exact_match
- value: "#8B3E1F"
token: --tk-pri-d
status: exact_match
variant: patient
- value: "#2A4F6A"
token: --tk-pri-d.doctor
status: exact_match
variant: doctor
T.bg:
token: null

View File

@@ -84,8 +84,14 @@ rules:
name: "登录/注册触发"
patterns:
- "登录"
- "立即"
- "注册"
- "微信登录"
- "立即登录"
exclude_patterns:
- "立即关注"
- "立即处理"
- "立即查看"
- "需要立即"
require_all: false
infer:
component: "PrimaryButton"

View File

@@ -121,6 +121,23 @@ function matchRule(rule, source, funcRanges) {
? matchedPatterns.length === rule.patterns.length
: matchedPatterns.length > 0;
// 排除模式检查:如果任一 exclude_pattern 命中,该规则不匹配
if (allMatched && Array.isArray(rule.exclude_patterns)) {
for (const exclPattern of rule.exclude_patterns) {
try {
const exclRegex = new RegExp(exclPattern);
if (exclRegex.test(source)) {
return {
matched: false,
matchedPatterns: [],
locations: [],
excluded_by: exclPattern,
};
}
} catch (_e) { /* ignore regex errors */ }
}
}
return {
matched: allMatched,
matchedPatterns: allMatched ? matchedPatterns : [],

View File

@@ -268,18 +268,53 @@ function buildTokenRegistry(tokensConfig) {
// ---------------------------------------------------------------------------
/**
* Layer 1: 别名直查
* @returns {object|null} 匹配结果或 null
* Layer 1: 别名直查(支持值条件映射)
*
* alias 格式有两种:
* 单条: { token: "...", status: "exact_match" }
* 数组: [{ value: "#C4623A", token: "--tk-pri", variant: "patient" },
* { value: "#3A6B8C", token: "--tk-pri.doctor", variant: "doctor" }]
*
* 数组格式按值匹配,单条格式直接匹配(兼容旧格式)。
*/
function matchByAlias(key, aliases) {
function matchByAlias(key, prototypeValue, aliases) {
if (!aliases || !aliases.prototype_keys) return null;
const alias = aliases.prototype_keys[key];
if (!alias) return null;
const result = {
method: 'alias',
confidence: null,
};
// 数组格式:按值条件匹配
if (Array.isArray(alias)) {
const normalizedInput = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
for (const entry of alias) {
const normalizedAlias = normalizeColor(entry.value) || normalizeNumericValue(entry.value) || String(entry.value);
if (normalizedInput === normalizedAlias) {
const result = {
method: 'alias',
confidence: entry.status === 'exact_match' ? 'confirmed' : 'pending',
token: entry.token || null,
scssVar: entry.scss_var || null,
};
if (entry.variant) result.variant = entry.variant;
if (entry.note) result.note = entry.note;
return result;
}
}
// 数组中无值匹配 → 降级到 Layer 2
return null;
}
// 单条格式(兼容旧格式)
const result = { method: 'alias', confidence: null };
// 值校验:如果 alias 有 value 字段,比较值
if (alias.value && prototypeValue != null) {
const normAlias = normalizeColor(alias.value) || normalizeNumericValue(alias.value) || String(alias.value);
const normProto = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
if (normAlias !== normProto) {
// 值不匹配 → 降级到 Layer 2
return null;
}
}
if (alias.status === 'exact_match') {
result.token = alias.token || null;
@@ -549,7 +584,7 @@ function main() {
const prototypeTokens = parseResult.tokens || {};
for (const [key, value] of Object.entries(prototypeTokens)) {
// Layer 1: 别名直查
const aliasResult = matchByAlias(key, aliases);
const aliasResult = matchByAlias(key, value, aliases);
if (aliasResult) {
matched[key] = {
...aliasResult,
@@ -617,9 +652,14 @@ function main() {
const totalAlias = Object.keys(prototypeTokens).length;
const totalInline = Object.values(inlineStyles).reduce((sum, arr) => sum + arr.length, 0);
// 检测 variant
const variantEntries = Object.values(matched).filter(e => e.variant);
const variant = variantEntries.length > 0 ? variantEntries[0].variant : null;
// ---- 输出 ----
const output = {
source: parseResult.source || null,
variant,
matched,
unmatched,
inlineTokenMap,

View File

@@ -263,18 +263,7 @@ function extractInlineStyles(source) {
function extractScreens(source) {
const screens = [];
// 策略:找所有 screen-wrap 块,从中提取 screen-label 文本和组件名
// 两种形式:
// 1) <div className="screen-wrap"> ... <span className="screen-label">文本</span> ... <IosFrame> ... </div>
// 2) <div class="screen-wrap"> ... <span class="screen-label">文本</span> ... <IosFrame> ... </div>
// 匹配所有 screen-wrap 块
const wrapPattern = /<div\s+(?:className|class)="screen-wrap"[^>]*>([\s\S]*?)<\/div>\s*(?=<div\s+(?:className|class)="screen-wrap"|<\/div>\s*<\/div>|\s*$)/g;
// 更可靠的方式:先找所有 screen-label再在上下文中找组件
// 因为 screen-wrap 的 HTML 嵌套可能很复杂
// 提取所有 screen-label 文本
// 策略 A: 找静态 screen-label 文本(外部 span/div 标签)
const labelPattern = /<(?:span|div)\s+(?:className|class)="screen-label"[^>]*>([^<]*)<\/(?:span|div)>/g;
const labels = [];
let labelMatch;
@@ -282,25 +271,35 @@ function extractScreens(source) {
labels.push(labelMatch[1].trim());
}
// 提取所有 screen-wrap 内的 IosFrame children 组件
// 模式: <IosFrame ...>\n <ComponentName />\n</IosFrame>
// 或: <IosFrame ...><ComponentName /></IosFrame>
// 策略 B: 找 IosFrame 的 label propJSX 属性模式)
// 匹配: <IosFrame ... label="文本" ...>
const iosFrameLabelPattern = /<IosFrame[^>]*\blabel=["']([^"']+)["'][^>]*>/g;
const iosLabels = [];
let iosLabelMatch;
while ((iosLabelMatch = iosFrameLabelPattern.exec(source)) !== null) {
iosLabels.push(iosLabelMatch[1].trim());
}
// 提取 IosFrame children 组件
const iosFramePattern = /<IosFrame[^>]*>\s*(?:<React\.Fragment[^>]*>)?\s*<(\w+)\s*\/?>/g;
const components = [];
let compMatch;
while ((compMatch = iosFramePattern.exec(source)) !== null) {
const name = compMatch[1];
// 排除 HTML 标签和 React 内置
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
components.push(name);
}
}
// labels 和 components 配对
const count = Math.max(labels.length, components.length);
// 选择 label 来源:优先静态标签,如果数量不匹配则尝试 JSX prop
const useIosLabels = labels.length === 0 || (iosLabels.length > 0 && iosLabels.length === components.length && labels.length !== components.length);
const selectedLabels = useIosLabels ? iosLabels : labels;
// 配对
const count = Math.max(selectedLabels.length, components.length);
for (let i = 0; i < count; i++) {
screens.push({
label: labels[i] || '',
label: selectedLabels[i] || '',
component: components[i] || '',
});
}

View File

@@ -327,16 +327,34 @@ shadow_unmapped:
aliases:
prototype_keys:
T.pri:
token: --tk-pri
status: exact_match
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
token: --tk-pri-l
status: exact_match
- value: "#F0DDD4"
token: --tk-pri-l
status: exact_match
variant: patient
- value: "#D4E5F0"
token: --tk-pri-l.doctor
status: exact_match
variant: doctor
T.priD:
token: --tk-pri-d
status: exact_match
- value: "#8B3E1F"
token: --tk-pri-d
status: exact_match
variant: patient
- value: "#2A4F6A"
token: --tk-pri-d.doctor
status: exact_match
variant: doctor
T.bg:
token: null