feat 个人中心

This commit is contained in:
Ironsp
2026-10-10 09:57:56 +08:00
parent 53f23d4e69
commit 0f038e7818
35 changed files with 2346 additions and 1656 deletions

View 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>