- 基于 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:
@@ -0,0 +1,132 @@
|
||||
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-component.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: Run build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to Verdaccio
|
||||
id: publish-verdaccio
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://verdaccio:4873/@windychen%2ftrans-form-component/$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")
|
||||
EXISTS=$(curl -s "http://gitea:3000/api/v1/packages/windychen?q=trans-form-component" -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-component/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-component/releases" \
|
||||
-u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"## trans-form-component 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-component v${VERSION} 构建完成 chen-fnos"
|
||||
else
|
||||
TITLE="❌ trans-form-component 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-component\n- 分支: ${{ github.ref_name }}\n- 提交: ${{ github.sha }}\"}}"
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
storybook-static/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('@storybook/vue3-vite').StorybookConfig} */
|
||||
export default {
|
||||
stories: ['../stories/**/*.stories.@(js|jsx)'],
|
||||
addons: ['@storybook/addon-essentials'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../src/styles/trans-form.css';
|
||||
|
||||
/** @type {import('@storybook/vue3').Preview} */
|
||||
export default {
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
# trans-form-component — Design (Q1–Q61)
|
||||
|
||||
Status: **implementation scaffold** (v0.1.0)
|
||||
|
||||
Companion package: `@windychen/trans-form-middleware`.
|
||||
|
||||
## Role
|
||||
|
||||
Schema-driven Vue 3 query form → flat filter array with optional `logic`, state flags, and `empty` markers.
|
||||
|
||||
## Key decisions (selected)
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| 4–5 | No UI framework; headless native controls for string `value-component` |
|
||||
| 7 | Flat array + `logic` between adjacent conditions |
|
||||
| 11 | `@change` + `getFilters()` ref API |
|
||||
| 15 | 12-column grid via `row` |
|
||||
| 17–18 | Query dropdown; optional `query-component` (defaults to value renderer) |
|
||||
| 19 | Global singleton `setConfig` / `componentMap` |
|
||||
| 20 | ESM only (Vite library build) |
|
||||
| 30, 35 | Inline layout 3:2:7 (name/query/value), globally configurable |
|
||||
| 47 | Storybook included |
|
||||
| 48 | `app.use(TransFormPlugin)` |
|
||||
| 51 | Built-in reset only (`showReset`, `reset()`) |
|
||||
| 54 | First filter item has no `logic` |
|
||||
| 55–59 | Reactive schema; preserve matching fields; AND/OR editable; v1 types include tags |
|
||||
| 60 | Output includes state flags for middleware |
|
||||
|
||||
## CI
|
||||
|
||||
Same as windychen-utils: commit must contain `chore` + `版本` + `更新`.
|
||||
@@ -0,0 +1,75 @@
|
||||
# @windychen/trans-form-component
|
||||
|
||||
Vue 3 dynamic query form component. Accepts a schema array, emits structured filter conditions for backend middleware.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @windychen/trans-form-component vue@^3.4
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { createApp } from 'vue';
|
||||
import { TransFormPlugin, setConfig } from '@windychen/trans-form-component';
|
||||
import '@windychen/trans-form-component/style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(TransFormPlugin);
|
||||
|
||||
setConfig({
|
||||
colRatio: { name: 3, query: 2, value: 7 },
|
||||
});
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<TransForm :schema="schema" @change="onChange" ref="formRef" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const formRef = ref();
|
||||
const schema = [
|
||||
{
|
||||
row: 6,
|
||||
'value-component': 'input',
|
||||
field: 'name',
|
||||
'field-name': '名称',
|
||||
'query-option': [{ value: 'EQ', label: '=' }],
|
||||
},
|
||||
];
|
||||
|
||||
function onChange(filters) {
|
||||
console.log(filters);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Output shape
|
||||
|
||||
```json
|
||||
[
|
||||
{ "field": "name", "value": "xx", "query": "EQ" },
|
||||
{ "field": "tags", "value": null, "query": "IN", "empty": true, "logic": "OR" }
|
||||
]
|
||||
```
|
||||
|
||||
Pair with `@windychen/trans-form-middleware` for server-side parsing.
|
||||
|
||||
See `DESIGN.md` for full grill-me decisions (Q1–Q61).
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm run build
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Generated
+4543
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@windychen/trans-form-component",
|
||||
"version": "0.1.0",
|
||||
"description": "Vue 3 dynamic query form: schema in, filter array out",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/trans-form-component.js"
|
||||
},
|
||||
"./style.css": "./dist/trans-form-component.css"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-essentials": "^8.6.14",
|
||||
"@storybook/vue3": "^8.6.14",
|
||||
"@storybook/vue3-vite": "^8.6.14",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"storybook": "^8.6.14",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4",
|
||||
"vue": "^3.5.16"
|
||||
},
|
||||
"keywords": [
|
||||
"vue3",
|
||||
"form",
|
||||
"query",
|
||||
"filter",
|
||||
"trans-form"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { getConfig } from './config.js';
|
||||
import {
|
||||
applyFilters,
|
||||
buildFilters,
|
||||
createFieldState,
|
||||
groupRows,
|
||||
} from './composables/useFilters.js';
|
||||
import HeadlessInput from './components/HeadlessInput.vue';
|
||||
import UnknownPlaceholder from './components/UnknownPlaceholder.vue';
|
||||
|
||||
const props = defineProps({
|
||||
schema: { type: Array, default: () => [] },
|
||||
showReset: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const initialState = createFieldState(props.schema);
|
||||
const state = reactive(createFieldState(props.schema));
|
||||
|
||||
const rowGroups = computed(() => groupRows(props.schema));
|
||||
const config = computed(() => getConfig());
|
||||
|
||||
const colStyle = computed(() => {
|
||||
const ratio = config.value.colRatio || { name: 3, query: 2, value: 7 };
|
||||
return {
|
||||
'--tf-name-cols': `${ratio.name ?? 3}fr`,
|
||||
'--tf-query-cols': `${ratio.query ?? 2}fr`,
|
||||
'--tf-value-cols': `${ratio.value ?? 7}fr`,
|
||||
};
|
||||
});
|
||||
|
||||
function resolveComponent(fieldDef, kind) {
|
||||
const key = kind === 'query' ? 'query-component' : 'value-component';
|
||||
const raw = fieldDef[key] ?? fieldDef['value-component'];
|
||||
if (raw && typeof raw === 'object') return raw;
|
||||
const name = typeof raw === 'string' ? raw : 'input';
|
||||
const mapped = config.value.componentMap[name];
|
||||
if (mapped) return mapped;
|
||||
if (name === 'input' || name === 'textarea' || name === 'number' || name === 'date') {
|
||||
return HeadlessInput;
|
||||
}
|
||||
console.warn(`[TransForm] unknown ${key}: "${name}"`);
|
||||
return UnknownPlaceholder;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
emit('change', getFilters());
|
||||
}
|
||||
|
||||
function getFilters() {
|
||||
return buildFilters(props.schema, state);
|
||||
}
|
||||
|
||||
function setFilters(filters) {
|
||||
const next = applyFilters(props.schema, filters);
|
||||
for (const key of Object.keys(state)) delete state[key];
|
||||
Object.assign(state, next);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function loadFilters(filters) {
|
||||
setFilters(filters);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const next = createFieldState(props.schema);
|
||||
for (const key of Object.keys(state)) delete state[key];
|
||||
Object.assign(state, next);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.schema,
|
||||
(nextSchema) => {
|
||||
const preserved = applyFilters(nextSchema, getFilters());
|
||||
for (const key of Object.keys(state)) delete state[key];
|
||||
Object.assign(state, preserved);
|
||||
emitChange();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
state,
|
||||
() => {
|
||||
if (config.value.changeEmit === 'manual') return;
|
||||
emitChange();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
defineExpose({ getFilters, setFilters, loadFilters, reset });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tf-form" :style="colStyle">
|
||||
<div v-for="(group, gi) in rowGroups" :key="gi" class="tf-row">
|
||||
<div
|
||||
v-for="fieldDef in group"
|
||||
:key="fieldDef.field"
|
||||
class="tf-field"
|
||||
:style="{ '--tf-span': fieldDef.row || 12 }"
|
||||
v-show="!state[fieldDef.field]?.hidden"
|
||||
>
|
||||
<div class="tf-field-inner">
|
||||
<label class="tf-label">{{ fieldDef['field-name'] || fieldDef.field }}</label>
|
||||
|
||||
<select
|
||||
class="tf-control"
|
||||
v-model="state[fieldDef.field].query"
|
||||
:disabled="state[fieldDef.field]?.disabled"
|
||||
>
|
||||
<option
|
||||
v-for="opt in fieldDef['query-option'] || []"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label || opt.value }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<component
|
||||
:is="resolveComponent(fieldDef, 'value')"
|
||||
v-model="state[fieldDef.field].value"
|
||||
:disabled="state[fieldDef.field]?.disabled"
|
||||
:read-only="state[fieldDef.field]?.readOnly"
|
||||
:name="typeof fieldDef['value-component'] === 'string' ? fieldDef['value-component'] : 'custom'"
|
||||
:placeholder="fieldDef['field-name']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showReset" class="tf-actions">
|
||||
<button type="button" class="tf-btn" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
readOnly: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
class="tf-control tf-input"
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:readonly="readOnly"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', ($event.target).value)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
name: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tf-unknown" role="alert">
|
||||
Unknown component: {{ name }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @typedef {object} SchemaField
|
||||
* @property {number} [row]
|
||||
* @property {'AND'|'OR'} [logic]
|
||||
* @property {string|import('vue').Component} [value-component]
|
||||
* @property {string|import('vue').Component} [query-component]
|
||||
* @property {string} field
|
||||
* @property {string} [field-name]
|
||||
* @property {Array<{value: string, label?: string}>} [query-option]
|
||||
* @property {string} [default-query]
|
||||
* @property {boolean} [hidden]
|
||||
* @property {boolean} [disabled]
|
||||
* @property {boolean} [readOnly]
|
||||
* @property {*} [value]
|
||||
* @property {string} [query]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {SchemaField[]} schema
|
||||
*/
|
||||
export function createFieldState(schema) {
|
||||
/** @type {Record<string, any>} */
|
||||
const state = {};
|
||||
for (const item of schema || []) {
|
||||
if (!item?.field) continue;
|
||||
const defaultQuery =
|
||||
item['default-query'] ??
|
||||
item.query ??
|
||||
item['query-option']?.[0]?.value ??
|
||||
'EQ';
|
||||
state[item.field] = {
|
||||
value: item.value ?? '',
|
||||
query: defaultQuery,
|
||||
logic: item.logic ?? 'AND',
|
||||
hidden: item.hidden === true,
|
||||
disabled: item.disabled === true,
|
||||
readOnly: item.readOnly === true,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SchemaField[]} schema
|
||||
* @param {Record<string, any>} state
|
||||
*/
|
||||
export function buildFilters(schema, state) {
|
||||
const filters = [];
|
||||
const fields = (schema || []).filter((s) => s?.field);
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const fieldDef = fields[i];
|
||||
const key = fieldDef.field;
|
||||
const current = state[key] || {};
|
||||
const value = current.value ?? '';
|
||||
const empty =
|
||||
value === '' || value == null || String(value).trim() === '';
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
const row = {
|
||||
field: key,
|
||||
value: empty ? null : value,
|
||||
query: current.query ?? fieldDef['query-option']?.[0]?.value ?? 'EQ',
|
||||
hidden: current.hidden === true,
|
||||
disabled: current.disabled === true,
|
||||
readOnly: current.readOnly === true,
|
||||
};
|
||||
|
||||
if (empty) row.empty = true;
|
||||
if (i > 0) row.logic = current.logic ?? fieldDef.logic ?? 'AND';
|
||||
|
||||
filters.push(row);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SchemaField[]} schema
|
||||
* @param {Array<Record<string, any>>} filters
|
||||
*/
|
||||
export function applyFilters(schema, filters) {
|
||||
const state = createFieldState(schema);
|
||||
for (const item of filters || []) {
|
||||
if (!item?.field || !state[item.field]) continue;
|
||||
state[item.field] = {
|
||||
...state[item.field],
|
||||
value: item.value ?? '',
|
||||
query: item.query ?? state[item.field].query,
|
||||
logic: item.logic ?? state[item.field].logic,
|
||||
hidden: item.hidden === true,
|
||||
disabled: item.disabled === true,
|
||||
readOnly: item.readOnly === true,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group schema rows by 12-column grid.
|
||||
* @param {SchemaField[]} schema
|
||||
*/
|
||||
export function groupRows(schema) {
|
||||
const groups = [];
|
||||
let current = [];
|
||||
let used = 0;
|
||||
|
||||
for (const item of schema || []) {
|
||||
const span = Math.min(12, Math.max(1, Number(item.row) || 12));
|
||||
if (used + span > 12 && current.length) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
used = 0;
|
||||
}
|
||||
current.push(item);
|
||||
used += span;
|
||||
if (used >= 12) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
used = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length) groups.push(current);
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** @typedef {{ componentMap?: Record<string, import('vue').Component>, colRatio?: { name?: number, query?: number, value?: number }, changeEmit?: 'immediate'|'debounce'|'manual' }} TransFormConfig */
|
||||
|
||||
/** @type {TransFormConfig & { componentMap: Record<string, import('vue').Component> }} */
|
||||
const globalConfig = {
|
||||
componentMap: {},
|
||||
colRatio: { name: 3, query: 2, value: 7 },
|
||||
changeEmit: 'immediate',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Partial<TransFormConfig>} patch
|
||||
*/
|
||||
export function setConfig(patch) {
|
||||
if (patch.componentMap) {
|
||||
globalConfig.componentMap = {
|
||||
...globalConfig.componentMap,
|
||||
...patch.componentMap,
|
||||
};
|
||||
}
|
||||
if (patch.colRatio) {
|
||||
globalConfig.colRatio = { ...globalConfig.colRatio, ...patch.colRatio };
|
||||
}
|
||||
if (patch.changeEmit) globalConfig.changeEmit = patch.changeEmit;
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return globalConfig;
|
||||
}
|
||||
|
||||
export function resetConfig() {
|
||||
globalConfig.componentMap = {};
|
||||
globalConfig.colRatio = { name: 3, query: 2, value: 7 };
|
||||
globalConfig.changeEmit = 'immediate';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import './styles/trans-form.css';
|
||||
|
||||
export { default as TransFormPlugin, TransForm, setConfig, getConfig } from './plugin.js';
|
||||
@@ -0,0 +1,11 @@
|
||||
import TransForm from './TransForm.vue';
|
||||
import { setConfig, getConfig } from './config.js';
|
||||
|
||||
export { TransForm, setConfig, getConfig };
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
app.component('TransForm', TransForm);
|
||||
app.config.globalProperties.$setTransFormConfig = setConfig;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
:root {
|
||||
--tf-gap: 8px;
|
||||
--tf-border: #d0d7de;
|
||||
--tf-radius: 6px;
|
||||
--tf-label-color: #57606a;
|
||||
--tf-bg: #ffffff;
|
||||
--tf-unknown-bg: #f6f8fa;
|
||||
--tf-unknown-color: #8c959f;
|
||||
}
|
||||
|
||||
.tf-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tf-gap);
|
||||
}
|
||||
|
||||
.tf-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: var(--tf-gap);
|
||||
}
|
||||
|
||||
.tf-field {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: span var(--tf-span, 12);
|
||||
align-items: center;
|
||||
gap: var(--tf-gap);
|
||||
}
|
||||
|
||||
.tf-field-inner {
|
||||
display: grid;
|
||||
grid-template-columns: var(--tf-name-cols, 3fr) var(--tf-query-cols, 2fr) var(--tf-value-cols, 7fr);
|
||||
gap: var(--tf-gap);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tf-label {
|
||||
color: var(--tf-label-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tf-control {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--tf-border);
|
||||
border-radius: var(--tf-radius);
|
||||
padding: 6px 8px;
|
||||
background: var(--tf-bg);
|
||||
}
|
||||
|
||||
.tf-control:disabled,
|
||||
.tf-control[readonly] {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tf-unknown {
|
||||
border: 1px dashed var(--tf-border);
|
||||
background: var(--tf-unknown-bg);
|
||||
color: var(--tf-unknown-color);
|
||||
border-radius: var(--tf-radius);
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tf-actions {
|
||||
display: flex;
|
||||
gap: var(--tf-gap);
|
||||
}
|
||||
|
||||
.tf-btn {
|
||||
border: 1px solid var(--tf-border);
|
||||
background: var(--tf-bg);
|
||||
border-radius: var(--tf-radius);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tf-btn:hover {
|
||||
background: #f6f8fa;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import TransForm from '../src/TransForm.vue';
|
||||
import '../src/styles/trans-form.css';
|
||||
|
||||
export default {
|
||||
title: 'TransForm/Basic',
|
||||
component: TransForm,
|
||||
};
|
||||
|
||||
const schema = [
|
||||
{
|
||||
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: 'OR',
|
||||
'value-component': 'input',
|
||||
field: 'tags',
|
||||
'field-name': '标签',
|
||||
'query-option': [{ value: 'IN', label: '∈', 'desc-cn': '包含任一' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
schema,
|
||||
showReset: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyFilters,
|
||||
buildFilters,
|
||||
createFieldState,
|
||||
groupRows,
|
||||
} from '../src/composables/useFilters.js';
|
||||
|
||||
const schema = [
|
||||
{
|
||||
row: 6,
|
||||
field: 'name',
|
||||
'field-name': '名称',
|
||||
'query-option': [{ value: 'EQ', label: '=' }],
|
||||
},
|
||||
{
|
||||
row: 6,
|
||||
logic: 'OR',
|
||||
field: 'status',
|
||||
'query-option': [{ value: 'NE', label: '≠' }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('createFieldState', () => {
|
||||
it('initializes default query from first option', () => {
|
||||
const state = createFieldState(schema);
|
||||
expect(state.name.query).toBe('EQ');
|
||||
expect(state.status.logic).toBe('OR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFilters', () => {
|
||||
it('emits logic from second item onward', () => {
|
||||
const state = createFieldState(schema);
|
||||
state.name.value = 'alice';
|
||||
state.status.value = 'active';
|
||||
const filters = buildFilters(schema, state);
|
||||
expect(filters[0].logic).toBeUndefined();
|
||||
expect(filters[1].logic).toBe('OR');
|
||||
});
|
||||
|
||||
it('marks empty values', () => {
|
||||
const state = createFieldState(schema);
|
||||
const filters = buildFilters(schema, state);
|
||||
expect(filters[0].empty).toBe(true);
|
||||
expect(filters[0].value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyFilters', () => {
|
||||
it('loads filters back into state', () => {
|
||||
const loaded = applyFilters(schema, [
|
||||
{ field: 'name', value: 'bob', query: 'EQ' },
|
||||
]);
|
||||
expect(loaded.name.value).toBe('bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupRows', () => {
|
||||
it('wraps when row spans exceed 12', () => {
|
||||
const groups = groupRows([
|
||||
{ row: 2, field: 'a' },
|
||||
{ row: 9, field: 'b' },
|
||||
{ row: 4, field: 'c' },
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toHaveLength(2);
|
||||
expect(groups[1]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.js'),
|
||||
name: 'TransFormComponent',
|
||||
fileName: 'trans-form-component',
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue'],
|
||||
output: {
|
||||
globals: {
|
||||
vue: 'Vue',
|
||||
},
|
||||
},
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user