抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

摘要:在AI数据查询工具中,运营需要组合时间、指标、维度等条件来构建查询。我们没有为每种条件写死UI组件,而是设计了一套JSON驱动的卡片选择系统:20+种可视化卡片通过配置声明式生成,新增或修改查询条件只改JSON、不写代码。这套方案后来被2个类似项目直接复用,新业务接入从"开发3天"变成"配半天"。本文拆解卡片系统的核心抽象思路、JSON Schema设计、渲染引擎实现,以及"配置化"这件事本身容易踩的过度设计陷阱。


一、为什么要做卡片系统?硬编码的代价

先说痛点。AI数据查询工具的交互流程是:用户选条件 → 组装成自然语言/结构化参数 → 喂给大模型生成SQL。
条件类型很多:时间范围、地域、品类、品牌、客单价区间、同比环比、聚合方式……早期我们按常规思路,每种条件一个独立组件,结果很快失控:

  • 重复劳动:5个页面都有"时间选择",但每个页面的可选粒度、默认值、联动逻辑略有不同,于是写了5个长得差不多但不完全一样的时间选择器;
  • 变更成本高:产品说"加一个渠道筛选",前端要新建组件、注册路由、联调接口、提测上线,一个简单需求排期2天;
  • AI对话场景的特殊性:条件不是表单提交,而是"边聊边选"——用户说"上周华东区",系统要自动高亮对应的时间卡片和地域卡片,这种动态绑定用硬编码组件很难优雅实现。

本质问题是:我们把"查询条件的数据结构"和"UI渲染"耦合在了一起
条件本身是数据,UI只是数据的视图。既然是数据,就应该用配置描述,而不是用代码实现。


二、核心抽象:一张卡片的四要素

观察所有查询条件后,我们发现无论多复杂,都可以拆成四个正交维度:

要素 说明 示例
type 卡片的交互形态 date-range / enum-select / number-slider / text-input / cascade-tree
schema 数据的结构约束 枚举选项列表、数值范围、日期格式、级联层级定义
binding 这个卡片对应查询参数的哪个字段 { param: "region", path: "filters.region_code" }
behavior 运行时行为规则 是否必填、与其他卡片的联动关系、默认值策略、AI识别后的回填映射

这四个要素组合起来,就是一张完整的卡片定义。
type决定长什么样,schema决定能填什么,binding决定填的值去哪,behavior决定怎么和其他卡片协作
新增一种查询条件,本质上就是新增一组这四要素的配置,而不是新增一个React组件。


三、JSON Schema设计:让配置自描述且可校验

我们定义了一套卡片配置的JSON Schema,既是文档也是运行时校验依据。以"地域选择"卡片为例:

{
"id": "card_region",
"type": "cascade-tree",
"label": "地域",
"schema": {
"dataSource": "api://meta/regions",
"maxLevel": 3,
"multiSelect": true,
"placeholder": "请选择省/市/区"
},
"binding": {
"param": "region",
"path": "filters.region_codes",
"serialize": "array"
},
"behavior": {
"required": false,
"defaultValue": { "strategy": "last_used" },
"aiMapping": {
"entityType": "region",
"examples": ["华东区", "浙江省", "杭州市"]
},
"dependencies": [
{
"watch": "card_time",
"action": "filter_options",
"rule": "region.available_ranges[{{card_time.value}}]"
}
]
}
}

几个关键设计决策:

3.1 dataSource支持多种来源

schema.dataSource 可以是静态数组、API地址、或表达式引用其他卡片的值。渲染引擎统一封装了 resolveDataSource() 方法,对调用方屏蔽差异。这样"品类列表从接口取"和"聚合方式写死在配置里"用的是同一套渲染逻辑。

3.2 aiMapping让AI能"认出"这张卡片

这是AI对话场景特有的设计。每张卡片声明自己对应的实体类型和典型表述样例,当大模型从用户自然语言中提取出"华东区"时,匹配引擎通过 aiMapping.entityType 定位到这张卡片并自动回填。
没有这个字段,AI提取的结果就无处安放,只能退化成纯文本拼接到prompt里,准确率大幅下降。

