pagetype列表页增加过滤栏

This commit is contained in:
jingrow 2025-10-25 22:57:44 +08:00
parent 33dd3ffc6e
commit 31430cf6b5
5 changed files with 614 additions and 13 deletions

6
.gitignore vendored
View File

@ -11,17 +11,14 @@ dump.rdb
*.rdb
redis.conf.bak
# Jingrow Local 前端
# Jingrow 前端
node_modules
frontend/dist/
frontend/node_modules/
frontend/.env.local
frontend/.env.test
frontend/.env.production
frontend/public/files/
# Jingrow Framework 前端
apps/jingrow/frontend/public/files/
# 忽略名为 test 的文件夹
test/
@ -29,6 +26,7 @@ test/
# 忽略所有 文件夹
**/frontend/public/files
**/jfile/files/
**/output/
**/__pycache__/

View File

@ -0,0 +1,429 @@
<template>
<div class="elegant-filter-bar">
<!-- 过滤条件内容 -->
<div class="filter-content">
<div v-if="filterableFields.length === 0" class="empty-state">
<i class="fa fa-filter"></i>
<span>{{ t('暂无可过滤的字段') }}</span>
</div>
<div v-else class="filter-row">
<div
v-for="field in filterableFields"
:key="field.fieldname"
class="filter-item"
:class="{ 'has-value': filters[field.fieldname] }"
>
<!-- 文本输入框 -->
<div v-if="isTextField(field.fieldtype)" class="filter-input">
<n-input
v-model:value="filters[field.fieldname]"
:placeholder="getFieldPlaceholder(field)"
clearable
size="small"
@input="onFilterChange"
/>
</div>
<!-- 选择框 -->
<div v-else-if="field.fieldtype === 'Select'" class="filter-input">
<n-select
v-model:value="filters[field.fieldname]"
:options="getSelectOptions(field)"
:placeholder="getFieldPlaceholder(field)"
clearable
size="small"
@update:value="onFilterChange"
/>
</div>
<!-- 多选框 -->
<div v-else-if="isMultiSelectField(field.fieldtype)" class="filter-input">
<n-select
v-model:value="filters[field.fieldname]"
:options="getSelectOptions(field)"
multiple
:placeholder="getFieldPlaceholder(field)"
clearable
size="small"
@update:value="onFilterChange"
/>
</div>
<!-- 复选框 -->
<div v-else-if="field.fieldtype === 'Check'" class="filter-input">
<n-select
v-model:value="filters[field.fieldname]"
:options="[
{ label: t('是'), value: 1 },
{ label: t('否'), value: 0 }
]"
:placeholder="getFieldPlaceholder(field)"
clearable
size="small"
@update:value="onFilterChange"
/>
</div>
<!-- 数字输入框 -->
<div v-else-if="isNumberField(field.fieldtype)" class="filter-input">
<n-input-number
v-model:value="filters[field.fieldname]"
:placeholder="getFieldPlaceholder(field)"
clearable
size="small"
@update:value="onFilterChange"
/>
</div>
<!-- 日期选择器 -->
<div v-else-if="isDateField(field.fieldtype)" class="filter-input">
<n-date-picker
v-model:value="filters[field.fieldname]"
:placeholder="getFieldPlaceholder(field)"
clearable
size="small"
@update:value="onFilterChange"
/>
</div>
<!-- 其他字段类型使用文本输入 -->
<div v-else class="filter-input">
<n-input
v-model:value="filters[field.fieldname]"
:placeholder="getFieldPlaceholder(field)"
clearable
size="small"
@input="onFilterChange"
/>
</div>
</div>
<!-- 操作按钮 -->
<div class="filter-actions">
<button
v-if="hasActiveFilters"
class="action-btn clear-btn"
@click="clearAllFilters"
:title="t('清除所有过滤条件')"
>
<i class="fa fa-times"></i>
</button>
<button
class="action-btn save-btn"
@click="showSaveDialog = true"
:title="t('保存当前过滤条件')"
>
<i class="fa fa-bookmark"></i>
</button>
</div>
</div>
</div>
<!-- 保存过滤条件对话框 -->
<n-modal v-model:show="showSaveDialog">
<n-card
style="width: 400px"
:title="t('保存筛选条件')"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-form :model="saveForm" ref="saveFormRef">
<n-form-item :label="t('过滤器名称')" path="name">
<n-input
v-model:value="saveForm.name"
:placeholder="t('请输入过滤器名称')"
size="small"
/>
</n-form-item>
</n-form>
<template #footer>
<div class="dialog-footer">
<n-button @click="showSaveDialog = false" size="small">
{{ t('取消') }}
</n-button>
<n-button type="primary" @click="saveFilter" size="small">
{{ t('保存') }}
</n-button>
</div>
</template>
</n-card>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { NInput, NSelect, NInputNumber, NDatePicker, NModal, NCard, NForm, NFormItem, NButton, useMessage } from 'naive-ui'
import { t } from '@/shared/i18n'
interface FilterField {
fieldname: string
label: string
fieldtype: string
options?: string
in_list_view?: boolean
in_standard_filter?: boolean
}
interface Props {
fields: FilterField[]
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'filter-change', filters: Record<string, any>): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const message = useMessage()
const showSaveDialog = ref(false)
const saveForm = ref({ name: '' })
const saveFormRef = ref()
//
const filters = ref<Record<string, any>>({ ...props.modelValue })
// in_standard_filter
const filterableFields = computed(() => {
const isFilterable = (f: FilterField) => !['Section Break', 'Column Break', 'Tab Break'].includes(f.fieldtype)
const filterable = props.fields.filter(isFilterable)
// in_standard_filter
const standardFilterFields = filterable.filter(f => f.in_standard_filter)
return standardFilterFields.slice(0, 8) // 8
})
//
const activeFilterCount = computed(() => {
return Object.values(filters.value).filter(value =>
value !== null && value !== undefined && value !== '' &&
!(Array.isArray(value) && value.length === 0)
).length
})
//
const hasActiveFilters = computed(() => activeFilterCount.value > 0)
//
function isTextField(fieldtype: string): boolean {
return ['Data', 'Text', 'Long Text', 'Comment'].includes(fieldtype)
}
function isMultiSelectField(fieldtype: string): boolean {
return ['MultiSelect', 'MultiSelect Pills', 'MultiSelect List'].includes(fieldtype)
}
function isNumberField(fieldtype: string): boolean {
return ['Int', 'Float', 'Currency', 'Percent'].includes(fieldtype)
}
function isDateField(fieldtype: string): boolean {
return ['Date', 'Datetime'].includes(fieldtype)
}
//
function getFieldPlaceholder(field: FilterField): string {
const label = field.label || field.fieldname
return t(label)
}
//
function getSelectOptions(field: FilterField) {
if (!field.options) return []
const options = field.options.split('\n').filter((s: string) => s.trim() !== '')
return options.map((opt: string) => ({ label: t(opt), value: opt }))
}
//
function onFilterChange() {
emit('update:modelValue', { ...filters.value })
emit('filter-change', { ...filters.value })
}
//
function clearAllFilters() {
filters.value = {}
onFilterChange()
}
//
function saveFilter() {
if (!saveForm.value.name.trim()) {
message.warning(t('请输入过滤器名称'))
return
}
//
const savedFilters = JSON.parse(localStorage.getItem('savedFilters') || '{}')
savedFilters[saveForm.value.name] = { ...filters.value }
localStorage.setItem('savedFilters', JSON.stringify(savedFilters))
message.success(t('过滤条件已保存'))
showSaveDialog.value = false
saveForm.value.name = ''
}
//
watch(() => props.modelValue, (newValue) => {
filters.value = { ...newValue }
}, { deep: true })
</script>
<style scoped>
.elegant-filter-bar {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.filter-content {
padding: 12px 16px;
}
.empty-state {
display: flex;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 13px;
padding: 8px 0;
}
.empty-state i {
color: #9ca3af;
}
.filter-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
padding: 4px 8px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
transition: all 0.2s ease;
min-width: 0;
}
.filter-item.has-value {
background: #f0f9ff;
border-color: #3b82f6;
}
.filter-item:hover {
border-color: #cbd5e1;
}
.filter-input {
min-width: 120px;
max-width: 200px;
}
.filter-input :deep(.n-input),
.filter-input :deep(.n-select),
.filter-input :deep(.n-date-picker),
.filter-input :deep(.n-input-number) {
border: none;
background: transparent;
box-shadow: none;
}
.filter-input :deep(.n-input:focus),
.filter-input :deep(.n-select:focus),
.filter-input :deep(.n-date-picker:focus),
.filter-input :deep(.n-input-number:focus) {
border: none;
box-shadow: none;
}
.filter-actions {
display: flex;
gap: 4px;
margin-left: auto;
}
.action-btn {
width: 24px;
height: 24px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: white;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: all 0.2s ease;
}
.action-btn:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.clear-btn:hover {
background: #fef2f2;
border-color: #fca5a5;
color: #dc2626;
}
.save-btn:hover {
background: #f0f9ff;
border-color: #93c5fd;
color: #2563eb;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.filter-row {
flex-direction: column;
align-items: stretch;
}
.filter-item {
width: 100%;
justify-content: center;
}
.filter-input {
min-width: 150px;
max-width: none;
}
.filter-actions {
margin-left: 0;
justify-content: flex-end;
margin-top: 8px;
}
}
/* 确保输入框完全显示 */
.filter-input :deep(.n-input),
.filter-input :deep(.n-select),
.filter-input :deep(.n-date-picker),
.filter-input :deep(.n-input-number) {
width: 100%;
}
</style>

