first
This commit is contained in:
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal file
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal 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>
|
||||
57
src/views/cases/cpns/CaseRichText.vue
Normal file
57
src/views/cases/cpns/CaseRichText.vue
Normal 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>
|
||||
324
src/views/cases/cpns/CasesFilter.vue
Normal file
324
src/views/cases/cpns/CasesFilter.vue
Normal 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>
|
||||
43
src/views/cases/cpns/CasesGrid.vue
Normal file
43
src/views/cases/cpns/CasesGrid.vue
Normal 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>
|
||||
Reference in New Issue
Block a user