提交
This commit is contained in:
1 parent
c3bd2c00ae
commit
a52fe77e34
39 files changed
+609647
-9238
No files matched your search
@@ -0,0 +1,512 @@
|
||||
<!-- 表头 Excel 风格列过滤:漏斗靠右,支持文本/枚举/数字/时间 -->
|
||||
<template>
|
||||
<el-popover
|
||||
:visible="visible"
|
||||
placement="bottom-end"
|
||||
:width="300"
|
||||
trigger="click"
|
||||
:teleported="true"
|
||||
popper-class="column-header-filter-popper"
|
||||
@update:visible="onVisibleChange"
|
||||
>
|
||||
<template #reference>
|
||||
<button
|
||||
type="button"
|
||||
class="column-header-filter-btn"
|
||||
:class="{ active: isActive }"
|
||||
:aria-label="`过滤${label}`"
|
||||
>
|
||||
<el-icon :size="14"><Filter /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="column-header-filter-panel" @click.stop>
|
||||
<div class="panel-hd">
|
||||
<div class="panel-title">过滤 · {{ label }}</div>
|
||||
<div class="panel-sub">{{ typeHint }}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-bd">
|
||||
<!-- 文本 / 枚举 -->
|
||||
<template v-if="type === 'text' || type === 'enum'">
|
||||
<div class="field">
|
||||
<label>搜索可选值</label>
|
||||
<el-input
|
||||
v-model="draftKeyword"
|
||||
clearable
|
||||
placeholder="输入关键字过滤下方列表"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div class="select-all-row">
|
||||
<span>本页可选值</span>
|
||||
<span>
|
||||
<el-button link type="primary" size="small" @click="selectAllVisible">全选</el-button>
|
||||
<el-button link type="primary" size="small" @click="selectNoneVisible"
|
||||
>全不选</el-button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="value-list">
|
||||
<label v-for="opt in visibleOptions" :key="opt.value" class="value-item">
|
||||
<el-checkbox
|
||||
:model-value="draftSelected.includes(opt.value)"
|
||||
@change="(v) => toggleSelected(opt.value, !!v)"
|
||||
>
|
||||
<span class="value-label">{{ opt.label }}</span>
|
||||
</el-checkbox>
|
||||
<span class="value-count">{{ opt.count }}</span>
|
||||
</label>
|
||||
<div v-if="!visibleOptions.length" class="empty-tip">无匹配项</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 数字 -->
|
||||
<template v-else-if="type === 'number'">
|
||||
<div class="field">
|
||||
<label>条件</label>
|
||||
<el-select v-model="draftOp" size="small" style="width: 100%">
|
||||
<el-option label="介于" value="between" />
|
||||
<el-option label="等于" value="eq" />
|
||||
<el-option label="大于" value="gt" />
|
||||
<el-option label="大于等于" value="gte" />
|
||||
<el-option label="小于" value="lt" />
|
||||
<el-option label="小于等于" value="lte" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="draftOp === 'between'" class="field">
|
||||
<label>数值区间</label>
|
||||
<div class="range-row">
|
||||
<el-input
|
||||
v-model="draftMin"
|
||||
type="number"
|
||||
size="small"
|
||||
placeholder="最小值"
|
||||
clearable
|
||||
/>
|
||||
<span class="sep">至</span>
|
||||
<el-input
|
||||
v-model="draftMax"
|
||||
type="number"
|
||||
size="small"
|
||||
placeholder="最大值"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="field">
|
||||
<label>数值</label>
|
||||
<el-input
|
||||
v-model="draftValue"
|
||||
type="number"
|
||||
size="small"
|
||||
placeholder="输入数值"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 时间 -->
|
||||
<template v-else-if="type === 'datetime'">
|
||||
<div class="field">
|
||||
<label>开始日期</label>
|
||||
<el-date-picker
|
||||
v-model="draftFrom"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="开始日期"
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>结束日期</label>
|
||||
<el-date-picker
|
||||
v-model="draftTo"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="结束日期"
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<p class="field-tip">闭区间:含起止当天</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="panel-ft">
|
||||
<el-button size="small" @click="clearColumn">
|
||||
<el-icon><Delete /></el-icon>清空本列
|
||||
</el-button>
|
||||
<div class="panel-ft-end">
|
||||
<el-button size="small" @click="close">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="confirm">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Filter, Delete, Close, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
isColumnFilterActive,
|
||||
type ColumnFilterType,
|
||||
type ColumnFilterState,
|
||||
type TextColumnFilterState,
|
||||
type NumberColumnFilterState,
|
||||
type DatetimeColumnFilterState,
|
||||
type NumberFilterOp
|
||||
} from '@/composables/useClientColumnFilters'
|
||||
|
||||
defineOptions({ name: 'ColumnHeaderFilter' })
|
||||
|
||||
const props = defineProps<{
|
||||
prop: string
|
||||
label: string
|
||||
type: ColumnFilterType
|
||||
isActive: boolean
|
||||
modelValue?: ColumnFilterState | null
|
||||
options?: Array<{ value: string; label: string; count: number }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ColumnFilterState | null]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const draftKeyword = ref('')
|
||||
const draftSelected = ref<string[]>([])
|
||||
const draftOp = ref<NumberFilterOp>('between')
|
||||
const draftValue = ref('')
|
||||
const draftMin = ref('')
|
||||
const draftMax = ref('')
|
||||
const draftFrom = ref('')
|
||||
const draftTo = ref('')
|
||||
|
||||
const typeHint = computed(() => {
|
||||
if (props.type === 'text') return '文本:搜索并勾选本页出现的值'
|
||||
if (props.type === 'enum') return '枚举:勾选固定选项'
|
||||
if (props.type === 'number') return '数字:运算符 + 单值或区间'
|
||||
return '时间:按日期闭区间过滤'
|
||||
})
|
||||
|
||||
const visibleOptions = computed(() => {
|
||||
const list = props.options || []
|
||||
const q = draftKeyword.value.trim().toLowerCase()
|
||||
if (!q) return list
|
||||
return list.filter(
|
||||
(opt) => opt.label.toLowerCase().includes(q) || String(opt.value).toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
function resetDraftFromModel() {
|
||||
const m = props.modelValue
|
||||
if (props.type === 'text' || props.type === 'enum') {
|
||||
const s = (m || { selected: [], keyword: '' }) as TextColumnFilterState
|
||||
draftSelected.value = [...(s.selected || [])]
|
||||
draftKeyword.value = s.keyword || ''
|
||||
return
|
||||
}
|
||||
if (props.type === 'number') {
|
||||
const s = (m || { op: 'between', value: '', min: '', max: '' }) as NumberColumnFilterState
|
||||
draftOp.value = s.op || 'between'
|
||||
draftValue.value = s.value ?? ''
|
||||
draftMin.value = s.min ?? ''
|
||||
draftMax.value = s.max ?? ''
|
||||
return
|
||||
}
|
||||
if (props.type === 'datetime') {
|
||||
const s = (m || { from: '', to: '' }) as DatetimeColumnFilterState
|
||||
draftFrom.value = s.from || ''
|
||||
draftTo.value = s.to || ''
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibleChange(next: boolean) {
|
||||
if (next) resetDraftFromModel()
|
||||
visible.value = next
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function toggleSelected(value: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!draftSelected.value.includes(value)) draftSelected.value.push(value)
|
||||
} else {
|
||||
draftSelected.value = draftSelected.value.filter((x) => x !== value)
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllVisible() {
|
||||
for (const opt of visibleOptions.value) {
|
||||
if (!draftSelected.value.includes(opt.value)) draftSelected.value.push(opt.value)
|
||||
}
|
||||
}
|
||||
|
||||
function selectNoneVisible() {
|
||||
const hide = new Set(visibleOptions.value.map((o) => o.value))
|
||||
draftSelected.value = draftSelected.value.filter((v) => !hide.has(v))
|
||||
}
|
||||
|
||||
function buildState(): ColumnFilterState {
|
||||
if (props.type === 'text' || props.type === 'enum') {
|
||||
return {
|
||||
selected: [...draftSelected.value],
|
||||
keyword: draftKeyword.value
|
||||
}
|
||||
}
|
||||
if (props.type === 'number') {
|
||||
return {
|
||||
op: draftOp.value,
|
||||
value: draftValue.value,
|
||||
min: draftMin.value,
|
||||
max: draftMax.value
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: draftFrom.value || '',
|
||||
to: draftTo.value || ''
|
||||
}
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.type === 'number' && draftOp.value === 'between') {
|
||||
if (
|
||||
draftMin.value !== '' &&
|
||||
draftMax.value !== '' &&
|
||||
Number(draftMin.value) > Number(draftMax.value)
|
||||
) {
|
||||
ElMessage.warning('最小值不能大于最大值')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (props.type === 'datetime') {
|
||||
if (draftFrom.value && draftTo.value && draftFrom.value > draftTo.value) {
|
||||
ElMessage.warning('开始日期不能晚于结束日期')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const state = buildState()
|
||||
if (isColumnFilterActive(props.type, state)) {
|
||||
emit('update:modelValue', state)
|
||||
} else {
|
||||
emit('update:modelValue', null)
|
||||
emit('clear')
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
function clearColumn() {
|
||||
emit('update:modelValue', null)
|
||||
emit('clear')
|
||||
close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (visible.value) resetDraftFromModel()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.column-header-filter-btn {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--mint-text, #1e293b);
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
|
||||
.column-header-filter-panel {
|
||||
margin: -4px;
|
||||
}
|
||||
|
||||
.panel-hd {
|
||||
padding: 4px 4px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter, #e2e8f0);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--mint-text, #1e293b);
|
||||
}
|
||||
|
||||
.panel-sub {
|
||||
font-size: 11px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.panel-bd {
|
||||
max-height: 280px;
|
||||
padding: 10px 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 10px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.range-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sep {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.select-all-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.value-list {
|
||||
max-height: 180px;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--el-border-color-lighter, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.value-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-bg, #ecfdf5);
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.value-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 16px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-ft {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 4px 4px;
|
||||
border-top: 1px solid var(--el-border-color-lighter, #e2e8f0);
|
||||
}
|
||||
|
||||
.panel-ft-end {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.column-header-filter-popper.el-popover {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.col-header-with-filter {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.col-header-with-filter__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-header-with-filter__label.is-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.col-header-with-filter__label.is-right {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ import { PL_PLAN_TYPE_TABS } from '@/utils/pl-display'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
/** 覆盖默认单据类型 Tab(如危化品证上传页追加「未上传证书」) */
|
||||
/** 覆盖默认单据类型 Tab(如运输计划追加「待安排」、危化品证上传页追加「未上传证书」) */
|
||||
tabs?: { label: string; name: string }[]
|
||||
}>()
|
||||
|
||||
|
||||
Reference in new issue
Block a user