3.3 dependencies用声明式表达联动

"选了某个时间段后,地域卡片的可选范围要缩小"这类联动逻辑,传统做法是在组件里写 useEffect 监听。
我们用声明式的 dependencies 数组描述,渲染引擎统一解析执行。
好处是:联动规则跟着配置走,换页面、换项目时不需要重写逻辑。

3.4 Schema即文档,配置即契约

所有卡片配置都通过JSON Schema校验,非法配置在保存时就报错,不会等到运行时才崩。
同时Schema本身就是新人上手文档——看一遍所有卡片的Schema,比读20个组件源码快得多。


四、渲染引擎:从JSON到UI的三层架构

配置再好,渲染引擎拉胯也白搭。我们的引擎分三层:

┌─────────────────────────────────┐
│ CardRegistry │ ← 卡片类型注册表(type → Component映射)
├─────────────────────────────────┤
│ ConfigResolver │ ← 解析dataSource/dependencies/defaults
├─────────────────────────────────┤
│ DynamicRenderer │ ← 遍历配置数组,动态实例化组件
└─────────────────────────────────┘

4.1 CardRegistry:类型与实现的解耦点

const registry = new Map<string, React.ComponentType<CardProps>>();

// 内置类型
registry.set('date-range', DateRangeCard);
registry.set('enum-select', EnumSelectCard);
registry.set('number-slider', NumberSliderCard);
registry.set('cascade-tree', CascadeTreeCard);
registry.set('text-input', TextInputCard);

// 业务扩展类型(新项目可以注册自己的)
registry.set('brand-tag-selector', BrandTagSelectorCard);

export function getCardComponent(type: string) {
const Comp = registry.get(type);
if (!Comp) throw new Error(`Unknown card type: ${type}`);
return Comp;
}

关键原则:渲染引擎永远不知道具体有哪些卡片类型,它只认registry。新项目接入时,只需注册自己的扩展类型,引擎代码零修改。这也是后来能被3个项目复用的架构基础。

4.2 ConfigResolver:把"声明"变成"运行时状态"

JSON配置是静态的,但卡片运行时需要动态数据(接口返回的选项、其他卡片的当前值、联动计算结果)。ConfigResolver负责这个转换:

class ConfigResolver {
// 缓存已解析的dataSource,避免重复请求
private cache = new Map<string, any>();

async resolve(cardConfig: CardConfig, context: CardContext): Promise<ResolvedCardConfig> {
const options = await this.resolveDataSource(cardConfig.schema.dataSource, context);
const defaultValue = this.resolveDefault(cardConfig.behavior.defaultValue, context);
const visible = this.evaluateVisibility(cardConfig.behavior.dependencies, context);

return { ...cardConfig, runtime: { options, defaultValue, visible } };
}
}

这里有个容易忽略的细节:resolver必须是纯函数+缓存
卡片配置可能被多个会话、多个页面共享,如果resolver有副作用或每次都重新请求,性能和一致性都会出问题。

4.3 DynamicRenderer:配置数组 → 组件树

function CardGroup({ configs, values, onChange }: Props) {
const resolved = useResolvedConfigs(configs, values); // hook内调用ConfigResolver

return (
<div className="card-group">
{resolved.map(config => {
if (!config.runtime.visible) return null;
const Component = getCardComponent(config.type);
return (
<Component
key={config.id}
config={config}
value={values[config.binding.param]}
onChange={(val) => onChange(config.binding.path, val)}
/>
);
})}
</div>
);
}

整个渲染过程是数据驱动的:配置数组变了,组件树自动变;值变了,只有绑定了该值的卡片重渲染。
这和手写一堆 if-else 渲染不同组件的方式有本质区别。


五、AI回填:让卡片"听懂"自然语言

