593 lines
14 KiB
Vue
593 lines
14 KiB
Vue
<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>
|