@qfei-design/make-app-filter
v1.1.2
Published
Headless Make advanced filter model, React panel, and optional host component adapters.
Keywords
Readme
@qfei-design/make-app-filter
Make advanced filter package with a headless core, a host-controlled React
panel, optional component-library adapters, and Make Data API compatible
filter.expression output.
The v1 interaction and internal panel styling follow the current ExpensePoc advanced-filter baseline. The package does not render Popover, Modal, Drawer, or any scroll container; host apps decide where to mount the panel and how wide, tall, or scrollable that container is. It also does not implement table-header filter UI. Header filter UI is a host integration that may call the package controller.
For Make App record-list pages, filtering is normally delivered as one integrated
feature. Make App filtering should wire the toolbar advanced filter and host-owned CanvasTable header linkage together: this package provides the advanced filter model, panel, controller, and openWithField; the host provides the CanvasTable header UI/menu and calls the same controller.
Install
pnpm add @qfei-design/make-app-filter@^1.0.0npm install @qfei-design/make-app-filter@^1.0.0yarn add @qfei-design/make-app-filter@^1.0.01.0.0 is the minimum runtime baseline for Lookup filtering with source-field CEL expressions. The package publishes this README,
PUBLIC_API.md, and the root-level machine-readable integration metadata.
unsupportedExpression, AdvancedFilterPanel.disabled, AdvancedFilterPanel.layout,
AdvancedFilterPanelLayout, minConditionCount, maxGroupDepth,
autoCreateDefaultCondition, canRemoveNodeFromGroup, and
RemoveNodeFromGroupOptions are newer public APIs.
v1.0.0 and v1.0.1 do not contain unsupportedExpression, AdvancedFilterPanel.disabled, AdvancedFilterPanel.layout, AdvancedFilterPanelLayout, minConditionCount, maxGroupDepth, autoCreateDefaultCondition, canRemoveNodeFromGroup, or RemoveNodeFromGroupOptions.
Released v1.0.0 through v1.0.3 do not contain AdvancedFilterPanelLayout "mobile" or its compact size="middle" control contract. Upgrade to the release whose React declaration includes the "mobile" literal before using that layout.
Before using any of these APIs, ensure the installed root declaration exports
CompileListFilterOptions, canRemoveNodeFromGroup, and RemoveNodeFromGroupOptions,
and the React declaration includes disabled, layout, minConditionCount,
maxGroupDepth, and autoCreateDefaultCondition and exports
AdvancedFilterPanelLayout; a
lockfile pinned to either older version must be upgraded through the normal
release channel.
Public Entrypoints
@qfei-design/make-app-filter: headless core helpers, operators, validation, summary, CEL compile/parse, andcompileListFilter.@qfei-design/make-app-filter/react:AdvancedFilterPanel,useAdvancedFilterController, candidate-source types, and component contracts.@qfei-design/make-app-filter/adapters/antd:createAntdFilterComponentsfor Ant Design hosts.@qfei-design/make-app-filter/styles.css: internal panel styles. Import once in the host UI entry.
Do not import from src, dist, or package-internal files.
Quick Start: AntD Popover Host
The package renders only the advanced filter panel. The host owns the toolbar button, Popover, width, max height, scroll behavior, applied state, and record reload.
import { Button, Popover } from "antd";
import { useLayoutEffect, useRef, useState } from "react";
import { cloneFilterGroup } from "@qfei-design/make-app-filter";
import {
AdvancedFilterPanel,
useAdvancedFilterController,
} from "@qfei-design/make-app-filter/react";
import { createAntdFilterComponents } from "@qfei-design/make-app-filter/adapters/antd";
import "@qfei-design/make-app-filter/styles.css";
const filterComponents = createAntdFilterComponents();
export function ContextualAdvancedFilterPopover({ resetKey, ...props }) {
return <AdvancedFilterPopover key={resetKey} {...props} />;
}
function AdvancedFilterPopover({
appliedGroup,
candidateSources,
fields,
onApplied,
onPersist,
}) {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const activeRef = useRef(true);
const saveRequestRef = useRef<symbol | null>(null);
const controller = useAdvancedFilterController({
fields,
value: appliedGroup,
});
useLayoutEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
saveRequestRef.current = null;
};
}, []);
function handleOpenChange(nextOpen) {
if (saveRequestRef.current) return;
if (nextOpen) {
controller.beginDraft();
setSaveError("");
setOpen(true);
return;
}
controller.resetDraft();
setOpen(false);
}
function isCurrentSave(requestId) {
return activeRef.current && saveRequestRef.current === requestId;
}
function finishSave(requestId) {
if (!isCurrentSave(requestId)) return;
saveRequestRef.current = null;
setSaving(false);
}
async function handleConfirm() {
if (saveRequestRef.current) return;
const validation = controller.validate();
if (!validation.valid) return;
const nextValue = cloneFilterGroup(controller.draftValue);
const requestId = Symbol("filter-preset-save");
saveRequestRef.current = requestId;
setSaving(true);
setSaveError("");
try {
await onPersist(nextValue);
} catch {
if (isCurrentSave(requestId)) {
setSaveError("筛选保存失败,请重试");
}
finishSave(requestId);
return;
}
if (!isCurrentSave(requestId)) return;
onApplied(nextValue);
controller.resetDraft();
setOpen(false);
finishSave(requestId);
}
return (
<Popover
open={open}
trigger="click"
placement="bottomLeft"
content={
<div className="advanced-filter-popover">
<AdvancedFilterPanel
candidateSources={candidateSources}
components={filterComponents}
disabled={saving}
fields={fields}
value={controller.draftValue}
validationErrors={controller.validationErrors}
onChange={controller.setDraftValue}
onClear={controller.clearDraft}
onConfirm={() => void handleConfirm()}
/>
{saveError ? <div role="alert">{saveError}</div> : null}
</div>
}
onOpenChange={handleOpenChange}
>
<Button disabled={saving}>筛选</Button>
</Popover>
);
}The host sizes the outer container and keeps only the package body scrollable:
.advanced-filter-popover {
width: 724px;
max-height: min(560px, calc(100vh - 160px));
overflow: hidden;
}
.advanced-filter-popover .advanced-filter__body {
overflow-y: auto;
}onPersist only saves the preset. onApplied updates the applied filter after
that save succeeds; the record query should react to applied state in its own
effect or query key. resetDraft() ends the successful package draft only after
the persisted snapshot has become the applied value. Do not reload records
inside the persistence callback. Pass disabled={saving} to freeze every draft
mutation while persistence is pending; otherwise a later user edit could be
discarded when the saved snapshot is applied.
resetKey is the host's committed, monotonic object-context generation. The
keyed wrapper remounts the controller only after that context transition commits,
and the layout-effect cleanup invalidates the old async handler on context change
or unmount. Do not mutate request-generation, saving, or context refs during
render. Comparing only the current key cannot reject an old A -> B -> A result.
条件组保存与刷新回显(宿主必读)
本次修复使用合法 CEL 的双层括号保留显式子条件组:
amount > 100 && ((title.contains("项目")))
amount > 100 || ((title.contains("项目") && amount > 200))双层括号是编辑器分组边界;“为空、包含任一项”等操作符内部的普通括号仍属于一个条件。不要手写括号标记,统一调用包内编译与解析 API。
宿主按以下链路对接,不需要在后端增加条件树字段:
- 确认草稿后使用
compileAdvancedFilterToCel(draft, fields)生成仅含高级筛选的表达式。不要把顶部搜索词合入需要保存的 Preset。 - 表达式非空时保存
{ filter: { expression } },清空时保存{ filter: null }。服务端必须原样保存并返回表达式,允许清理首尾空白,但不能重排、简化或删除括号。 - 保存成功后更新已应用状态;失败时保留原状态和草稿。异步结果继续校验对象和请求代次,防止旧的 A → B → A 响应覆盖新状态。
- 刷新页面或再次进入时,调用
parseCelToAdvancedFilter(savedExpression, fields),将成功返回的filter直接传给 controller /AdvancedFilterPanel,不要在宿主再做同关系组展开。 - 查询记录时再调用
compileListFilter({ advancedFilter, fields, searchText })合并当前搜索词;它的输出用于查询,不替代第 1 步的 Preset 表达式。
const validation = validateAdvancedFilter(draft, fields);
if (!validation.valid) {
// 显示 validation.structureError 和 conditionErrors,保留草稿,不发起保存。
return;
}
// 包括搜索合并在内的编译要放在宿主现有错误处理内;成功后才能继续保存/查询。
const expression = compileAdvancedFilterToCel(draft, fields);
const presetUpdate = { filter: expression ? { expression } : null };
// 先用宿主已有接口保存 presetUpdate,成功后应用 draft。
const restored = parseCelToAdvancedFilter(savedExpression ?? "", fields);
if (restored.status === "supported") {
// restored.filter 是回显值;刷新后也不需要靠内存快照恢复分组。
const panelValue = restored.filter;
}包的公共函数签名、React props 和样式入口不变。宿主需要安装包含本次修复的发布版本、同步锁文件并重新构建部署;已发布的 1.1.1 不包含此修复。已有内存快照可保留,但不能代替上述刷新回显链路。
本次不迁移或猜测历史表达式的分组意图。空组不保存;只有一个子节点时关系没有运算区别,恢复为默认 and;组/条件 ID 会重新生成,宿主不能依赖它们跨刷新保持一致。查询结果仍受服务端 CEL 子集约束,本功能不会自动将任意嵌套 OR 展开成 DNF。
验收至少覆盖:确认后重开、浏览器刷新、离开后返回、单条件组、同关系多组、多层组、复合操作符、清空、保存失败和旧请求晚返回。
Make 逻辑组合限制与搜索合并
后端支持外层“或”、组内“且”:A || (B && C)、(A && B) || (C && D)。不支持 A && (B || C);条件组容器本身可以保留,同关系组、空组和单子节点包装不算新增逻辑运算。
- 编辑阶段就限制能力:关系下拉框只显示切换后受支持的值;根组及子组的添加操作若会产生不支持组合就禁用;表头
openWithField也不能绕过限制。只有切换为受支持关系后,相关添加操作才恢复。maxGroupDepth等参数不能放开后端逻辑限制。 validateAdvancedFilter对不支持的组合返回valid: false和structureError。controller 的confirm()不触发onChange;所有面板布局立即显示提示并禁用确认,允许用户继续修改草稿。compileAdvancedFilterToCel和compileListFilter({ advancedFilter })遇到此类树会抛出错误,不能将它当成空筛选或静默丢弃条件。无面板或自定义确认按钮的宿主也必须先校验,并在现有错误处理内编译。- 仅查询阶段,
compileListFilter将搜索与高级筛选等价分配为 DNF。例如高级筛选A || B、搜索S1 || S2,输出(A && S1) || (A && S2) || (B && S1) || (B && S2)。字段操作符内部的空值检查等复合表达式作为完整条件保留,不改变其语义。 - 自动合并最多生成 256 个分支,超限抛错;宿主显示错误并保留当前已应用查询,不能捕获后改成无筛选请求。搜索输入更新时也要处理该错误;不要为失败的编译发送记录请求。在保存草稿前,可用当前搜索词调用
compileListFilter预检,预检通过再保存 Preset。 unsupportedExpression仍是服务端已接受的未知表达式透传通道。合并搜索只拆外层 OR,不解释未知谓词、不修复原文内部不受支持的嵌套逻辑;没有搜索时继续原样透传。宿主不能用此通道绕过新草稿校验。
否定操作符按后端标准写法编译:not_in 为 !(field in [...]),has_none 为 !([...].exists(x, x in field)),不额外拼接“或为空”。NULL 是否命中遵循后端语义;如业务还需要包含空值,显式使用外层“或”添加“为空”条件。多选、多用户、多部门不支持 neq,校验不再接受该操作符,不恢复旧的集合不等式扩展。
新建草稿仅提供当前后端支持的组合,不新增历史条件树迁移或兼容策略。以上限制与合并统一由包负责。宿主升级包含修复的新版本后,沿用 controller 和面板即可获得拦截;自定义保存/查询链路按上述约定接入错误处理,无需自行实现 DNF 转换。新查询表达式只用于请求,不覆盖用于条件组回显的 Preset 原文。
Quick Start: Compile Service Filter
Use compileListFilter to merge toolbar search with either an editable filter
group or a preserved unsupported expression.
import {
compileListFilter,
parseCelToAdvancedFilter,
} from "@qfei-design/make-app-filter";
const parsed = parseCelToAdvancedFilter(savedExpression, fields);
const filter =
parsed.status === "unsupported"
? compileListFilter({
fields,
searchText,
unsupportedExpression: parsed.expression,
})
: compileListFilter({
advancedFilter: parsed.filter,
fields,
searchText,
});
const requestPayload = filter ? { filter } : {};When compileListFilter returns undefined, omit filter. Do not send
filter: [], filter: {}, or { expression: "" }.
Keep the parse result as a discriminated applied state. When it is unsupported,
pass its raw expression back through compileListFilter.unsupportedExpression;
the package preserves it and owns the safe AND merge with search. Do not
concatenate CEL in the host. Keep the toolbar visibly active and show a
compatibility warning; only replace or clear the expression after a new
confirmation persists successfully.
DateRange Filtering
Pass DateRange fields as normal Make field metadata:
{ key: "period", name: "报销周期", type: "Make.Field.DateRange" }The package keeps the original field as a whole-range condition and also adds two generated field options in the panel:
报销周期.开始时间->period['begin']报销周期.结束时间->period['end']
Whole DateRange empty checks compile to period == null and period != null.
Whole DateRange equality keeps the backend range-boundary contract:
period['begin'] == "2026-07-01" && period['end'] == "2026-07-31".
The generated start/end options use the single-date operator matrix and value
editor. Hosts should not create these options manually or use
period['begin'] / period['end'] as raw fieldKey values; the package owns
the generated collision-safe keys and CEL rendering.
Package provides
- Filter IR helpers.
- Make field support and operator matrix.
- Default values, validation, and active-condition summary.
- CEL expression compile and parse.
AdvancedFilterPanelfor React hosts.AdvancedFilterPaneloptionallayout="stacked"presentation for narrow side panels. It keeps the same filter IR, interactions, validation, and CEL output while placing the value editor beneath the field and operator controls.AdvancedFilterPaneloptionallayout="mobile"presentation for a host-owned mobile sheet. The host may use a bottom or full-screen presentation. It keeps the same filter IR, interactions, validation, and CEL output while rendering touch-friendly condition cards and defaulting the panel title and confirm action to设置筛选条件/完成.- Complete default condition rows with the first filterable field and first supported operator selected. Field dropdowns are not auto-opened by the package.
- Optional whole-tree minimum condition retention through
AdvancedFilterPanel minConditionCount={1}. It disables destructive actions that would leave no conditions and removes nested groups emptied by an allowed condition deletion. - Optional
maxGroupDepthfor explicitly limiting condition-group levels while keeping the legacy depth behavior when it is omitted. - Optional
autoCreateDefaultCondition={false}for hosts that need an empty filter until the user adds the first condition or group. useAdvancedFilterControllerfor draft, reset, clear, confirm, validation, andopenWithFieldfor host-owned field entry points.candidateSourcesprops for remote user and department values.createAntdFilterComponentsfor Ant Design controls.- ExpensePoc-derived internal panel styling through
styles.css.
Narrow panel layout
The default layout="default" preserves the existing compact inline layout.
When a host renders the panel in a constrained side area, pass layout="stacked"
to keep the field and operator controls on one row and move the value control
to the next row. This is a presentation-only option; it does not alter the
filter group structure, nested AND/OR behavior, validation, or generated CEL.
<AdvancedFilterPanel
components={filterComponents}
fields={fields}
layout="stacked"
value={controller.draftValue}
onChange={controller.setDraftValue}
/>Mobile sheet layout
For a mobile record list, keep search and the filter trigger in the host page
and mount the panel inside a host-owned mobile sheet. Use a bottom or full-screen
presentation according to the available height and filter complexity. Pass
layout="mobile" to render condition cards, grouped labels, a panel footer,
and a 完成 confirmation action. This is presentation-only: the host still
controls the sheet, mask, close action, scroll container, safe-area handling,
persistence, and record reload. When the host scrolls the entire Sheet, the
footer follows the host sheet's scroll behavior. To keep actions visible while
conditions scroll, the host must constrain the Sheet and make only
.advanced-filter__body scrollable, as in the Popover example above.
<MobileSheet presentation="bottom" open={open} onClose={closeAndDiscardDraft}>
<AdvancedFilterPanel
components={filterComponents}
fields={fields}
layout="mobile"
value={controller.draftValue}
onChange={controller.setDraftValue}
onConfirm={handlePersistThenApply}
/>
</MobileSheet>Every layout sends size="middle" to injected controls by default. The built-in
styles use the standard 32px middle height for fields, operators, values,
relation controls, secondary actions, and confirmation. The mobile layout also
adds its scoped mobile control class. A custom component adapter must honor the
same 32px visual control contract. The built-in Ant
Design adapter keeps mobile multi-select values to
one ellipsized tag plus a remaining-count hint. Custom adapters must forward the
provided className and render field, operator, and value text on one line with
ellipsis instead of wrapping or overflowing its control;
the package does not import or override a host design system.
Released v1.0.0 through v1.0.3 do not contain AdvancedFilterPanelLayout "mobile" or its compact size="middle" control contract.
Group Depth and Empty Drafts
maxGroupDepth counts condition-group levels below the root group. Pass 0
to hide “添加条件组”, 1 to allow only one group level, or a larger integer to
allow further nested levels. Omit it to preserve the existing
maxDepth and allowNestedGroupAdd behavior.
autoCreateDefaultCondition defaults to true. Set it to false on both the
panel and controller to render an empty filter with only available add actions.
When minConditionCount is greater than 0, the minimum takes precedence and
the package creates enough draft conditions to satisfy it.
const controller = useAdvancedFilterController({
autoCreateDefaultCondition: false,
fields,
value,
});
<AdvancedFilterPanel
autoCreateDefaultCondition={false}
components={filterComponents}
fields={fields}
maxGroupDepth={3}
value={controller.draftValue}
onChange={controller.setDraftValue}
/>Minimum Retained Conditions
Set minConditionCount={1} when the host requires the filter to keep at least
one condition across its whole group tree. The panel disables a condition or
group delete control when that action would violate the limit, disables the
clear action, and removes a nested group after an allowed deletion leaves it
empty. The package also materializes the minimum in the controlled panel value.
When using useAdvancedFilterController, pass the same option to the controller
so direct clearDraft, setDraftValue, and confirm calls preserve the rule.
This does not require every group to contain a condition. If the host currently
cannot provide enough filterable fields to satisfy the minimum, the panel
disables confirmation and the controller returns an invalid confirmation result
without emitting an empty filter; once filterable fields are available, the
existing minimum is materialized normally.
const controller = useAdvancedFilterController({
fields,
minConditionCount: 1,
value,
});
<AdvancedFilterPanel
components={filterComponents}
fields={fields}
minConditionCount={1}
value={controller.draftValue}
onChange={controller.setDraftValue}
/>Host app provides
- Normalized Make field metadata.
- Resolved Lookup relation and target field metadata when Lookup fields should be filterable.
- Applied advanced-filter state.
- Toolbar trigger placement.
- Popover, Modal, Drawer, or another mounting container.
- Container width, max height, and scrolling.
- User and department candidate APIs.
- Service request adapter and record reload timing.
- Async persistence request generation and reset behavior for object changes.
- Visible applied state for unsupported saved CEL while backend filtering remains active.
- CanvasTable header filter UI, menu behavior, and
openWithFieldlinkage. - Optional URL/deep-link encoding and parsing policy.
Lookup Host Schema Resolution
Lookup filtering requires the host to resolve the complete runtime schema before passing fields to the package. Do not read copied DSL files or infer the target type from record values. Start from the current Entity, its Lookup field, all runtime relations, and all runtime entities.
For each Make.Field.Lookup source field:
- Read
relationKeyandtargetFieldKeyfrom the source field or itsproperties. - Find the relation and select the opposite Entity from
from/to. - Find
targetFieldKeyin that target Entity. - Pass the source Lookup key plus resolved target field metadata to this package.
This reference resolver accepts normalized runtime schema objects using
entityKey, fieldKey, fieldType, relationKey, from, and to:
const text = (value) =>
typeof value === "string" ? value.trim() : "";
const normalizeFilterField = (field) => ({
key: text(field.fieldKey ?? field.key),
name: text(field.name) || text(field.fieldKey ?? field.key),
type: text(field.fieldType ?? field.type),
properties: field.properties ?? {},
meta: field.meta ?? {},
disabled: Boolean(field.disabled),
});
function resolveLookupFilterField(
sourceField,
sourceEntityKey,
schema,
) {
const normalized = normalizeFilterField(sourceField);
if (normalized.type !== "Make.Field.Lookup") return normalized;
const relationKey = text(
sourceField.relationKey ?? sourceField.properties?.relationKey,
);
const targetFieldKey = text(
sourceField.targetFieldKey ?? sourceField.properties?.targetFieldKey,
);
const relation = (schema.relations ?? []).find(
(item) => text(item.relationKey ?? item.key) === relationKey,
);
const fromEntityKey = text(relation?.from?.entityKey);
const toEntityKey = text(relation?.to?.entityKey);
const targetEntityKey =
fromEntityKey === sourceEntityKey
? toEntityKey
: toEntityKey === sourceEntityKey
? fromEntityKey
: "";
const entities = schema.objects ?? schema.entities ?? [];
const targetEntity = entities.find(
(item) => text(item.entityKey ?? item.key) === targetEntityKey,
);
const targetField = targetEntity?.fields?.find(
(item) => text(item.fieldKey ?? item.key) === targetFieldKey,
);
const targetFieldType = text(targetField?.fieldType ?? targetField?.type);
if (
!relationKey ||
!targetFieldKey ||
!targetField ||
!targetFieldType ||
targetFieldType === "Make.Field.Lookup"
) {
return normalized;
}
return {
...normalized,
lookup: {
relationKey,
targetField: {
fieldKey: targetFieldKey,
fieldType: targetFieldType,
name: targetField.name,
properties: targetField.properties,
meta: targetField.meta,
disabled: Boolean(targetField.disabled),
},
},
};
}
const fields = currentEntity.fields.map((field) =>
resolveLookupFilterField(field, currentEntity.entityKey, schema),
);If the relation, target Entity, target field, or target type cannot be resolved,
leave the Lookup field without lookup metadata. The package treats it as
unsupported and hides it from field selectors and header filter actions.
Pass the same resolved fields array to the panel, compiler, validation, search,
and parser:
import {
compileListFilter,
parseCelToAdvancedFilter,
} from "@qfei-design/make-app-filter";
const parsed = parseCelToAdvancedFilter(savedExpression, fields);
const filter =
parsed.status === "unsupported"
? compileListFilter({
fields,
searchText,
unsupportedExpression: parsed.expression,
})
: compileListFilter({
advancedFilter: parsed.filter,
fields,
searchText,
});
const requestPayload = filter ? { filter } : {};
controller.openWithField("mediaNameLookup");The panel and Filter IR store the source Lookup key. CEL also uses that source
key, for example mediaNameLookup.contains("腾讯"); never emit
mediaName.contains("腾讯") or a cross-object path. The resolved target field
type only controls operators, value normalization, validation, and value editor
options.
Host-Owned CanvasTable Header Linkage
Header filter UI is a host integration, not a package feature. For CanvasTable
header menus, keep the table integration in the host and call the package
controller when the user chooses 按该字段筛选:
controller.openWithField(fieldKey);The package does not implement CanvasTable header filter UI.
The package does not implement CanvasTable suffixRender.
The package also does not implement header menu placement or scroll cleanup. The
header action should only open the same toolbar advanced filter draft; it must
not submit immediately or reload records before 确认.
Candidate Sources
User and department filter values should use identities, not display labels.
Pass remote candidates through candidateSources:
const candidateSources = {
users: { options: userOptions, loading: usersLoading, onSearch: searchUsers },
departments: {
options: departmentOptions,
loading: departmentsLoading,
onSearch: searchDepartments,
},
};Do not source production user or department options from field schema options, current table rows, local demo arrays, or display labels.
For Make.Field.SingleUser, the package prepends a built-in 当前登录人 option
before remote user candidates. Selecting it stores a structured system-variable
value and compiles to _currentUser, for example approver == _currentUser.
Host apps should not add this option to candidateSources.users.options; those
options should remain real user identities only. Make.Field.MultiUser does not
show this built-in option.
Out Of Scope
This package does not render Popover, Modal, Drawer, or scroll containers. It
does not implement Service routes, authentication, deployment, saved views,
CanvasTable header filter UI, or CanvasTable suffixRender. The package does not
filter loaded table rows locally; Make record lists must send filter.expression
to the Service/backend.
Published Documentation
- Human integration guide:
README.md - Public API contract:
PUBLIC_API.md - Package metadata and read order for agents:
package.ai.json - Integration recipes:
recipes.json - Capability catalog:
capabilities.json
Development
pnpm test
pnpm typecheck
pnpm build值校验与编译失败约定(宿主必读)
validateAdvancedFilter、isConditionComplete 和编译器复用同一套字段值校验。自定义编辑器也必须遵守:
- 数值必须为有限的 number,不接受数字字符串、NaN 或 Infinity;文本必须为非空字符串。
- 日期使用有效的
YYYY-MM-DD;日期时间使用YYYY-MM-DD HH:mm:ss,包括日期时间区间的两个端点,不接受仅日期或 ISO 时区格式。区间必须恰有两个有效端点,开始不晚于结束。 - 多值操作符使用非空数组,每个元素符合字段类型;不接受稀疏数组、空元素、嵌套数组或任意对象。单选值支持字符串、有限数字和布尔值;用户/部门 ID 支持非空字符串和有限数字;结构化当前用户变量仅适用于已支持的单用户操作符。
- 附件数量必须为非负安全整数,附件名称包含/不包含使用非空字符串。“为空/不为空”不读取遗留 value。
真正的空条件树仍可省略;只要含有未知字段、缺失字段元数据、不支持的操作符、无效值或不支持的结构,compileAdvancedFilterToCel / compileListFilter({ advancedFilter }) 就整体抛错,不再静默删除该条件后生成剩余查询。宿主先校验,再在现有异常处理内编译;失败时保留已应用状态并停止请求,禁止将编译失败当成空筛选发送全量查询。标准面板与控制器已复用此校验,自定义接入遵循同一合同,无需复制包内规则。
公开字面量工具 renderCelLiteral 对非有限数字和不支持的对象抛错,不再替换成 0 或 null;显式 null/undefined 仍输出 null。此轮没有增加公共导出、依赖或宿主私有逻辑,版本号未变;已发布的 1.1.1 不包含这些尚未发布的修改。
CEL 值的无损回显
数组中包含 null、嵌套数组、对象或无法表示的元素时,解析器整体返回 unsupported 并保留原始表达式,绝不删除部分元素后返回可编辑条件。宿主继续遵守已有 unsupportedExpression 合同,无需实现自己的清洗或数值转换。
CEL 整数字面量只有在能精确转换为 JavaScript number 时才进入编辑器;无法精确表示的大整数返回 unsupported,不转换为字符串或四舍五入。包内 number 超出安全整数范围时编译为指数浮点格式(例如 1e+16),使重新解析后仍保持相同数值与 number 类型;负数字面量同样支持往返。该规则不表示后端各数值字段允许任意大小,业务仍遵守字段范围。公共类型、导出及依赖未变。
独立条件保持原结构
同字段的 >= 与 <= 保持两条独立比较条件,不自动合并为区间操作符;原生日期区间仍通过 .isWithin(...) 等专用表达式回显。“为空,或不属于列表”(以及显式 NULL 与集合否定的 OR)保留两条条件,不再套用旧的空值包装折叠规则。根层和显式条件组采用同一合同,宿主直接使用包解析结果,无需自行拆分条件;不新增历史数据迁移。
复合条件括号协议
解析逻辑链时,仅将带一层完整外括号的谓词识别为编译器生成的复合条件;双层外括号继续标识显式条件组。裸逻辑链保留各独立条件,包括日期区间虚拟开始/结束字段的比较、为空/不为空条件,不因 AST 恰好匹配区间模式而合并。日期区间本身的等于操作仍由编译器输出单层括号,专用方法操作不变。
宿主必须保留编译器输出的表达式原文和括号,读取后直接调用包解析器;不要剥离括号或自行推断/合并字段条件。本轮未引入历史表达式迁移,也未修改公共导出、依赖或编译输出。
