feat:首页完成

This commit is contained in:
李伊帆
2026-09-11 08:02:01 +08:00
parent 5b31eac508
commit 173cd6b975
68 changed files with 3181 additions and 112 deletions

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

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

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

View File

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