feat:首页完成
This commit is contained in:
185
src/pages/case-library/components/CaseCard.vue
Normal file
185
src/pages/case-library/components/CaseCard.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import type { CaseItem } from '@/types/case-library'
|
||||
|
||||
defineProps<{
|
||||
item: CaseItem
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleFavorite: [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="case-card">
|
||||
<view class="case-card__cover-wrap">
|
||||
<image
|
||||
class="case-card__cover"
|
||||
:src="item.cover"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text class="case-card__tag">
|
||||
{{ item.category }}
|
||||
</text>
|
||||
<view
|
||||
class="case-card__favorite"
|
||||
@tap.stop="emit('toggleFavorite', item.id)"
|
||||
>
|
||||
<image
|
||||
class="case-card__favorite-icon"
|
||||
:src="item.favorite ? '/static/icons/icon-heart-filled.svg' : '/static/icons/icon-heart.svg'"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="case-card__title">
|
||||
{{ item.title }}
|
||||
</text>
|
||||
|
||||
<view class="case-card__meta">
|
||||
<view class="case-card__meta-row">
|
||||
<view class="case-card__meta-item">
|
||||
<text class="case-card__meta-label">
|
||||
客户类型
|
||||
</text>
|
||||
<text class="case-card__meta-value">
|
||||
{{ item.clientType }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="case-card__meta-item">
|
||||
<text class="case-card__meta-label">
|
||||
所属行业
|
||||
</text>
|
||||
<text class="case-card__meta-value">
|
||||
{{ item.industry }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="case-card__meta-item">
|
||||
<text class="case-card__meta-label">
|
||||
规模
|
||||
</text>
|
||||
<text class="case-card__meta-value">
|
||||
{{ item.scale }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="case-card__meta-row">
|
||||
<view class="case-card__meta-item">
|
||||
<text class="case-card__meta-label">
|
||||
项目周期
|
||||
</text>
|
||||
<text class="case-card__meta-value">
|
||||
{{ item.cycle }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="case-card__meta-item">
|
||||
<text class="case-card__meta-label">
|
||||
服务团队
|
||||
</text>
|
||||
<text class="case-card__meta-value">
|
||||
{{ item.teamSize }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="case-card__meta-item">
|
||||
<text class="case-card__meta-label">
|
||||
完成时间
|
||||
</text>
|
||||
<text class="case-card__meta-value">
|
||||
{{ item.completedAt }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.case-card {
|
||||
padding: 0 0 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.case-card__cover-wrap {
|
||||
position: relative;
|
||||
height: 320rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.case-card__cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.case-card__tag {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
left: 20rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
font-size: 22rpx;
|
||||
color: #ffffff;
|
||||
background: $color-primary;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.case-card__favorite {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.case-card__favorite-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.case-card__title {
|
||||
display: block;
|
||||
padding: 24rpx 24rpx 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.case-card__meta {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.case-card__meta-row {
|
||||
display: flex;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.case-card__meta-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.case-card__meta-label {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.case-card__meta-value {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
</style>
|
||||
526
src/pages/case-library/components/CaseFilterPanel.vue
Normal file
526
src/pages/case-library/components/CaseFilterPanel.vue
Normal file
@@ -0,0 +1,526 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
CASE_FILTER_INDUSTRIES,
|
||||
CASE_FILTER_SCALES,
|
||||
CASE_FILTER_TIME_RANGES,
|
||||
DEFAULT_CASE_FILTER,
|
||||
} from '@/constants/case-library'
|
||||
import type { CaseFilterValues, CaseItem } from '@/types/case-library'
|
||||
import {
|
||||
cloneCaseFilter,
|
||||
getSelectedFilterTags,
|
||||
matchCaseFilter,
|
||||
removeFilterTag,
|
||||
} from '@/utils/case-filter'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
cases: CaseItem[]
|
||||
value: CaseFilterValues
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [value: CaseFilterValues]
|
||||
}>()
|
||||
|
||||
const draft = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible)
|
||||
draft.value = cloneCaseFilter(props.value)
|
||||
},
|
||||
)
|
||||
|
||||
const selectedTags = computed(() => getSelectedFilterTags(draft.value))
|
||||
|
||||
const previewCount = computed(() => {
|
||||
return props.cases.filter(item => matchCaseFilter(item, draft.value)).length
|
||||
})
|
||||
|
||||
function isIndustryActive(item: string) {
|
||||
return draft.value.industries.includes(item)
|
||||
}
|
||||
|
||||
function isScaleActive(item: string) {
|
||||
if (item === '全部')
|
||||
return draft.value.scales.length === 0
|
||||
|
||||
return draft.value.scales.includes(item)
|
||||
}
|
||||
|
||||
function isTimeActive(item: string) {
|
||||
if (item === '全部')
|
||||
return draft.value.timeRanges.length === 0 && !draft.value.dateRange.start && !draft.value.dateRange.end
|
||||
|
||||
return draft.value.timeRanges.includes(item)
|
||||
}
|
||||
|
||||
function toggleIndustry(item: string) {
|
||||
const next = cloneCaseFilter(draft.value)
|
||||
|
||||
if (next.industries.includes(item))
|
||||
next.industries = next.industries.filter(value => value !== item)
|
||||
else
|
||||
next.industries.push(item)
|
||||
|
||||
draft.value = next
|
||||
}
|
||||
|
||||
function toggleScale(item: string) {
|
||||
const next = cloneCaseFilter(draft.value)
|
||||
|
||||
if (item === '全部') {
|
||||
next.scales = []
|
||||
draft.value = next
|
||||
return
|
||||
}
|
||||
|
||||
if (next.scales.includes(item))
|
||||
next.scales = next.scales.filter(value => value !== item)
|
||||
else
|
||||
next.scales.push(item)
|
||||
|
||||
draft.value = next
|
||||
}
|
||||
|
||||
function toggleTime(item: string) {
|
||||
const next = cloneCaseFilter(draft.value)
|
||||
|
||||
if (item === '全部') {
|
||||
next.timeRanges = []
|
||||
next.dateRange = { start: '', end: '' }
|
||||
draft.value = next
|
||||
return
|
||||
}
|
||||
|
||||
if (next.timeRanges.includes(item))
|
||||
next.timeRanges = next.timeRanges.filter(value => value !== item)
|
||||
else
|
||||
next.timeRanges.push(item)
|
||||
|
||||
draft.value = next
|
||||
}
|
||||
|
||||
function handleDateChange(type: 'start' | 'end', value: string) {
|
||||
const next = cloneCaseFilter(draft.value)
|
||||
next.dateRange[type] = value.replace(/-/g, '/')
|
||||
next.timeRanges = []
|
||||
draft.value = next
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
draft.value = cloneCaseFilter(DEFAULT_CASE_FILTER)
|
||||
}
|
||||
|
||||
function handleClearTags() {
|
||||
draft.value = cloneCaseFilter(DEFAULT_CASE_FILTER)
|
||||
}
|
||||
|
||||
function handleRemoveTag(key: string) {
|
||||
draft.value = removeFilterTag(draft.value, key)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', cloneCaseFilter(draft.value))
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="visible"
|
||||
class="case-filter-panel"
|
||||
>
|
||||
<view
|
||||
class="case-filter-panel__mask"
|
||||
@tap="handleCancel"
|
||||
/>
|
||||
|
||||
<view
|
||||
class="case-filter-panel__sheet"
|
||||
@tap.stop
|
||||
>
|
||||
<view class="case-filter-panel__header">
|
||||
<view class="case-filter-panel__title-wrap">
|
||||
<image
|
||||
class="case-filter-panel__title-icon"
|
||||
src="/static/icons/icon-filter.svg"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="case-filter-panel__title">
|
||||
筛选条件
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="case-filter-panel__reset"
|
||||
@tap="handleReset"
|
||||
>
|
||||
<text class="case-filter-panel__reset-icon">
|
||||
↺
|
||||
</text>
|
||||
<text class="case-filter-panel__reset-text">
|
||||
重置
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="case-filter-panel__body"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view class="case-filter-panel__section">
|
||||
<text class="case-filter-panel__section-title">
|
||||
行业
|
||||
</text>
|
||||
<view class="case-filter-panel__chips">
|
||||
<text
|
||||
v-for="item in CASE_FILTER_INDUSTRIES"
|
||||
:key="item"
|
||||
class="case-filter-panel__chip"
|
||||
:class="{ 'case-filter-panel__chip--active': isIndustryActive(item) }"
|
||||
@tap="toggleIndustry(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="case-filter-panel__section">
|
||||
<text class="case-filter-panel__section-title">
|
||||
项目规模
|
||||
</text>
|
||||
<view class="case-filter-panel__chips">
|
||||
<text
|
||||
v-for="item in CASE_FILTER_SCALES"
|
||||
:key="item"
|
||||
class="case-filter-panel__chip"
|
||||
:class="{ 'case-filter-panel__chip--active': isScaleActive(item) }"
|
||||
@tap="toggleScale(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="case-filter-panel__section">
|
||||
<text class="case-filter-panel__section-title">
|
||||
完成时间
|
||||
</text>
|
||||
<view class="case-filter-panel__chips">
|
||||
<text
|
||||
v-for="item in CASE_FILTER_TIME_RANGES"
|
||||
:key="item"
|
||||
class="case-filter-panel__chip"
|
||||
:class="{ 'case-filter-panel__chip--active': isTimeActive(item) }"
|
||||
@tap="toggleTime(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="case-filter-panel__date-row">
|
||||
<picker
|
||||
mode="date"
|
||||
:value="draft.dateRange.start.replace(/\//g, '-')"
|
||||
@change="handleDateChange('start', $event.detail.value)"
|
||||
>
|
||||
<view class="case-filter-panel__date-field">
|
||||
<text :class="{ 'case-filter-panel__date-placeholder': !draft.dateRange.start }">
|
||||
{{ draft.dateRange.start || '开始时间' }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text class="case-filter-panel__date-separator">
|
||||
—
|
||||
</text>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="draft.dateRange.end.replace(/\//g, '-')"
|
||||
@change="handleDateChange('end', $event.detail.value)"
|
||||
>
|
||||
<view class="case-filter-panel__date-field">
|
||||
<text :class="{ 'case-filter-panel__date-placeholder': !draft.dateRange.end }">
|
||||
{{ draft.dateRange.end || '结束时间' }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
v-if="selectedTags.length > 0"
|
||||
class="case-filter-panel__selected"
|
||||
>
|
||||
<view class="case-filter-panel__selected-header">
|
||||
<text class="case-filter-panel__selected-title">
|
||||
已选条件 ({{ selectedTags.length }}个)
|
||||
</text>
|
||||
<text
|
||||
class="case-filter-panel__selected-clear"
|
||||
@tap="handleClearTags"
|
||||
>
|
||||
清空
|
||||
</text>
|
||||
</view>
|
||||
<view class="case-filter-panel__selected-tags">
|
||||
<view
|
||||
v-for="tag in selectedTags"
|
||||
:key="tag.key"
|
||||
class="case-filter-panel__tag"
|
||||
>
|
||||
<text class="case-filter-panel__tag-text">
|
||||
{{ tag.label }}
|
||||
</text>
|
||||
<text
|
||||
class="case-filter-panel__tag-close"
|
||||
@tap.stop="handleRemoveTag(tag.key)"
|
||||
>
|
||||
×
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="case-filter-panel__footer">
|
||||
<view
|
||||
class="case-filter-panel__btn case-filter-panel__btn--cancel"
|
||||
@tap="handleCancel"
|
||||
>
|
||||
取消
|
||||
</view>
|
||||
<view
|
||||
class="case-filter-panel__btn case-filter-panel__btn--confirm"
|
||||
@tap="handleConfirm"
|
||||
>
|
||||
确定 ({{ previewCount }}个结果)
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.case-filter-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.case-filter-panel__mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.case-filter-panel__sheet {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 78vh;
|
||||
background: $color-bg-page;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
}
|
||||
|
||||
.case-filter-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 28rpx 32rpx 20rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__title-wrap,
|
||||
.case-filter-panel__reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.case-filter-panel__title-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.case-filter-panel__reset-icon {
|
||||
margin-right: 6rpx;
|
||||
font-size: 28rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.case-filter-panel__reset-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.case-filter-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.case-filter-panel__section {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__section-title {
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.case-filter-panel__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__chip {
|
||||
padding: 14rpx 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-primary;
|
||||
background: $color-bg-section;
|
||||
border-radius: 999rpx;
|
||||
|
||||
&--active {
|
||||
color: $color-primary;
|
||||
background: $color-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
.case-filter-panel__date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__date-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 220rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: $color-bg-section;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__date-placeholder {
|
||||
color: $color-text-placeholder;
|
||||
}
|
||||
|
||||
.case-filter-panel__date-separator {
|
||||
margin: 0 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.case-filter-panel__selected {
|
||||
flex-shrink: 0;
|
||||
margin: 0 32rpx 20rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
background: $color-bg-section;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__selected-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__selected-title {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.case-filter-panel__selected-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.case-filter-panel__selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 16rpx;
|
||||
background: $color-primary-light;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.case-filter-panel__tag-text {
|
||||
font-size: 22rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.case-filter-panel__tag-close {
|
||||
margin-left: 8rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.case-filter-panel__footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.case-filter-panel__btn {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 44rpx;
|
||||
|
||||
&--cancel {
|
||||
color: $color-primary;
|
||||
background: #ffffff;
|
||||
border: 2rpx solid $color-primary;
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
color: #ffffff;
|
||||
background: $color-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
src/pages/case-library/components/LoadMoreFooter.vue
Normal file
83
src/pages/case-library/components/LoadMoreFooter.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
finished: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadMore: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="hasMore || finished"
|
||||
class="load-more-footer"
|
||||
@tap="emit('loadMore')"
|
||||
>
|
||||
<view
|
||||
v-if="loading"
|
||||
class="load-more-footer__loading"
|
||||
>
|
||||
<view class="load-more-footer__spinner" />
|
||||
<text class="load-more-footer__text">
|
||||
加载更多...
|
||||
</text>
|
||||
</view>
|
||||
<text
|
||||
v-else-if="hasMore"
|
||||
class="load-more-footer__text"
|
||||
>
|
||||
加载更多
|
||||
</text>
|
||||
<text
|
||||
v-else-if="finished"
|
||||
class="load-more-footer__finished"
|
||||
>
|
||||
没有更多了
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.load-more-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.load-more-footer__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.load-more-footer__spinner {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
border: 4rpx solid #d6e4ff;
|
||||
border-top-color: $color-primary;
|
||||
border-radius: 50%;
|
||||
animation: load-more-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.load-more-footer__text,
|
||||
.load-more-footer__finished {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
@keyframes load-more-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppTabBar from '@/components/AppTabBar.vue'
|
||||
import {
|
||||
CASE_CATEGORIES,
|
||||
CASE_LIBRARY_LIST,
|
||||
CASE_PAGE_SIZE,
|
||||
DEFAULT_CASE_FILTER,
|
||||
} from '@/constants/case-library'
|
||||
import type { CaseCategory, CaseFilterValues } from '@/types/case-library'
|
||||
import { cloneCaseFilter, matchCaseFilter } from '@/utils/case-filter'
|
||||
import CaseCard from './components/CaseCard.vue'
|
||||
import CaseFilterPanel from './components/CaseFilterPanel.vue'
|
||||
import LoadMoreFooter from './components/LoadMoreFooter.vue'
|
||||
|
||||
const LOAD_MORE_DELAY = 600
|
||||
|
||||
const keyword = ref('')
|
||||
const activeCategory = ref<CaseCategory>('全部')
|
||||
const caseList = ref(CASE_LIBRARY_LIST.map(item => ({ ...item })))
|
||||
const pageSize = ref(CASE_PAGE_SIZE)
|
||||
const loadingMore = ref(false)
|
||||
const filterVisible = ref(false)
|
||||
const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
|
||||
|
||||
const filteredCases = computed(() => {
|
||||
const text = keyword.value.trim()
|
||||
|
||||
return caseList.value.filter((item) => {
|
||||
const matchCategory = activeCategory.value === '全部' || item.category === activeCategory.value
|
||||
const matchKeyword = !text || item.title.includes(text) || item.industry.includes(text) || item.category.includes(text)
|
||||
const matchFilter = matchCaseFilter(item, appliedFilter.value)
|
||||
return matchCategory && matchKeyword && matchFilter
|
||||
})
|
||||
})
|
||||
|
||||
const visibleCases = computed(() => filteredCases.value.slice(0, pageSize.value))
|
||||
|
||||
const hasMore = computed(() => pageSize.value < filteredCases.value.length)
|
||||
|
||||
const loadFinished = computed(() => !hasMore.value && visibleCases.value.length > 0)
|
||||
|
||||
watch([keyword, activeCategory, appliedFilter], () => {
|
||||
pageSize.value = CASE_PAGE_SIZE
|
||||
})
|
||||
|
||||
function handleSelectCategory(category: CaseCategory) {
|
||||
activeCategory.value = category
|
||||
}
|
||||
|
||||
function handleToggleFavorite(id: string) {
|
||||
caseList.value = caseList.value.map(item =>
|
||||
item.id === id ? { ...item, favorite: !item.favorite } : item,
|
||||
)
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
if (loadingMore.value || !hasMore.value)
|
||||
return
|
||||
|
||||
loadingMore.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
pageSize.value += CASE_PAGE_SIZE
|
||||
loadingMore.value = false
|
||||
}, LOAD_MORE_DELAY)
|
||||
}
|
||||
|
||||
function handleOpenFilter() {
|
||||
filterVisible.value = true
|
||||
}
|
||||
|
||||
function handleCloseFilter() {
|
||||
filterVisible.value = false
|
||||
}
|
||||
|
||||
function handleConfirmFilter(value: CaseFilterValues) {
|
||||
appliedFilter.value = cloneCaseFilter(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="placeholder-page">
|
||||
<text class="placeholder-page__title">
|
||||
案例库
|
||||
</text>
|
||||
<text class="placeholder-page__desc">
|
||||
功能开发中
|
||||
</text>
|
||||
<view class="case-library">
|
||||
<view class="case-library__header">
|
||||
<text class="case-library__title">
|
||||
案例库
|
||||
</text>
|
||||
|
||||
<view class="case-library__search-row">
|
||||
<view class="case-library__search">
|
||||
<image
|
||||
class="case-library__search-icon"
|
||||
src="/static/icons/icon-search.svg"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="case-library__search-input"
|
||||
placeholder="搜索案例标题、描述、标签..."
|
||||
placeholder-class="case-library__search-placeholder"
|
||||
>
|
||||
</view>
|
||||
<view class="case-library__favorite-btn">
|
||||
<image
|
||||
class="case-library__favorite-btn-icon"
|
||||
src="/static/icons/icon-heart.svg"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="case-library__tabs"
|
||||
scroll-x
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view class="case-library__tabs-inner">
|
||||
<text
|
||||
v-for="category in CASE_CATEGORIES"
|
||||
:key="category"
|
||||
class="case-library__tab"
|
||||
:class="{ 'case-library__tab--active': activeCategory === category }"
|
||||
@tap="handleSelectCategory(category)"
|
||||
>
|
||||
{{ category }}
|
||||
</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="case-library__toolbar">
|
||||
<text class="case-library__count">
|
||||
共 {{ filteredCases.length }}
|
||||
</text>
|
||||
<view
|
||||
class="case-library__filter"
|
||||
@tap="handleOpenFilter"
|
||||
>
|
||||
<image
|
||||
class="case-library__filter-icon"
|
||||
src="/static/icons/icon-filter.svg"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="case-library__filter-text">
|
||||
筛选
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="case-library__scroll"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
:lower-threshold="120"
|
||||
@scrolltolower="handleLoadMore"
|
||||
>
|
||||
<view class="case-library__list">
|
||||
<CaseCard
|
||||
v-for="item in visibleCases"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@toggle-favorite="handleToggleFavorite"
|
||||
/>
|
||||
|
||||
<LoadMoreFooter
|
||||
:loading="loadingMore"
|
||||
:has-more="hasMore"
|
||||
:finished="loadFinished"
|
||||
@load-more="handleLoadMore"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<AppTabBar active="case" />
|
||||
|
||||
<CaseFilterPanel
|
||||
:visible="filterVisible"
|
||||
:cases="caseList"
|
||||
:value="appliedFilter"
|
||||
@close="handleCloseFilter"
|
||||
@confirm="handleConfirmFilter"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/variables.scss';
|
||||
|
||||
.placeholder-page {
|
||||
.case-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: $color-bg-section;
|
||||
}
|
||||
|
||||
.case-library__header {
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height, 44px) + 12rpx) 24rpx 0;
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
.placeholder-page__title {
|
||||
.case-library__title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder-page__desc {
|
||||
margin-top: 16rpx;
|
||||
.case-library__search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.case-library__search {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-section;
|
||||
border-radius: 36rpx;
|
||||
}
|
||||
|
||||
.case-library__search-icon {
|
||||
flex-shrink: 0;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.case-library__search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.case-library__search-placeholder {
|
||||
color: $color-text-placeholder;
|
||||
}
|
||||
|
||||
.case-library__favorite-btn {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.case-library__favorite-btn-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.case-library__tabs {
|
||||
margin-top: 24rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.case-library__tabs-inner {
|
||||
display: inline-flex;
|
||||
gap: 32rpx;
|
||||
padding-right: 24rpx;
|
||||
}
|
||||
|
||||
.case-library__tab {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 12rpx;
|
||||
font-size: 28rpx;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&--active {
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
border-bottom: 4rpx solid $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.case-library__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.case-library__count {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.case-library__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.case-library__filter-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.case-library__filter-text {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.case-library__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.case-library__list {
|
||||
padding: 0 24rpx calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user