feat 个人中心
This commit is contained in:
588
src/components/CasesFilter.vue
Normal file
588
src/components/CasesFilter.vue
Normal file
@@ -0,0 +1,588 @@
|
||||
<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: 516px;
|
||||
padding: 15px 0 40px;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
|
||||
&__head,
|
||||
&__group,
|
||||
&__actions {
|
||||
padding: 0 50px 0 35px;
|
||||
}
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, center);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
line-height: 41px;
|
||||
color: @cases-filter-title;
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: @primary-strong;
|
||||
font-size: 16px;
|
||||
|
||||
&:hover {
|
||||
color: @primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&__group {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
&__head + &__group {
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 35px;
|
||||
color: @cases-text;
|
||||
}
|
||||
|
||||
&__rows {
|
||||
margin-top: 19px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0;
|
||||
color: @cases-text;
|
||||
font-size: 20px;
|
||||
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: 16px;
|
||||
height: 16px;
|
||||
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: 4px;
|
||||
width: 5px;
|
||||
height: 8px;
|
||||
border: solid @white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__row-label {
|
||||
flex: 1;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__row-count {
|
||||
color: @cases-muted;
|
||||
font-size: 16px;
|
||||
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: 24px 33px;
|
||||
margin-top: 21px;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
height: 53px;
|
||||
padding: 0 @spacing-sm;
|
||||
border-radius: @radius-base;
|
||||
background: @pill-bg;
|
||||
color: @cases-text;
|
||||
font-size: 20px;
|
||||
.ellipsis();
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-strong;
|
||||
}
|
||||
|
||||
&.is-active,
|
||||
&.is-light {
|
||||
background: @pill-selected-bg;
|
||||
color: @white;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ElDatePicker 有两层元素:外层 div.case-filter__date 与内部 .el-range-editor,
|
||||
// 真正控制盒子尺寸的是内层。所以尺寸/背景/圆角都要用 :deep() 打到内层,
|
||||
// 且不能写在 &__date 上——那样只命中外层,视觉上看不到变化。
|
||||
&__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: 53px;
|
||||
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: 53px;
|
||||
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: 92px;
|
||||
background: transparent;
|
||||
color: @cases-text;
|
||||
font-size: 20px;
|
||||
|
||||
&::placeholder {
|
||||
color: @cases-text;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-range-separator) {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 @spacing-xs;
|
||||
color: @cases-text;
|
||||
font-size: 20px;
|
||||
line-height: 53px;
|
||||
}
|
||||
|
||||
&.is-focus {
|
||||
background: @input-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&__selected {
|
||||
margin: 23px 50px 0 35px;
|
||||
padding: 16px 24px 21px;
|
||||
border-radius: @radius-base;
|
||||
background: @chips-panel-bg;
|
||||
}
|
||||
|
||||
&__selected-head {
|
||||
.flex(row, space-between, center);
|
||||
}
|
||||
|
||||
&__selected-title {
|
||||
color: @cases-text;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__selected-empty {
|
||||
margin-top: @spacing-sm;
|
||||
color: @cases-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__chips {
|
||||
.flex(row, flex-start, center);
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 13px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
height: 32px;
|
||||
padding: 0 @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @white;
|
||||
color: @cases-accent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, flex-start, center);
|
||||
gap: 13px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
&__reset {
|
||||
width: 135px;
|
||||
height: 30px;
|
||||
border-radius: @radius-round;
|
||||
background: @cases-reset-bg;
|
||||
color: @black;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
transition: filter 0.2s;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
&__confirm {
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
border-radius: @radius-round;
|
||||
background: @cases-confirm;
|
||||
color: @white;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @cases-accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user