This commit is contained in:
zlwu committed 2026-08-19 09:14:37 +08:00
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 }[]
}>()