View File

@ -11,6 +11,21 @@
<div class="filters">
<n-input v-model:value="searchQuery" :placeholder="t('Search')" clearable style="width: 200px" />
</div>
<!-- 活跃过滤条件标签 -->
<div v-if="activeFilterTags.length > 0" class="active-filters">
<div class="filter-tags">
<span v-for="tag in activeFilterTags" :key="tag.field" class="filter-tag">
<span class="tag-content">
<span class="tag-label">{{ tag.label }}</span>
<span class="tag-separator">:</span>
<span class="tag-value">{{ tag.value }}</span>
</span>
<button @click="removeFilter(tag.field)" class="remove-filter-btn">
<i class="fa fa-times"></i>
</button>
</span>
</div>
</div>
<div class="view-toggle">
<button
class="toggle-btn"
@ -54,6 +69,14 @@
</div>
<div class="page-content">
<!-- 过滤栏 -->
<FilterBar
v-if="!isSinglePage && metaFields.length > 0"
:fields="metaFields"
v-model="filters"
@filter-change="onFilterChange"
/>
<div v-if="loading" class="loading">
<i class="fa fa-spinner fa-spin"></i> {{ t('Loading...') }}
</div>
@ -174,6 +197,7 @@ import { get_session_api_headers } from '@/shared/api/auth'
import { usePageTypeSlug } from '@/shared/utils/slug'
import { isSinglePageType } from '@/shared/utils/pagetype'
import SinglePageDetail from './SinglePageDetail.vue'
import FilterBar from '@/core/components/FilterBar.vue'
const route = useRoute()
const router = useRouter()
@ -201,6 +225,49 @@ const viewMode = ref<'card' | 'list'>(
(localStorage.getItem(`genericListViewMode:${entity.value}`) as 'card' | 'list') || 'list'
)
//
const filters = ref<Record<string, any>>({})
//
const activeFilterTags = computed(() => {
const tags: Array<{field: string, label: string, value: string}> = []
Object.entries(filters.value).forEach(([fieldName, value]) => {
if (value !== null && value !== undefined && value !== '' &&
!(Array.isArray(value) && value.length === 0)) {
const field = metaFields.value.find(f => f.fieldname === fieldName)
const label = field?.label || fieldName
let displayValue = value
if (Array.isArray(value)) {
displayValue = value.join(', ')
} else if (field?.fieldtype === 'Check') {
displayValue = value === 1 ? t('是') : t('否')
} else if (field?.options) {
//
const options = field.options.split('\n').filter((s: string) => s.trim() !== '')
if (options.includes(value)) {
displayValue = t(value)
}
}
tags.push({ field: fieldName, label, value: displayValue })
}
})
return tags
})
//
function removeFilter(fieldName: string) {
filters.value[fieldName] = null
onFilterChange()
}
//
function onFilterChange() {
page.value = 1 //
loadData()
}
const displayColumns = computed(() => {
// in_list_view
const isDisplayable = (f: any) => !['Section Break', 'Column Break', 'Tab Break'].includes(f.fieldtype)
@ -243,13 +310,36 @@ async function loadData() {
const listUrl = `/api/data/${encodeURIComponent(entity.value)}`
//
const fieldNames = displayColumns.value.map((c: any) => c.key).filter((k: string) => k && k !== 'actions')
//
const params: any = {
fields: JSON.stringify(fieldNames),
limit_start: (page.value - 1) * pageSize.value,
limit_page_length: pageSize.value,
order_by: 'modified desc'
}
//
const filterConditions: any[] = []
Object.entries(filters.value).forEach(([fieldName, value]) => {
if (value !== null && value !== undefined && value !== '' &&
!(Array.isArray(value) && value.length === 0)) {
if (Array.isArray(value)) {
// 使 in
filterConditions.push([fieldName, 'in', value])
} else {
// 使 =
filterConditions.push([fieldName, '=', value])
}
}
})
if (filterConditions.length > 0) {
params.filters = JSON.stringify(filterConditions)
}
const res = await axios.get(listUrl, {
params: {
fields: JSON.stringify(fieldNames),
limit_start: (page.value - 1) * pageSize.value,
limit_page_length: pageSize.value,
order_by: 'modified desc'
},
params,
headers: get_session_api_headers(), withCredentials: true
})
rows.value = res.data?.data || []
@ -294,6 +384,7 @@ watch(() => route.params.entity, async (newEntity, oldEntity) => {
page.value = 1
searchQuery.value = ''
selectedKeys.value = []
filters.value = {} //
//
await loadMeta()
//
@ -610,6 +701,74 @@ function formatDisplayValue(value: any, fieldName: string) {
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
/* 简洁的活跃过滤条件标签样式 */
.active-filters {
margin-right: 12px;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.filter-tag {
display: inline-flex;
align-items: center;
background: #3b82f6;
color: white;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
overflow: hidden;
transition: all 0.2s ease;
}
.filter-tag:hover {
background: #2563eb;
}
.tag-content {
display: flex;
align-items: center;
padding: 2px 6px;
}
.tag-label {
font-weight: 600;
}
.tag-separator {
margin: 0 2px;
opacity: 0.8;
}
.tag-value {
opacity: 0.9;
}
.remove-filter-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
cursor: pointer;
padding: 0;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0 3px 3px 0;
transition: all 0.2s ease;
}
.remove-filter-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.remove-filter-btn i {
font-size: 7px;
}
</style>

View File

@ -833,5 +833,17 @@
"Name Z-A": "名称 Z-A",
"Most Popular": "最受欢迎",
"Applications": "应用列表",
"applications": "个应用"
"applications": "个应用",
"是": "是",
"否": "否",
"暂无可过滤的字段": "暂无可过滤的字段",
"清除所有过滤条件": "清除所有过滤条件",
"清除": "清除",
"保存当前过滤条件": "保存当前过滤条件",
"保存": "保存",
"保存筛选条件": "保存筛选条件",
"过滤器名称": "过滤器名称",
"请输入过滤器名称": "请输入过滤器名称",
"取消": "取消"
}

View File

@ -46,13 +46,15 @@
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"in_standard_filter": 1,
"label": "状态",
"options": "\n草稿\n待执行\n进行中\n未完成\n已完成"
},
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "已启用"
},
{
@ -114,6 +116,7 @@
"fieldtype": "Data",
"in_global_search": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "智能体名称",
"reqd": 1
},