This commit is contained in:
wangzzpeng
2026-09-24 10:10:43 +08:00
commit 7bc84bbe5e
194 changed files with 22571 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { formatBusinessType } from '@/utils/format'
import type { Case } from '@/types/business/cases'
interface Props {
item: Case
}
defineProps<Props>()
const emit = defineEmits<{
favorite: [id: string]
share: [id: string]
}>()
</script>
<template>
<header class="case-header">
<div class="case-header__main">
<div class="case-header__head">
<h1 class="case-header__title">
{{ item.title }}
</h1>
<span class="case-header__tag">{{ formatBusinessType(item.category) }}</span>
</div>
<div class="case-header__actions">
<button
class="case-header__btn"
:class="{ 'is-favorite': item.isFavorite }"
@click="emit('favorite', item.id)"
>
{{ item.isFavorite ? '★ 已收藏' : '☆ 收藏' }}
</button>
<button class="case-header__btn" @click="emit('share', item.id)">
分享
</button>
</div>
<div class="case-header__meta">
<div class="case-header__meta-item">
<span class="text-secondary">完成时间</span>
<span>{{ item.finishDate }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">服务团队</span>
<span>{{ item.teamSize }}人</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">规模</span>
<span>{{ item.scale }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">所属行业</span>
<span>{{ item.industry }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">项目周期</span>
<span>{{ item.duration }}</span>
</div>
<div class="case-header__meta-item">
<span class="text-secondary">客户类型</span>
<span>{{ item.clientType }}</span>
</div>
</div>
</div>
<div class="case-header__video">
<!-- TODO(蓝湖): 接入真实视频源 -->
<span>视频将在预览时播放</span>
<span class="case-header__video-time">0:00 / 0:00</span>
</div>
</header>
</template>
<style lang="less" scoped>
.case-header {
.flex(row, flex-start);
gap: @spacing-lg;
padding: @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__main {
flex: 1;
}
&__head {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__tag {
padding: 2px @spacing-sm;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-sm;
}
&__actions {
.flex(row, flex-start);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__btn {
height: 32px;
padding: 0 @spacing-base;
border: 1px solid @border-color-dark;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-favorite {
border-color: @warning-color;
color: @warning-color;
}
}
&__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
}
&__meta-item {
.flex(column, flex-start);
gap: 4px;
font-size: @font-size-base;
color: @heading-color;
}
&__video {
.flex(column, center);
gap: @spacing-sm;
width: 320px;
height: 200px;
border-radius: @radius-base;
background: @heading-color;
color: @text-color-secondary;
font-size: @font-size-sm;
}
&__video-time {
color: @text-color-disabled;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { Case } from '@/types/business/cases'
interface Props {
item: Case
}
defineProps<Props>()
const sections = [
{ key: 'background', title: '项目背景', field: 'background' },
{ key: 'painPoints', title: '客户痛点', field: 'painPoints' },
{ key: 'requirements', title: '需求描述', field: 'requirements' },
{ key: 'deliverables', title: '交付物', field: 'deliverables' },
] as const
</script>
<template>
<div class="case-rich">
<section v-for="section in sections" :key="section.key" class="case-rich__section">
<h3 class="case-rich__title">
{{ section.title }}
</h3>
<p class="case-rich__text">{{ item[section.field] }}</p>
</section>
</div>
</template>
<style lang="less" scoped>
.case-rich {
.flex(column, flex-start);
gap: @spacing-base;
&__section {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title {
margin-bottom: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
padding-left: @spacing-sm;
border-left: 3px solid @primary-color;
}
&__text {
font-size: @font-size-base;
line-height: 1.8;
color: @text-color;
white-space: pre-wrap;
}
}
</style>

View File

@@ -0,0 +1,324 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElDatePicker } from 'element-plus'
import { FilterPanel, type FilterChip } from '@/components'
import { formatBusinessType, formatDate, formatScale } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { CaseQuery, ScaleRange } from '@/types/business/cases'
interface Props {
query: CaseQuery
counts: Partial<Record<BusinessType, number>>
total: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:query': [query: CaseQuery]
search: []
reset: []
}>()
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
const activeShortcut = ref<TimeShortcut>('all')
watch(
() => props.query.dateRange,
(value) => {
if (!value) activeShortcut.value = 'all'
},
)
const categoryOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
const industryOptions = [
'电商零售', '教育培训', '医疗健康', '硬件电路', '能源环保',
'房地产', '自动化', '工厂服务', '智能设备', '园区服务',
]
const scaleOptions: Array<{ label: string; value: ScaleRange }> = [
{ label: '50万以下', value: 'lt50w' },
{ label: '50-100万', value: '50to100w' },
{ label: '100-300万', value: '100to300w' },
{ label: '300-500万', value: '300to500w' },
{ label: '500万以上', value: 'gt500w' },
]
const timeShortcuts: Array<{ label: string; value: TimeShortcut }> = [
{ label: '全部', value: 'all' },
{ label: '近3个月', value: '3m' },
{ label: '近半年', value: '6m' },
{ label: '近一年', value: '1y' },
]
const keyword = computed({
get: () => props.query.keyword,
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
})
const chips = computed<FilterChip[]>(() => {
const list: FilterChip[] = []
if (props.query.category !== 'all') {
list.push({ key: 'category', label: formatBusinessType(props.query.category) })
}
for (const industry of props.query.industries) {
list.push({ key: `industry:${industry}`, label: industry })
}
for (const scale of props.query.scales) {
list.push({ key: `scale:${scale}`, label: formatScale(scale) })
}
if (props.query.dateRange) {
const shortcutLabel = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
list.push({
key: 'time',
label: shortcutLabel && shortcutLabel !== '全部'
? shortcutLabel
: `${props.query.dateRange[0]}—${props.query.dateRange[1]}`,
})
}
return list
})
function patch(partial: Partial<CaseQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
function setCategory(value: BusinessType | 'all'): void {
patch({ category: value })
}
function toggleIndustry(industry: string): void {
const next = props.query.industries.includes(industry)
? props.query.industries.filter((item) => item !== industry)
: [...props.query.industries, industry]
patch({ industries: next })
}
function toggleScale(scale: ScaleRange): void {
const next = props.query.scales.includes(scale)
? props.query.scales.filter((item) => item !== scale)
: [...props.query.scales, scale]
patch({ scales: next })
}
function setShortcut(value: TimeShortcut): void {
activeShortcut.value = value
if (value === 'all') {
patch({ dateRange: null })
return
}
const end = new Date()
const start = new Date()
if (value === '3m') start.setMonth(start.getMonth() - 3)
if (value === '6m') start.setMonth(start.getMonth() - 6)
if (value === '1y') start.setFullYear(start.getFullYear() - 1)
patch({
dateRange: [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')],
})
}
function onDateRange(value: [Date, Date] | null): void {
activeShortcut.value = 'custom'
patch({
dateRange: value
? [formatDate(value[0], 'YYYY-MM-DD'), formatDate(value[1], 'YYYY-MM-DD')]
: null,
})
}
function onRemoveChip(key: string): void {
if (key === 'category') {
setCategory('all')
}
else if (key === 'time') {
activeShortcut.value = 'all'
patch({ dateRange: null })
}
else if (key.startsWith('industry:')) {
const industry = key.slice('industry:'.length)
patch({ industries: props.query.industries.filter((item) => item !== industry) })
}
else if (key.startsWith('scale:')) {
const scale = key.slice('scale:'.length) as ScaleRange
patch({ scales: props.query.scales.filter((item) => item !== scale) })
}
}
function onClearChips(): void {
activeShortcut.value = 'all'
emit('update:query', {
...props.query,
category: 'all',
industries: [],
scales: [],
dateRange: null,
})
}
</script>
<template>
<FilterPanel
v-model:keyword="keyword"
:chips="chips"
:show-result-count="true"
:result-count="total"
search-label="确定筛选"
@search="emit('search')"
@reset="emit('reset')"
@remove-chip="onRemoveChip"
@clear-chips="onClearChips"
>
<!-- 业务类型(单选 + 计数) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">业务类型</span>
<div class="cases-filter__options">
<button
v-for="option in categoryOptions"
:key="option.value"
class="cases-filter__option"
:class="{ 'is-active': query.category === option.value }"
@click="setCategory(option.value)"
>
{{ option.label }}
<span class="cases-filter__count">
{{ option.value === 'all' ? total : (counts[option.value] ?? 0) }}
</span>
</button>
</div>
</div>
<!-- 所属行业(多选) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">所属行业</span>
<div class="cases-filter__options">
<button
class="cases-filter__option"
:class="{ 'is-active': query.industries.length === 0 }"
@click="patch({ industries: [] })"
>
全部
</button>
<button
v-for="industry in industryOptions"
:key="industry"
class="cases-filter__option"
:class="{ 'is-active': query.industries.includes(industry) }"
@click="toggleIndustry(industry)"
>
{{ industry }}
</button>
</div>
</div>
<!-- 项目规模(多选) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">项目规模</span>
<div class="cases-filter__options">
<button
class="cases-filter__option"
:class="{ 'is-active': query.scales.length === 0 }"
@click="patch({ scales: [] })"
>
全部
</button>
<button
v-for="scale in scaleOptions"
:key="scale.value"
class="cases-filter__option"
:class="{ 'is-active': query.scales.includes(scale.value) }"
@click="toggleScale(scale.value)"
>
{{ scale.label }}
</button>
</div>
</div>
<!-- 完成时间(快捷区间 + 自定义) -->
<div class="cases-filter__group">
<span class="cases-filter__group-title">完成时间</span>
<div class="cases-filter__options">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="cases-filter__option"
:class="{ 'is-active': activeShortcut === shortcut.value }"
@click="setShortcut(shortcut.value)"
>
{{ shortcut.label }}
</button>
<ElDatePicker
v-model="query.dateRange"
type="daterange"
range-separator="—"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
class="cases-filter__date"
@change="onDateRange"
/> </div>
</div>
</FilterPanel>
</template>
<style lang="less" scoped>
.cases-filter {
&__group {
.flex(row, flex-start);
gap: @spacing-base;
}
&__group-title {
flex-shrink: 0;
width: 70px;
padding-top: 5px;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
}
&__option {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
border-radius: @radius-sm;
background: @bg-color;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
}
}
&__count {
margin-left: 4px;
color: @text-color-secondary;
font-size: @font-size-sm;
}
&__date {
width: 240px;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { CaseCard } from '@/components'
import type { Case } from '@/types/business/cases'
interface Props {
cases: Case[]
}
defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>()
</script>
<template>
<div class="cases-grid">
<template v-if="cases.length > 0">
<CaseCard
v-for="item in cases"
:key="item.id"
:item="item"
@view="emit('view', $event)"
/>
</template>
<p v-else class="cases-grid__empty text-secondary">
暂无符合条件的案例,请调整筛选条件
</p>
</div>
</template>
<style lang="less" scoped>
.cases-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: @spacing-base;
&__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>