chore: 初始化 trans-form 项目脚手架
Node.js Build / build (push) Has been skipped

- 基于 grill-me Q1-Q61 决策
- Vitest 测试与 CI workflow
- middleware: parseFilters 核心实现
- component: Vue3 TransForm 基础组件 + Storybook

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 00:47:58 +00:00
commit ccd16bf956
14 changed files with 2126 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
name: Node.js Build
on:
push:
branches: ["main", "master"]
workflow_dispatch:
jobs:
build:
if: contains(github.event.head_commit.message, 'chore') && contains(github.event.head_commit.message, '版本') && contains(github.event.head_commit.message, '更新')
runs-on: docker
steps:
- name: Checkout code
env:
GIT_AUTH: ${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}
run: |
git init
git remote add origin http://gitea:3000/windychen/trans-form-middleware.git
AUTH_B64=$(printf '%s' "$GIT_AUTH" | base64 | tr -d '\n')
git -c http.extraHeader="Authorization: Basic ${AUTH_B64}" fetch --depth 1 origin ${{ github.sha }}
git checkout ${{ github.sha }}
- name: Setup npm for Verdaccio
run: |
npm config set registry http://verdaccio:4873/
npm config set //verdaccio:4873/:_authToken ${{ secrets.NPM_TOKEN }}
- name: Install dependencies
run: npm i --registry http://verdaccio:4873/
- name: Run tests
run: npm test
- name: Publish to Verdaccio
id: publish-verdaccio
run: |
VERSION=$(node -p "require('./package.json').version")
PKG=$(node -p "require('./package.json').name.replace('@windychen/','')")
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://verdaccio:4873/@windychen%2f${PKG}/$VERSION || echo 404)
if [ "$HTTP_CODE" = "200" ]; then
echo "Verdaccio: version $VERSION already exists, skipping."
echo "verdaccio_result=skipped|版本 ${VERSION} 已存在" >> $GITHUB_OUTPUT
else
if npm publish --access public 2>&1; then
echo "verdaccio_result=published|已发布 v${VERSION}" >> $GITHUB_OUTPUT
else
echo "verdaccio_result=failed|发布失败" >> $GITHUB_OUTPUT
exit 1
fi
fi
- name: Publish to Gitea
id: publish-gitea
env:
GITEA_NPM_AUTH: ${{ secrets.CI_USER }}:${{ secrets.NPM_GITEA_TOKEN }}
run: |
AUTH_B64=$(printf '%s' "$GITEA_NPM_AUTH" | base64 | tr -d '\n')
npm config set //gitea:3000/api/packages/windychen/npm/:_auth "$AUTH_B64"
VERSION=$(node -p "require('./package.json').version")
PKG=$(node -p "require('./package.json').name")
EXISTS=$(curl -s "http://gitea:3000/api/v1/packages/windychen?q=trans-form-middleware" -u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" | grep -c "\"version\":\"$VERSION\"" || true)
if [ "$EXISTS" -gt 0 ]; then
echo "Gitea: version $VERSION already exists, skipping."
echo "gitea_result=skipped|版本 ${VERSION} 已存在" >> $GITHUB_OUTPUT
else
if npm publish --registry http://gitea:3000/api/packages/windychen/npm/ 2>&1; then
echo "gitea_result=published|已发布 v${VERSION}" >> $GITHUB_OUTPUT
else
echo "gitea_result=failed|发布失败" >> $GITHUB_OUTPUT
exit 1
fi
fi
- name: Create Gitea Release
if: always() && steps.publish-gitea.outcome == 'success'
run: |
VERSION=$(node -p "require('./package.json').version")
EXISTS=$(curl -s "http://gitea:3000/api/v1/repos/windychen/trans-form-middleware/releases/tags/v${VERSION}" -u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" | grep -c "\"tag_name\"" || true)
if [ "$EXISTS" = "0" ]; then
curl -s -X POST \
"http://gitea:3000/api/v1/repos/windychen/trans-form-middleware/releases" \
-u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"## trans-form-middleware v${VERSION}\",\"target_commitish\":\"main\"}"
else
echo "Release v${VERSION} already exists, skipping."
fi
- name: Notify DingTalk
if: always()
run: |
STATUS="${{ job.status }}"
VERSION=$(node -p "require('./package.json').version")
parse_result() {
local raw="$1"
local target="$2"
local icon="${raw%%|*}"
local detail="${raw#*|}"
case "$icon" in
published) echo "📦 ${target}: ${detail}" ;;
skipped) echo "⏭️ ${target}: ${detail}" ;;
failed) echo "❌ ${target}: ${detail}" ;;
*) echo "⚠️ ${target}: ${detail}" ;;
esac
}
VERD_RESULT="${{ steps.publish-verdaccio.outputs.verdaccio_result }}"
GITEA_RESULT="${{ steps.publish-gitea.outputs.gitea_result }}"
if [ -n "$VERD_RESULT" ]; then
VERD_LINE=$(parse_result "$VERD_RESULT" "Verdaccio")
else
VERD_LINE="⚠️ Verdaccio: 未执行"
fi
if [ -n "$GITEA_RESULT" ]; then
GITEA_LINE=$(parse_result "$GITEA_RESULT" "Gitea")
else
GITEA_LINE="⚠️ Gitea: 未执行"
fi
if [ "$STATUS" = "success" ]; then
TITLE="✅ trans-form-middleware v${VERSION} 构建完成 chen-fnos"
else
TITLE="❌ trans-form-middleware v${VERSION} 构建失败 chen-fnos"
fi
curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=${{ secrets.DINGDING_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"markdown\",\"markdown\":{\"title\":\"${TITLE}\",\"text\":\"## ${TITLE}\n\n${VERD_LINE}\n${GITEA_LINE}\n\n- 仓库: windychen/trans-form-middleware\n- 分支: ${{ github.ref_name }}\n- 提交: ${{ github.sha }}\"}}"
+4
View File
@@ -0,0 +1,4 @@
node_modules/
coverage/
*.log
.DS_Store
+49
View File
@@ -0,0 +1,49 @@
# trans-form-middleware — Design (Q1–Q61)
Status: **implementation scaffold** (v0.1.0)
Companion package: `@windychen/trans-form-component` (Vue 3 dynamic query form).
## Role
Receive component filter output → structured `{ field, query, op, value, logic?, values?, empty? }[]`. **No SQL generation.**
## Key decisions
| # | Decision |
|---|----------|
| 1–2 | Node.js pure JS; structured filters only |
| 8 | Dual fields: `query` (code) + `op` (symbol) |
| 14 | `mode`: pass-through (default) / flat / tree |
| 24–25 | Zero deps; ESM only; no bundling (`src/index.js`) |
| 38 | `empty: true` preserved by default |
| 39 | Built-in `DEFAULT_OPERATOR_MAP`, merge via `operatorMap` |
| 41 | Multi-function exports: `parseFilters`, `toTree`, `mapOperators`, … |
| 44 | `strict` default false |
| 46 | Multi-value: `value` (string) + `values` (parsed array) |
| 49–50 | `allowedFields` required; `unknownField: 'error'` default |
| 60 | Component emits all state flags; middleware skips `disabled` by default |
| 61 | Also skips `hidden`; `skipEmpty` option controls empty items |
## Output example
```json
[
{
"field": "tags",
"query": "IN",
"op": "IN",
"value": "a,b,c",
"values": ["a", "b", "c"],
"logic": "OR"
}
]
```
## Operator map
`EQ→=`, `NE→<>`, `GT→>`, `GTE→>=`, `LT→<`, `LTE→<=`, `LIKE→LIKE`, `IN→IN`, `NOT_IN→NOT IN`, `BETWEEN→BETWEEN`, `IS_NULL→IS NULL`, `IS_NOT_NULL→IS NOT NULL`
## CI
Copy of windychen-utils pattern: commit message must contain `chore` + `版本` + `更新` to trigger publish.
+62
View File
@@ -0,0 +1,62 @@
# @windychen/trans-form-middleware
Node.js ESM library that parses filter arrays emitted by `@windychen/trans-form-component` into structured conditions. **Does not generate SQL** — ORM adapters consume the output.
## Install
```bash
npm install @windychen/trans-form-middleware
```
## Usage
```js
import { parseFilters } from '@windychen/trans-form-middleware';
const componentOutput = [
{ field: 'name', value: 'xx', query: 'EQ' },
{ field: 'tags', value: 'a,b,c', query: 'IN', logic: 'OR' },
];
const filters = parseFilters(componentOutput, {
allowedFields: ['name', 'tags', 'status'],
mode: 'pass-through',
unknownField: 'error',
skipEmpty: false,
});
```
See `DESIGN.md` for the full protocol and grill-me decisions (Q1–Q61).
## API
| Export | Description |
|--------|-------------|
| `parseFilters(conditions, options)` | Main parser |
| `mapOperators(query, operatorMap?)` | Map EQ/GT/… to op symbols |
| `toTree(flat)` | Nest flat array by logic |
| `splitMultiValue(query, value)` | Parse comma-separated multi-values |
| `DEFAULT_OPERATOR_MAP` | Built-in operator mapping |
### parseOptions
| Option | Default | Description |
|--------|---------|-------------|
| `allowedFields` | *(required)* | Whitelist of field names |
| `mode` | `'pass-through'` | `'flat'` \| `'tree'` \| `'pass-through'` |
| `unknownField` | `'error'` | `'error'` \| `'skip'` |
| `strict` | `false` | Validate item shape |
| `operatorMap` | `{}` | Merge/override operator mapping |
| `skipFlags` | `['disabled','hidden']` | Skip items with these truthy flags |
| `skipEmpty` | `false` | Skip items with `empty: true` |
## Development
```bash
npm install
npm test
```
## License
MIT
+50
View File
@@ -0,0 +1,50 @@
[
{
"row": 6,
"logic": "AND",
"value-component": "input",
"field": "name",
"field-name": "名称",
"query-option": [
{ "value": "EQ", "label": "=", "desc-cn": "等于" },
{ "value": "LIKE", "label": "~", "desc-cn": "包含" }
]
},
{
"row": 6,
"logic": "AND",
"value-component": "select",
"field": "status",
"field-name": "状态",
"query-option": [
{ "value": "EQ", "label": "=", "desc-cn": "等于" },
{ "value": "NE", "label": "≠", "desc-cn": "不等于" }
],
"options": [
{ "value": "active", "label": "启用" },
{ "value": "inactive", "label": "停用" }
]
},
{
"row": 2,
"logic": "OR",
"value-component": "input",
"field": "tags",
"field-name": "标签",
"in-ui": "tags",
"query-option": [
{ "value": "IN", "label": "∈", "desc-cn": "包含任一" }
]
},
{
"row": 9,
"logic": "OR",
"value-component": "input",
"field": "created",
"field-name": "创建时间",
"between-ui": "dual-input",
"query-option": [
{ "value": "BETWEEN", "label": "↔", "desc-cn": "区间" }
]
}
]
+1503
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@windychen/trans-form-middleware",
"version": "0.1.0",
"description": "Parse trans-form-component filter output into structured conditions (no SQL)",
"type": "module",
"exports": {
".": "./src/index.js"
},
"files": [
"src"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"filter",
"query",
"middleware",
"trans-form"
],
"license": "MIT",
"devDependencies": {
"vitest": "^3.2.4"
}
}
+4
View File
@@ -0,0 +1,4 @@
export { DEFAULT_OPERATOR_MAP, mapOperators } from './operatorMap.js';
export { splitMultiValue } from './splitMultiValue.js';
export { toTree } from './toTree.js';
export { parseFilters } from './parseFilters.js';
+25
View File
@@ -0,0 +1,25 @@
/** @type {Record<string, string>} */
export const DEFAULT_OPERATOR_MAP = {
EQ: '=',
NE: '<>',
GT: '>',
GTE: '>=',
LT: '<',
LTE: '<=',
LIKE: 'LIKE',
IN: 'IN',
NOT_IN: 'NOT IN',
BETWEEN: 'BETWEEN',
IS_NULL: 'IS NULL',
IS_NOT_NULL: 'IS NOT NULL',
};
/**
* @param {string} query
* @param {Record<string, string>} [operatorMap]
*/
export function mapOperators(query, operatorMap = DEFAULT_OPERATOR_MAP) {
const q = String(query).toUpperCase();
const merged = { ...DEFAULT_OPERATOR_MAP, ...operatorMap };
return merged[q] ?? q;
}
+103
View File
@@ -0,0 +1,103 @@
import { mapOperators } from './operatorMap.js';
import { splitMultiValue } from './splitMultiValue.js';
import { toTree } from './toTree.js';
const DEFAULT_SKIP_FLAGS = ['disabled', 'hidden'];
/**
* @param {object} item
* @param {string[]} skipFlags
*/
function shouldSkipByFlags(item, skipFlags) {
return skipFlags.some((flag) => item[flag] === true);
}
/**
* @param {Array<{field?: string, value?: *, query?: string, logic?: string, empty?: boolean, disabled?: boolean, hidden?: boolean}>} conditions
* @param {{
* allowedFields: string[],
* mode?: 'pass-through'|'flat'|'tree',
* unknownField?: 'error'|'skip',
* strict?: boolean,
* operatorMap?: Record<string, string>,
* skipFlags?: string[],
* skipEmpty?: boolean,
* }} options
*/
export function parseFilters(conditions, options) {
const {
allowedFields,
mode = 'pass-through',
unknownField = 'error',
strict = false,
operatorMap = {},
skipFlags = DEFAULT_SKIP_FLAGS,
skipEmpty = false,
} = options;
if (!Array.isArray(allowedFields) || allowedFields.length === 0) {
throw new Error('parseFilters: allowedFields is required');
}
if (!Array.isArray(conditions)) {
if (strict) throw new Error('parseFilters: conditions must be an array');
return mode === 'tree' ? [] : [];
}
const allowed = new Set(allowedFields.map(String));
const errors = [];
const out = [];
for (let i = 0; i < conditions.length; i++) {
const c = conditions[i];
if (!c || typeof c !== 'object') {
if (strict) errors.push({ index: i, reason: 'invalid item' });
continue;
}
if (shouldSkipByFlags(c, skipFlags)) continue;
if (skipEmpty && c.empty === true) continue;
if (c.field == null || c.query == null) {
if (strict) errors.push({ index: i, reason: 'missing field or query' });
continue;
}
const field = String(c.field);
if (!allowed.has(field)) {
if (unknownField === 'error') {
throw new Error(`parseFilters: unknown field "${field}"`);
}
continue;
}
const query = String(c.query).toUpperCase();
const item = {
field,
query,
op: mapOperators(query, operatorMap),
value: c.value ?? null,
};
const parsed = splitMultiValue(query, c.value);
if (parsed != null) item.values = parsed;
if (c.empty === true) item.empty = true;
if (c.disabled === true) item.disabled = true;
if (c.hidden === true) item.hidden = true;
if (c.readOnly === true) item.readOnly = true;
if (c.logic != null) item.logic = c.logic;
out.push(item);
}
if (strict && errors.length) {
throw new Error(`parseFilters: ${errors.length} validation error(s)`);
}
if (mode === 'flat') {
return out.map(({ logic, ...rest }) => rest);
}
if (mode === 'tree') {
return toTree(out);
}
return out;
}
+18
View File
@@ -0,0 +1,18 @@
/**
* @param {string} query
* @param {string|null|undefined} value
* @returns {string[]|[string,string]|null}
*/
export function splitMultiValue(query, value) {
if (value == null || value === '') return null;
const s = String(value);
const q = String(query).toUpperCase();
if (q === 'IN' || q === 'NOT_IN') {
return s.split(',').map((v) => v.trim()).filter(Boolean);
}
if (q === 'BETWEEN') {
const parts = s.split(',').map((v) => v.trim());
return parts.length >= 2 ? [parts[0], parts[1]] : null;
}
return null;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @param {Array<object>} flat
* @returns {Array<object>}
*/
export function toTree(flat) {
if (!flat.length) return [];
if (flat.length === 1) return [flat[0]];
let acc = flat[0];
for (let i = 1; i < flat.length; i++) {
const logic = flat[i].logic || 'AND';
acc = { logic, children: [acc, flat[i]] };
}
return [acc];
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_OPERATOR_MAP,
mapOperators,
parseFilters,
splitMultiValue,
toTree,
} from '../src/index.js';
describe('mapOperators', () => {
it('maps known query codes', () => {
expect(mapOperators('EQ')).toBe('=');
expect(mapOperators('NOT_IN')).toBe('NOT IN');
});
it('allows custom overrides', () => {
expect(mapOperators('EQ', { EQ: 'equals' })).toBe('equals');
});
});
describe('splitMultiValue', () => {
it('splits IN values', () => {
expect(splitMultiValue('IN', 'a,b,c')).toEqual(['a', 'b', 'c']);
});
it('splits BETWEEN values', () => {
expect(splitMultiValue('BETWEEN', '2024-01-01,2024-12-31')).toEqual([
'2024-01-01',
'2024-12-31',
]);
});
});
describe('parseFilters', () => {
const allowedFields = ['name', 'status', 'tags', 'created'];
it('requires allowedFields', () => {
expect(() => parseFilters([], {})).toThrow(/allowedFields/);
});
it('parses basic conditions with logic pass-through', () => {
const input = [
{ field: 'name', value: 'xx', query: 'EQ' },
{ field: 'tags', value: 'a,b', query: 'IN', logic: 'OR' },
];
const out = parseFilters(input, { allowedFields });
expect(out).toEqual([
{
field: 'name',
query: 'EQ',
op: '=',
value: 'xx',
},
{
field: 'tags',
query: 'IN',
op: 'IN',
value: 'a,b',
values: ['a', 'b'],
logic: 'OR',
},
]);
});
it('preserves empty markers by default', () => {
const out = parseFilters(
[{ field: 'name', value: null, query: 'EQ', empty: true }],
{ allowedFields },
);
expect(out[0].empty).toBe(true);
});
it('skips disabled and hidden by default', () => {
const out = parseFilters(
[
{ field: 'name', value: 'a', query: 'EQ', disabled: true },
{ field: 'status', value: 'b', query: 'EQ', hidden: true },
{ field: 'tags', value: 'c', query: 'EQ' },
],
{ allowedFields },
);
expect(out).toHaveLength(1);
expect(out[0].field).toBe('tags');
});
it('can skip empty items when skipEmpty is true', () => {
const out = parseFilters(
[{ field: 'name', value: null, query: 'EQ', empty: true }],
{ allowedFields, skipEmpty: true },
);
expect(out).toEqual([]);
});
it('throws on unknown field by default', () => {
expect(() =>
parseFilters([{ field: 'unknown', value: 'x', query: 'EQ' }], {
allowedFields,
}),
).toThrow(/unknown field/);
});
it('supports flat mode', () => {
const out = parseFilters(
[{ field: 'name', value: 'x', query: 'EQ', logic: 'AND' }],
{ allowedFields, mode: 'flat' },
);
expect(out[0].logic).toBeUndefined();
});
});
describe('toTree', () => {
it('nests flat filters', () => {
const flat = [
{ field: 'a', query: 'EQ', op: '=', value: '1' },
{ field: 'b', query: 'EQ', op: '=', value: '2', logic: 'OR' },
];
const tree = toTree(flat);
expect(tree[0]).toMatchObject({
logic: 'OR',
children: expect.any(Array),
});
});
});
describe('DEFAULT_OPERATOR_MAP', () => {
it('includes IS_NULL operators', () => {
expect(DEFAULT_OPERATOR_MAP.IS_NULL).toBe('IS NULL');
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});