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 fc1d8df60f
21 changed files with 5420 additions and 0 deletions
+141
View File
@@ -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>
+22
View File
@@ -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>
+11
View File
@@ -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>
+126
View File
@@ -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;
}
+34
View File
@@ -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';
}
+3
View File
@@ -0,0 +1,3 @@
import './styles/trans-form.css';
export { default as TransFormPlugin, TransForm, setConfig, getConfig } from './plugin.js';
+11
View File
@@ -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;
},
};
+81
View File
@@ -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;
}