- 基于 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