Files
cz-web/src/components/CasesFilter.vue
2026-10-13 15:12:51 +08:00

593 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue'
import { ElDatePicker } from 'element-plus'
import type { FilterChip } from '@/components'
import { formatDate } from '@/utils/format'
import type { BusinessIndustryOption, CaseQuery, CaseTypeOption, ScaleLimit } from '@/types/business/cases'
interface Props {
query: CaseQuery
typeOptions: CaseTypeOption[]
industryOptions: BusinessIndustryOption[]
scaleOptions: ScaleLimit[]
caseTotal: number
previewTotal: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:query': [query: CaseQuery]
search: []
reset: []
}>()
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
const timeShortcuts: Array<{ label: string; value: Exclude<TimeShortcut, 'custom'>; months: number }> = [
{ label: '全部', value: 'all', months: 0 },
{ label: '近3个月', value: '3m', months: 3 },
{ label: '近半年', value: '6m', months: 6 },
{ label: '近一年', value: '1y', months: 12 },
]
function scaleKey(item: ScaleLimit): string {
return `${item.lowerLimit ?? ''}-${item.upperLimit ?? ''}`
}
function scaleLabel(item: ScaleLimit): string {
return item.label ?? `${item.lowerLimit ?? 0}-${item.upperLimit ?? '不限'}万`
}
function rangeOf(months: number): [string, string] {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - months)
return [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')]
}
const activeShortcut = computed<TimeShortcut>(() => {
const range = props.query.dateRange
if (!range) return 'all'
const hit = timeShortcuts.find(
(item) => item.value !== 'all' && rangeOf(item.months).join() === range.join(),
)
return hit ? hit.value : 'custom'
})
const dateRange = computed<[string, string] | null>({
get: () => props.query.dateRange,
set: (value) => patch({ dateRange: value }),
})
const chips = computed<FilterChip[]>(() => {
const result: FilterChip[] = []
for (const value of props.query.categories) {
const option = props.typeOptions.find((item) => item.value === value)
result.push({ key: `category:${value}`, label: option?.label ?? value })
}
for (const name of props.query.industries) {
result.push({ key: `industry:${name}`, label: name })
}
for (const scale of props.query.scales) {
result.push({ key: `scale:${scaleKey(scale)}`, label: scaleLabel(scale) })
}
if (props.query.dateRange) {
const label = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
result.push({
key: 'time',
label: label ?? `${props.query.dateRange[0]}-${props.query.dateRange[1]}`,
})
}
return result
})
function patch(partial: Partial<CaseQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
function toggleIn<T>(list: T[], value: T): T[] {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
}
function toggleScale(item: ScaleLimit): void {
const key = scaleKey(item)
const exists = props.query.scales.some((scale) => scaleKey(scale) === key)
patch({
scales: exists
? props.query.scales.filter((scale) => scaleKey(scale) !== key)
: [...props.query.scales, { ...item }],
})
}
function setShortcut(value: Exclude<TimeShortcut, 'custom'>): void {
if (value === 'all') {
patch({ dateRange: null })
return
}
const shortcut = timeShortcuts.find((item) => item.value === value)
if (shortcut) patch({ dateRange: rangeOf(shortcut.months) })
}
function onRemoveChip(key: string): void {
if (key === 'time') {
patch({ dateRange: null })
return
}
if (key.startsWith('category:')) {
const value = key.slice('category:'.length)
patch({ categories: props.query.categories.filter((item) => item !== value) })
return
}
if (key.startsWith('industry:')) {
const name = key.slice('industry:'.length)
patch({ industries: props.query.industries.filter((item) => item !== name) })
return
}
if (key.startsWith('scale:')) {
const target = key.slice('scale:'.length)
patch({ scales: props.query.scales.filter((item) => scaleKey(item) !== target) })
}
}
function clearChips(): void {
patch({ categories: [], industries: [], scales: [], dateRange: null })
}
</script>
<template>
<aside class="case-filter">
<div class="case-filter__head">
<h2 class="case-filter__title">
筛选条件
</h2>
<button class="case-filter__link" @click="emit('reset')">
重置
</button>
</div>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
业务类型
</h3>
<ul class="case-filter__rows">
<li>
<button
class="case-filter__row"
:class="{ 'is-active': query.categories.length === 0 }"
@click="patch({ categories: [] })"
>
<span class="case-filter__box" />
<span class="case-filter__row-label">全部</span>
<span class="case-filter__row-count">{{ caseTotal }}</span>
</button>
</li>
<li v-for="option in typeOptions" :key="option.value">
<button
class="case-filter__row"
:class="{ 'is-active': query.categories.includes(option.value) }"
@click="patch({ categories: toggleIn(query.categories, option.value) })"
>
<span class="case-filter__box" />
<span class="case-filter__row-label">{{ option.label }}</span>
<span class="case-filter__row-count">{{ option.count }}</span>
</button>
</li>
</ul>
</section>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
所属行业
</h3>
<div class="case-filter__pills">
<button
class="case-filter__pill"
:class="{ 'is-light': query.industries.length === 0 }"
@click="patch({ industries: [] })"
>
全部
</button>
<button
v-for="option in industryOptions"
:key="option.name ?? ''"
class="case-filter__pill"
:class="{ 'is-active': query.industries.includes(option.name ?? '') }"
@click="patch({ industries: toggleIn(query.industries, option.name ?? '') })"
>
{{ option.name }}
</button>
</div>
</section>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
项目规模
</h3>
<div class="case-filter__pills">
<button
class="case-filter__pill"
:class="{ 'is-light': query.scales.length === 0 }"
@click="patch({ scales: [] })"
>
全部
</button>
<button
v-for="option in scaleOptions"
:key="scaleKey(option)"
class="case-filter__pill"
:class="{ 'is-active': query.scales.some((item) => scaleKey(item) === scaleKey(option)) }"
@click="toggleScale(option)"
>
{{ scaleLabel(option) }}
</button>
</div>
</section>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
完成时间
</h3>
<div class="case-filter__pills">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="case-filter__pill"
:class="{
'is-light': shortcut.value === 'all' && activeShortcut === 'all',
'is-active': shortcut.value !== 'all' && activeShortcut === shortcut.value,
}"
@click="setShortcut(shortcut.value)"
>
{{ shortcut.label }}
</button>
<div class="case-filter__date-wrap">
<ElDatePicker
v-model="dateRange"
class="case-filter__date"
type="daterange"
range-separator="—"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
/>
</div>
</div>
</section>
<section class="case-filter__selected">
<div class="case-filter__selected-head">
<span class="case-filter__selected-title">已选条件({{ chips.length }}个)</span>
<button class="case-filter__link" @click="clearChips">
清空
</button>
</div>
<div v-if="chips.length > 0" class="case-filter__chips">
<button
v-for="chip in chips"
:key="chip.key"
class="case-filter__chip"
:title="`移除 ${chip.label}`"
@click="onRemoveChip(chip.key)"
>
{{ chip.label }} ×
</button>
</div>
<p v-else class="case-filter__selected-empty">
暂无已选条件
</p>
</section>
<div class="case-filter__actions">
<button class="case-filter__reset" @click="emit('reset')">
重置
</button>
<button class="case-filter__confirm" @click="emit('search')">
确定筛选({{ previewTotal }}个结果)
</button>
</div>
</aside>
</template>
<style lang="less" scoped>
.case-filter {
flex-shrink: 0;
width: @cases-filter-width;
padding: @spacing-lg 0 @spacing-xl;
border-radius: @radius-base;
background: @white;
&__head,
&__group,
&__actions {
padding: 0 @spacing-lg;
}
&__head {
.flex(row, space-between, center);
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
line-height: 1.4;
color: @cases-filter-title;
}
&__link {
color: @primary-strong;
font-size: @font-size-sm;
&:hover {
color: @primary-hover;
}
}
&__group {
margin-top: @spacing-lg;
}
&__head + &__group {
margin-top: @spacing-base;
}
&__group-title {
font-size: @font-size-md;
font-weight: 500;
line-height: 1.4;
color: @cases-text;
}
&__rows {
margin-top: @spacing-sm;
display: flex;
flex-direction: column;
gap: 2px;
}
&__row {
.flex(row, flex-start, center);
gap: @spacing-sm;
width: 100%;
height: 40px;
padding: 0 @spacing-sm;
border-radius: @radius-sm;
color: @cases-text;
font-size: @font-size-base;
text-align: left;
transition: background 0.2s;
&:hover {
background: @bg-color-light;
}
&.is-active {
background: @filter-selected-bg;
color: @primary-strong;
font-weight: 600;
}
}
&__box {
position: relative;
flex-shrink: 0;
width: 14px;
height: 14px;
border: 1px solid @cases-input-border;
border-radius: 2px;
.case-filter__row.is-active & {
border-color: @primary-strong;
background: @primary-strong;
&::after {
content: '';
position: absolute;
top: 1px;
left: 3px;
width: 4px;
height: 7px;
border: solid @white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
&__row-label {
flex: 1;
.ellipsis();
}
&__row-count {
color: @cases-muted;
font-size: @font-size-sm;
font-weight: 500;
}
.case-filter__row.is-active &__row-count {
color: inherit;
font-weight: inherit;
}
&__pills {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: @spacing-sm;
margin-top: @spacing-sm;
}
&__pill {
height: 36px;
padding: 0 @spacing-sm;
border-radius: @radius-base;
background: @pill-bg;
color: @cases-text;
font-size: @font-size-sm;
.ellipsis();
transition: all 0.2s;
&:hover {
color: @primary-strong;
}
&.is-active,
&.is-light {
background: @filter-selected-bg;
color: @primary-strong;
font-weight: 500;
}
}
// ElDatePicker 有两层元素:外层 div.case-filter__date 与内部 .el-range-editor,
// 真正控制盒子尺寸的是内层。所以尺寸/背景/圆角都要用 :deep() 打到内层。
&__date-wrap {
grid-column: 1 / -1;
width: 100%;
:deep(.case-filter__date),
:deep(.case-filter__date .el-range-editor) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
height: 36px;
padding: 0 @spacing-sm;
border-radius: @radius-base;
background: @input-bg;
box-sizing: border-box;
box-shadow: none;
justify-content: center;
}
}
&__date {
justify-content: center;
min-width: 0;
height: 36px;
padding: 0 @spacing-sm;
border-radius: @radius-base;
background: @input-bg;
box-sizing: border-box;
box-shadow: none;
&:hover,
&.is-active,
&.is-focus {
box-shadow: none;
}
:deep(.el-range__icon),
:deep(.el-range__close-icon) {
display: none;
}
:deep(.el-range-input) {
flex: 0 1 auto;
width: 88px;
background: transparent;
color: @cases-text;
font-size: @font-size-sm;
&::placeholder {
color: @cases-muted;
}
}
:deep(.el-range-separator) {
flex: 0 0 auto;
margin: 0 @spacing-xs;
color: @cases-muted;
font-size: @font-size-sm;
line-height: 36px;
}
&.is-focus {
background: @white;
box-shadow: 0 0 0 1px @cases-accent inset;
}
}
&__selected {
margin: @spacing-lg @spacing-lg 0;
padding: @spacing-base;
border-radius: @radius-base;
background: @chips-panel-bg;
}
&__selected-head {
.flex(row, space-between, center);
}
&__selected-title {
color: @cases-text;
font-size: @font-size-base;
font-weight: 500;
}
&__selected-empty {
margin-top: @spacing-sm;
color: @cases-muted;
font-size: @font-size-sm;
}
&__chips {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
margin-top: @spacing-sm;
}
&__chip {
height: 28px;
padding: 0 @spacing-sm;
border-radius: @radius-round;
background: @white;
color: @cases-accent;
font-size: @font-size-sm;
font-weight: 500;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
&__actions {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-top: @spacing-lg;
}
&__reset {
flex: 0 0 88px;
height: 32px;
border-radius: @radius-round;
background: @cases-reset-bg;
color: @text-color;
font-size: @font-size-sm;
font-weight: 400;
transition: color 0.2s;
&:hover {
color: @primary-color;
}
}
&__confirm {
flex: 1;
height: 32px;
border-radius: @radius-round;
background: @cases-confirm;
color: @white;
font-size: @font-size-sm;
font-weight: 400;
transition: background 0.2s;
&:hover {
background: @cases-accent;
}
}
}
</style>