这是这套系统和普通低代码表单最大的区别。AI从用户输入中提取出结构化实体后,需要一个确定性的机制把实体映射到具体卡片。流程如下:

  1. 大模型返回提取结果:{ entityType: "region", value: "华东区", confidence: 0.92 }
  2. 匹配引擎遍历所有卡片的 behavior.aiMapping,找到 entityType === "region" 的卡片;
  3. 如果有多张同类型卡片(比如"发货地"和"收货地"都是region),按confidence + 上下文位置排序取最优;
  4. 调用该卡片的 deserialize(value) 方法将"华东区"转为内部编码 ["CN_EAST"],写入对应binding.path;
  5. 触发依赖该卡片的其他卡片重新resolve(比如地域变了,可选门店列表要刷新)。

关键设计:AI提取和卡片回填是两个独立步骤,中间有明确的接口契约(entityType + value)。
这意味着换一个大模型、换一种提取算法,卡片系统完全不用改。
反过来,新增一种卡片类型,只要声明了aiMapping,现有AI管线就能自动支持。


六、复用实录:3个项目是怎么"抄作业"的

这套系统上线后,先后被以下项目复用:

项目 复用方式 适配工作量
AI商品分析助手 全量复用引擎+内置卡片,新增5种商品域卡片 2人日(写新卡片+配置)
运营活动效果看板 复用引擎,替换全部卡片为活动域配置 3人日(配置为主,1个自定义卡片)
客服工单智能检索 复用引擎+aiMapping机制,卡片从零配置 1.5人日(纯配置,无新组件)

复用的前提不是"代码写得通用",而是抽象层次恰好卡在"查询条件选择"这个业务概念上
如果再往上抽成"万能表单引擎",就会陷入过度设计;如果往下沉成"一堆UI组件",又失去了配置化的意义。


七、踩坑与反思:配置化不是银弹

  1. 别把所有东西都配置化:我们最初尝试把卡片的样式、动画、校验提示文案都放进JSON,结果配置文件膨胀到难以维护。后来收敛为:只有影响"数据语义"的部分才配置,纯展示层的差异用CSS变量和主题token解决
  2. dependencies别做成编程语言:早期有人在dependency rule里写嵌套三元表达式,可读性灾难。我们限制了rule的表达力(只支持简单的模板插值和预定义操作符),复杂联动改为在自定义卡片组件内处理。**配置化的边界是"80%的场景用配置搞定,剩下20%允许逃逸到代码"**。
  3. 配置要有版本管理:JSON配置改了之后回滚困难。我们把配置纳入Git管理,配合CI做Schema校验+快照测试,每次发布前自动Diff配置变更并生成预览链接。
  4. aiMapping的examples要持续补充:AI识别准确率高度依赖样例质量。我们建了一个反馈闭环:用户手动修正AI回填结果时,修正记录自动追加到对应卡片的examples里。三个月后识别准确率从78%提升到94%。
  5. 新人上手成本真实存在:虽然配置比代码简单,但20+种卡片类型+联动规则仍需学习。我们做了一个可视化配置编辑器(拖拽排序、实时预览、Schema提示),把纯JSON编辑的体验拉到了低代码平台水平。工具链的完善度决定了配置化方案能不能真正落地

八、写在最后

回过头看,这套卡片系统最核心的价值不是"少写了多少代码",而是把"新增一种查询条件"这件事的决策权从前端工程师转移到了产品和运营手里。他们可以自己改配置、自己验证、自己上线,前端只需要在引入全新交互形态时才介入。

这才是"零代码接入"的真正含义:不是消灭代码,而是让代码出现在它该出现的地方,让不该写代码的人不必写代码

如果你也在做类似的配置化系统,希望这篇能帮你少走一些弯路。
抽象的粒度、配置与代码的边界、工具链的配套——这三件事想清楚了,配置化才能真正产生复利,而不是变成另一个需要维护的技术债。


这个系列还剩最后一篇:异步执行+独立会话管理的工程细节。感兴趣的话收藏网址,更新不迷路。

评论