Files
DianZhanDemo/app/components/UniversalQueryBuilder.vue
ch197511161 ddce8fce18 init5
2025-12-11 01:29:41 +08:00

744 lines
21 KiB
Vue

<template>
<div class="universal-query-builder bg-white rounded-lg shadow-lg p-6">
<!-- 表选择 -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2"> 选择表 </label>
<select
v-model="selectedTable"
@change="onTableChange"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">请选择表</option>
<option v-for="table in availableTables" :key="table.key" :value="table.key">
{{ table.name }}
</option>
</select>
</div>
<!-- 查询条件构造器 -->
<div v-if="selectedTable && tableInfo" class="mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">查询条件</h3>
<button
@click="addCondition"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
添加条件
</button>
</div>
<!-- 条件列表 -->
<div v-if="queryOptions.where && queryOptions.where.length > 0" class="space-y-4">
<div
v-for="(condition, index) in queryOptions.where"
:key="index"
class="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg"
>
<!-- 逻辑操作符 -->
<select
v-if="index > 0"
v-model="condition.logicalOperator"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="AND">AND</option>
<option value="OR">OR</option>
</select>
<!-- 字段选择 -->
<select
v-model="condition.field"
@change="onFieldChange(condition)"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">选择字段</option>
<option v-for="field in tableInfo.fields" :key="field.name" :value="field.name">
{{ field.name }} ({{ field.type }})
</option>
</select>
<!-- 操作符选择 -->
<select
v-model="condition.operator"
@change="onOperatorChange(condition)"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">选择操作符</option>
<option
v-for="operator in getAvailableOperators(condition.field)"
:key="operator.value"
:value="operator.value"
>
{{ operator.label }}
</option>
</select>
<!-- 值输入 -->
<div class="flex-1">
<!-- 单值输入 -->
<input
v-if="
!isMultiValueOperator(condition.operator) && !isNullOperator(condition.operator)
"
v-model="condition.value"
:type="getInputType(condition.field, condition.operator)"
:placeholder="getInputPlaceholder(condition.field, condition.operator)"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<!-- 多值输入 -->
<div v-else-if="isMultiValueOperator(condition.operator)" class="space-y-2">
<input
v-if="condition.operator === 'between' || condition.operator === 'date_between'"
v-model="condition.value[0]"
:type="getInputType(condition.field, condition.operator)"
placeholder="起始值"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
v-if="condition.operator === 'between' || condition.operator === 'date_between'"
v-model="condition.value[1]"
:type="getInputType(condition.field, condition.operator)"
placeholder="结束值"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<textarea
v-if="condition.operator === 'in' || condition.operator === 'not_in'"
v-model="condition.valueText"
@input="onMultiValueInput(condition)"
placeholder="输入多个值,用逗号分隔"
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- 空值操作符不需要输入 -->
<span v-else-if="isNullOperator(condition.operator)" class="text-gray-500 italic">
无需输入值
</span>
</div>
<!-- 删除按钮 -->
<button
@click="removeCondition(index)"
class="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500"
>
删除
</button>
</div>
</div>
<div v-else class="text-gray-500 text-center py-8">
暂无查询条件,点击"添加条件"开始构建查询
</div>
</div>
<!-- 搜索框 -->
<div v-if="selectedTable && tableInfo" class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2"> 全文搜索 </label>
<input
v-model="queryOptions.search"
type="text"
placeholder="输入搜索关键词..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p class="text-sm text-gray-500 mt-1">搜索字段: {{ tableInfo.searchFields.join(', ') }}</p>
</div>
<!-- 排序设置 -->
<div v-if="selectedTable && tableInfo" class="mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">排序设置</h3>
<button
@click="addSortCondition"
class="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500"
>
添加排序
</button>
</div>
<div v-if="queryOptions.orderBy && queryOptions.orderBy.length > 0" class="space-y-2">
<div
v-for="(sort, index) in queryOptions.orderBy"
:key="index"
class="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
>
<select
v-model="sort.field"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">选择字段</option>
<option v-for="field in tableInfo.sortableFields" :key="field" :value="field">
{{ field }}
</option>
</select>
<select
v-model="sort.direction"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="ASC">升序</option>
<option value="DESC">降序</option>
</select>
<button
@click="removeSortCondition(index)"
class="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500"
>
删除
</button>
</div>
</div>
</div>
<!-- 分页设置 -->
<div v-if="selectedTable" class="mb-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">分页设置</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">页码</label>
<input
v-model.number="queryOptions.pagination.page"
type="number"
min="1"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">每页数量</label>
<select
v-model.number="queryOptions.pagination.limit"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="selectedTable" class="flex space-x-4">
<button
@click="executeQuery"
:disabled="loading"
class="flex-1 px-6 py-3 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
{{ loading ? '查询中...' : '执行查询' }}
</button>
<button
@click="executeAggregateQuery"
:disabled="loading"
class="px-6 py-3 bg-purple-500 text-white rounded-md hover:bg-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
>
聚合查询
</button>
<button
@click="clearQuery"
class="px-6 py-3 bg-gray-500 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
清空
</button>
</div>
<!-- 查询结果 -->
<div v-if="queryResult" class="mt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">查询结果</h3>
<!-- 结果统计 -->
<div class="mb-4 p-4 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-800">
<span v-if="queryResult.pagination">
第 {{ queryResult.pagination.page }} 页,共 {{ queryResult.pagination.totalPages }} 页,
总计 {{ queryResult.total }} 条记录
</span>
<span v-else> 总计 {{ queryResult.total }} 条记录 </span>
</p>
</div>
<!-- 数据表格 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
v-for="field in getDisplayFields()"
:key="field"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{{ field }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(item, index) in queryResult.data" :key="index">
<td
v-for="field in getDisplayFields()"
:key="field"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
{{ formatValue(item[field]) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 聚合结果 -->
<div v-if="aggregateResult" class="mt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">聚合结果</h3>
<div class="bg-gray-50 rounded-lg p-4">
<pre class="text-sm">{{ JSON.stringify(aggregateResult, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AdvancedQueryOptions, QueryCondition, SortCondition } from '@nuxt4crud/shared/types'
/**
* 组件属性
* Component props
*/
interface Props {
modelValue?: AdvancedQueryOptions
table?: string
}
/**
* 组件事件
* Component events
*/
interface Emits {
(e: 'update:modelValue', value: AdvancedQueryOptions): void
(e: 'query', result: any): void
(e: 'aggregate', result: any): void
(e: 'search', options: AdvancedQueryOptions): void
(e: 'reset'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => ({
where: [],
orderBy: [],
pagination: { page: 1, limit: 20 },
}),
})
const emit = defineEmits<Emits>()
// 响应式数据
const selectedTable = ref(props.table || '')
const availableTables = ref<any[]>([])
const tableInfo = ref<any>(null)
const loading = ref(false)
const queryResult = ref<any>(null)
const aggregateResult = ref<any>(null)
// 监听table属性变化
watch(() => props.table, (newTable) => {
if (newTable) {
selectedTable.value = newTable
}
})
// 查询选项
const queryOptions = ref<AdvancedQueryOptions>({
where: [],
orderBy: [],
search: '',
pagination: { page: 1, limit: 20 },
})
/**
* 监听查询选项变化
* Watch query options changes
*/
watch(
queryOptions,
newValue => {
emit('update:modelValue', newValue)
},
{ deep: true }
)
/**
* 监听外部传入的值
* Watch external value changes
*/
watch(
() => props.modelValue,
newValue => {
if (newValue && JSON.stringify(newValue) !== JSON.stringify(queryOptions.value)) {
queryOptions.value = { ...newValue }
}
},
{ immediate: true }
)
/**
* 获取可用表列表
* Get available tables
*/
async function loadAvailableTables() {
try {
const response = await $fetch('/api/table-info')
if (response.success) {
availableTables.value = response.data.tables
} else {
// 提供默认的表列表,确保能够正确加载用户表和文章表
availableTables.value = [
{
key: 'User',
name: '用户表'
},
{
key: 'Post',
name: '文章表'
}
]
}
} catch (error) {
console.error('Failed to load tables:', error)
// 错误时提供默认的表列表
availableTables.value = [
{
key: 'User',
name: '用户表'
},
{
key: 'Post',
name: '文章表'
}
]
}
}
/**
* 表选择变化处理
* Handle table selection change
*/
async function onTableChange() {
if (!selectedTable.value) {
tableInfo.value = null
return
}
try {
const response = await $fetch(`/api/table-info?table=${selectedTable.value}`)
if (response.success) {
tableInfo.value = response.data.table
// 清空之前的查询条件
clearQuery()
}
} catch (error) {
console.error('Failed to load table info:', error)
}
}
/**
* 添加查询条件
* Add query condition
*/
function addCondition() {
const condition: QueryCondition & { logicalOperator?: string; valueText?: string } = {
field: '',
operator: 'equals' as any,
value: '',
logicalOperator: queryOptions.value.where!.length > 0 ? 'AND' : undefined,
valueText: '',
}
if (!queryOptions.value.where) {
queryOptions.value.where = []
}
queryOptions.value.where.push(condition as any)
}
/**
* 删除查询条件
* Remove query condition
*/
function removeCondition(index: number) {
if (queryOptions.value.where) {
queryOptions.value.where.splice(index, 1)
}
}
/**
* 字段变化处理
* Handle field change
*/
function onFieldChange(condition: any) {
// 重置操作符和值
condition.operator = 'equals'
condition.value = ''
condition.valueText = ''
}
/**
* 操作符变化处理
* Handle operator change
*/
function onOperatorChange(condition: any) {
// 根据操作符类型初始化值
if (isMultiValueOperator(condition.operator)) {
if (condition.operator === 'between' || condition.operator === 'date_between') {
condition.value = ['', '']
} else {
condition.value = []
condition.valueText = ''
}
} else if (isNullOperator(condition.operator)) {
condition.value = null
} else {
condition.value = ''
}
}
/**
* 多值输入处理
* Handle multi-value input
*/
function onMultiValueInput(condition: any) {
if (condition.valueText) {
condition.value = condition.valueText
.split(',')
.map((v: string) => v.trim())
.filter(Boolean)
} else {
condition.value = []
}
}
/**
* 获取可用操作符
* Get available operators for field
*/
function getAvailableOperators(fieldName: string) {
if (!tableInfo.value || !fieldName) return []
const field = tableInfo.value.fields.find((f: any) => f.name === fieldName)
if (!field) return []
return tableInfo.value.operators.filter((op: any) => op.types.includes(field.type))
}
/**
* 判断是否为多值操作符
* Check if operator requires multiple values
*/
function isMultiValueOperator(operator: string): boolean {
return ['in', 'not_in', 'between', 'date_between'].includes(operator)
}
/**
* 判断是否为空值操作符
* Check if operator is null operator
*/
function isNullOperator(operator: string): boolean {
return ['is_null', 'is_not_null'].includes(operator)
}
/**
* 获取输入框类型
* Get input type for field and operator
*/
function getInputType(fieldName: string, operator: string): string {
if (!tableInfo.value || !fieldName) return 'text'
const field = tableInfo.value.fields.find((f: any) => f.name === fieldName)
if (!field) return 'text'
if (operator.startsWith('date_') || field.type === 'datetime') {
return 'datetime-local'
} else if (field.type === 'number') {
return 'number'
}
return 'text'
}
/**
* 获取输入框占位符
* Get input placeholder
*/
function getInputPlaceholder(fieldName: string, operator: string): string {
if (!tableInfo.value || !fieldName) return ''
const field = tableInfo.value.fields.find((f: any) => f.name === fieldName)
if (!field) return ''
return `请输入${field.name}的值`
}
/**
* 添加排序条件
* Add sort condition
*/
function addSortCondition() {
const sortCondition: SortCondition = {
field: '',
direction: 'ASC',
}
if (!queryOptions.value.orderBy) {
queryOptions.value.orderBy = []
}
queryOptions.value.orderBy.push(sortCondition)
}
/**
* 删除排序条件
* Remove sort condition
*/
function removeSortCondition(index: number) {
if (queryOptions.value.orderBy) {
queryOptions.value.orderBy.splice(index, 1)
}
}
/**
* 执行查询
* Execute query
*/
async function executeQuery() {
if (!selectedTable.value) return
loading.value = true
queryResult.value = null
aggregateResult.value = null
// 发出搜索事件,传递查询选项
emit('search', queryOptions.value)
try {
const response = await $fetch('/api/universal-query', {
method: 'POST',
body: {
table: selectedTable.value,
options: queryOptions.value,
mode: 'query',
},
})
if (response.success) {
queryResult.value = response.data
emit('query', response.data)
}
} catch (error) {
console.error('Query failed:', error)
alert('查询失败: ' + (error as any).data?.message || '未知错误')
} finally {
loading.value = false
}
}
/**
* 执行聚合查询
* Execute aggregate query
*/
async function executeAggregateQuery() {
if (!selectedTable.value) return
loading.value = true
queryResult.value = null
aggregateResult.value = null
try {
// 构建聚合选项
const aggregateOptions = {
...queryOptions.value,
aggregate: {
count: true,
sum: tableInfo.value.fields.filter((f: any) => f.type === 'number').map((f: any) => f.name),
avg: tableInfo.value.fields.filter((f: any) => f.type === 'number').map((f: any) => f.name),
min: tableInfo.value.fields.filter((f: any) => f.type === 'number').map((f: any) => f.name),
max: tableInfo.value.fields.filter((f: any) => f.type === 'number').map((f: any) => f.name),
},
}
const response = await $fetch('/api/universal-query', {
method: 'POST',
body: {
table: selectedTable.value,
options: aggregateOptions,
mode: 'aggregate',
},
})
if (response.success) {
aggregateResult.value = response.data.result
emit('aggregate', response.data)
}
} catch (error) {
console.error('Aggregate query failed:', error)
alert('聚合查询失败: ' + (error as any).data?.message || '未知错误')
} finally {
loading.value = false
}
}
/**
* 清空查询
* Clear query
*/
function clearQuery() {
queryOptions.value = {
where: [],
orderBy: tableInfo.value?.defaultOrderBy || [],
search: '',
pagination: { page: 1, limit: 20 },
}
queryResult.value = null
aggregateResult.value = null
// 发出重置事件
emit('reset')
}
/**
* 获取显示字段
* Get display fields
*/
function getDisplayFields(): string[] {
if (!queryResult.value?.data || queryResult.value.data.length === 0) return []
return Object.keys(queryResult.value.data[0])
}
/**
* 格式化值显示
* Format value for display
*/
function formatValue(value: any): string {
if (value === null || value === undefined) return '-'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.includes('T')) {
// 可能是日期时间
try {
return new Date(value).toLocaleString()
} catch {
return value
}
}
return String(value)
}
// 组件挂载时加载表列表
onMounted(() => {
loadAvailableTables()
})
</script>
<style scoped>
.universal-query-builder {
max-width: 100%;
}
</style>