- 基于 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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user