恢复修改的数据
This commit is contained in:
@@ -1,164 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { formatBusinessType } from '@/utils/format'
|
||||
import { computed } from 'vue'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
favorite: [id: string]
|
||||
share: [id: string]
|
||||
}>()
|
||||
|
||||
/** 空值兜底(后端 customerType / industry 等可能不下发) */
|
||||
function dash(value: string): string {
|
||||
return value || '--'
|
||||
}
|
||||
|
||||
/** 概览信息(设计稿:标签 + 全角冒号 + 值,单行铺开) */
|
||||
const meta = computed(() => [
|
||||
{ label: '客户类型', value: dash(props.item.clientType) },
|
||||
{ label: '所属行业', value: dash(props.item.industry) },
|
||||
{ label: '规模', value: dash(props.item.scale) },
|
||||
{ label: '项目周期', value: dash(props.item.duration) },
|
||||
{ label: '服务团队', value: props.item.teamSize ? `${props.item.teamSize}人` : '--' },
|
||||
])
|
||||
</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>
|
||||
<section class="overview">
|
||||
<h3 class="overview__title">
|
||||
项目概览
|
||||
</h3>
|
||||
|
||||
<div class="overview__body">
|
||||
<div class="overview__meta">
|
||||
<ul class="overview__row">
|
||||
<li v-for="entry in meta" :key="entry.label" class="overview__item">
|
||||
<span class="overview__label">{{ entry.label }}:</span>
|
||||
<span class="overview__value">{{ entry.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="overview__item">
|
||||
<span class="overview__label">完成时间:</span>
|
||||
<span class="overview__value">{{ dash(props.item.finishDate) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="case-header__actions">
|
||||
<div class="overview__actions">
|
||||
<button
|
||||
class="case-header__btn"
|
||||
:class="{ 'is-favorite': item.isFavorite }"
|
||||
@click="emit('favorite', item.id)"
|
||||
class="overview__pill"
|
||||
:class="{ 'is-active': props.item.isFavorite }"
|
||||
type="button"
|
||||
@click="emit('favorite', props.item.id)"
|
||||
>
|
||||
{{ item.isFavorite ? '★ 已收藏' : '☆ 收藏' }}
|
||||
<svg
|
||||
class="overview__icon"
|
||||
viewBox="0 0 24 24"
|
||||
:fill="props.item.isFavorite ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.8 5.6a5.5 5.5 0 0 0-7.8 0L12 6.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l8.8 8.8 8.8-8.8a5.5 5.5 0 0 0 0-7.8z" />
|
||||
</svg>
|
||||
<span>{{ props.item.isFavorite ? '已收藏' : '收藏' }}</span>
|
||||
</button>
|
||||
<button class="case-header__btn" @click="emit('share', item.id)">
|
||||
分享
|
||||
|
||||
<button
|
||||
class="overview__pill"
|
||||
type="button"
|
||||
@click="emit('share', props.item.id)"
|
||||
>
|
||||
<svg
|
||||
class="overview__icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<path d="M8.6 13.6l6.8 3.8M15.4 6.6l-6.8 3.8" />
|
||||
</svg>
|
||||
<span>分享</span>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-header {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-lg;
|
||||
padding: @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
.overview {
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__head {
|
||||
&__title {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-xl;
|
||||
font-size: @font-size-lg;
|
||||
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;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
border-color: @warning-color;
|
||||
color: @warning-color;
|
||||
}
|
||||
&__body {
|
||||
.flex(row, space-between, center);
|
||||
gap: @spacing-lg;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: @spacing-base;
|
||||
// 显式 stretch:否则元信息行会被居中收缩(设计稿为左起铺满整行)
|
||||
.flex(column, flex-start, stretch);
|
||||
flex: 1;
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
.flex(column, flex-start);
|
||||
gap: 4px;
|
||||
&__row {
|
||||
.flex(row, space-between, center);
|
||||
gap: @spacing-base;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__item {
|
||||
font-size: @font-size-base;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__video {
|
||||
.flex(column, center);
|
||||
&__actions {
|
||||
.flex(row, flex-start, center);
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
&__pill {
|
||||
.flex(row, center, center);
|
||||
gap: @spacing-xs;
|
||||
height: 32px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-round;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,53 +5,84 @@ interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = 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
|
||||
/**
|
||||
* 案例正文区块(设计稿:一段一张白卡,卡片标题带蓝色竖条)。
|
||||
*
|
||||
* <p>区块的**数量与标题都来自后端**(vm_project_case_catalog:title + content,
|
||||
* 按 sort_order 升序),前端不再写死「项目背景 / 客户痛点 / 需求描述 / 交付物」,
|
||||
* 后端增删目录即自动生效。空内容已在 utils/caseVo.ts#toSections 里过滤。</p>
|
||||
*/
|
||||
</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
|
||||
v-for="(section, index) in props.item.sections"
|
||||
:key="`${index}-${section.title}`"
|
||||
class="case-rich__card"
|
||||
>
|
||||
<h3 v-if="section.title" class="case-rich__title">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
<p class="case-rich__text">{{ item[section.field] }}</p>
|
||||
<!-- 后端下发富文本 HTML(可能内嵌 img / video),直接渲染 -->
|
||||
<div class="case-rich__html" v-html="section.content" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-rich {
|
||||
.flex(column, flex-start);
|
||||
// 同 detail.vue:显式 stretch,避免正文块被压成内容宽
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
|
||||
&__section {
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
&__card {
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
padding-left: @spacing-sm;
|
||||
border-left: 3px solid @primary-color;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: @primary-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
&__html {
|
||||
font-size: @font-size-base;
|
||||
line-height: 1.8;
|
||||
color: @text-color;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
:deep(p) {
|
||||
margin-bottom: @spacing-sm;
|
||||
// 后端富文本常把「1、…2、…」分条内容放在同一个 <p> 里用 \r\n 分行,
|
||||
// 默认会被 HTML 折叠成一段,故保留换行(与旧纯文本分支的 pre-wrap 行为一致)
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:deep(img),
|
||||
:deep(video) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: @spacing-sm;
|
||||
border-radius: @radius-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed } 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'
|
||||
import type { FilterChip } from '@/components'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import type { BusinessIndustryOption, CaseQuery, CaseTypeOption, ScaleLimit } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
query: CaseQuery
|
||||
counts: Partial<Record<BusinessType, number>>
|
||||
total: number
|
||||
/** 业务类型选项(接口下发:/categories 的统计;value=提报 id,label=类型名) */
|
||||
typeOptions: CaseTypeOption[]
|
||||
/** 所属行业选项(接口下发:/categories) */
|
||||
industryOptions: BusinessIndustryOption[]
|
||||
/** 项目规模选项(接口下发:/categories,label 已由后端拼好) */
|
||||
scaleOptions: ScaleLimit[]
|
||||
/** 案例总数(全量口径,用于业务类型「全部」行角标) */
|
||||
caseTotal: number
|
||||
/** 待提交条件的结果数(「确定筛选(N 个结果)」) */
|
||||
previewTotal: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -22,282 +29,405 @@ const emit = defineEmits<{
|
||||
|
||||
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 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 },
|
||||
]
|
||||
|
||||
const industryOptions = [
|
||||
'电商零售', '教育培训', '医疗健康', '硬件电路', '能源环保',
|
||||
'房地产', '自动化', '工厂服务', '智能设备', '园区服务',
|
||||
]
|
||||
/**
|
||||
* 规模档位的稳定标识。
|
||||
*
|
||||
* <p>后端档位是「上下限」对象(label 只是展示文字),用上下限拼 key 比 label 可靠:
|
||||
* 区间值唯一、且不依赖后端文案。</p>
|
||||
*/
|
||||
function scaleKey(item: ScaleLimit): string {
|
||||
return `${item.lowerLimit ?? ''}-${item.upperLimit ?? ''}`
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
function scaleLabel(item: ScaleLimit): string {
|
||||
return item.label ?? `${item.lowerLimit ?? 0}-${item.upperLimit ?? '不限'}万`
|
||||
}
|
||||
|
||||
const timeShortcuts: Array<{ label: string; value: TimeShortcut }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '近3个月', value: '3m' },
|
||||
{ label: '近半年', value: '6m' },
|
||||
{ label: '近一年', value: '1y' },
|
||||
]
|
||||
/** 快捷区间 → 具体日期区间 */
|
||||
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 keyword = computed({
|
||||
get: () => props.query.keyword,
|
||||
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
|
||||
/** 当前区间命中的快捷项(用于选中态回显,避免额外维护一份状态) */
|
||||
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 list: FilterChip[] = []
|
||||
if (props.query.category !== 'all') {
|
||||
list.push({ key: 'category', label: formatBusinessType(props.query.category) })
|
||||
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 industry of props.query.industries) {
|
||||
list.push({ key: `industry:${industry}`, label: industry })
|
||||
for (const name of props.query.industries) {
|
||||
result.push({ key: `industry:${name}`, label: name })
|
||||
}
|
||||
for (const scale of props.query.scales) {
|
||||
list.push({ key: `scale:${scale}`, label: formatScale(scale) })
|
||||
result.push({ key: `scale:${scaleKey(scale)}`, label: scaleLabel(scale) })
|
||||
}
|
||||
if (props.query.dateRange) {
|
||||
const shortcutLabel = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
|
||||
list.push({
|
||||
const label = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
|
||||
result.push({
|
||||
key: 'time',
|
||||
label: shortcutLabel && shortcutLabel !== '全部'
|
||||
? shortcutLabel
|
||||
: `${props.query.dateRange[0]}—${props.query.dateRange[1]}`,
|
||||
label: label ?? `${props.query.dateRange[0]}-${props.query.dateRange[1]}`,
|
||||
})
|
||||
}
|
||||
return list
|
||||
return result
|
||||
})
|
||||
|
||||
function patch(partial: Partial<CaseQuery>): void {
|
||||
emit('update:query', { ...props.query, ...partial })
|
||||
}
|
||||
|
||||
function setCategory(value: BusinessType | 'all'): void {
|
||||
patch({ category: value })
|
||||
function toggleIn<T>(list: T[], value: T): T[] {
|
||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, 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(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 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
|
||||
function setShortcut(value: Exclude<TimeShortcut, 'custom'>): void {
|
||||
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,
|
||||
})
|
||||
const shortcut = timeShortcuts.find((item) => item.value === value)
|
||||
if (shortcut) patch({ dateRange: rangeOf(shortcut.months) })
|
||||
}
|
||||
|
||||
function onRemoveChip(key: string): void {
|
||||
if (key === 'category') {
|
||||
setCategory('all')
|
||||
}
|
||||
else if (key === 'time') {
|
||||
activeShortcut.value = 'all'
|
||||
if (key === 'time') {
|
||||
patch({ dateRange: null })
|
||||
return
|
||||
}
|
||||
else if (key.startsWith('industry:')) {
|
||||
const industry = key.slice('industry:'.length)
|
||||
patch({ industries: props.query.industries.filter((item) => item !== industry) })
|
||||
if (key.startsWith('category:')) {
|
||||
const value = key.slice('category:'.length)
|
||||
patch({ categories: props.query.categories.filter((item) => item !== value) })
|
||||
return
|
||||
}
|
||||
else if (key.startsWith('scale:')) {
|
||||
const scale = key.slice('scale:'.length) as ScaleRange
|
||||
patch({ scales: props.query.scales.filter((item) => item !== scale) })
|
||||
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 onClearChips(): void {
|
||||
activeShortcut.value = 'all'
|
||||
emit('update:query', {
|
||||
...props.query,
|
||||
category: 'all',
|
||||
industries: [],
|
||||
scales: [],
|
||||
dateRange: null,
|
||||
})
|
||||
function clearChips(): void {
|
||||
patch({ categories: [], 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>
|
||||
<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>
|
||||
|
||||
<!-- 所属行业(多选) -->
|
||||
<div class="cases-filter__group">
|
||||
<span class="cases-filter__group-title">所属行业</span>
|
||||
<div class="cases-filter__options">
|
||||
<!-- 业务类型(接口下发;带案例数) -->
|
||||
<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="cases-filter__option"
|
||||
:class="{ 'is-active': query.industries.length === 0 }"
|
||||
class="case-filter__pill"
|
||||
:class="{ 'is-light': 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)"
|
||||
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 ?? '') })"
|
||||
>
|
||||
{{ industry }}
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 项目规模(多选) -->
|
||||
<div class="cases-filter__group">
|
||||
<span class="cases-filter__group-title">项目规模</span>
|
||||
<div class="cases-filter__options">
|
||||
<!-- 项目规模(接口下发,多选) -->
|
||||
<section class="case-filter__group">
|
||||
<h3 class="case-filter__group-title">
|
||||
项目规模
|
||||
</h3>
|
||||
<div class="case-filter__pills">
|
||||
<button
|
||||
class="cases-filter__option"
|
||||
:class="{ 'is-active': query.scales.length === 0 }"
|
||||
class="case-filter__pill"
|
||||
:class="{ 'is-light': 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)"
|
||||
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)"
|
||||
>
|
||||
{{ scale.label }}
|
||||
{{ scaleLabel(option) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 完成时间(快捷区间 + 自定义) -->
|
||||
<div class="cases-filter__group">
|
||||
<span class="cases-filter__group-title">完成时间</span>
|
||||
<div class="cases-filter__options">
|
||||
<!-- 完成时间(快捷区间 + 自定义;后端未下发该组选项) -->
|
||||
<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="cases-filter__option"
|
||||
:class="{ 'is-active': activeShortcut === 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>
|
||||
<ElDatePicker
|
||||
v-model="query.dateRange"
|
||||
v-model="dateRange"
|
||||
class="case-filter__date"
|
||||
type="daterange"
|
||||
range-separator="—"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="cases-filter__date"
|
||||
@change="onDateRange"
|
||||
/> </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>
|
||||
</FilterPanel>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cases-filter {
|
||||
.case-filter {
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
padding: @spacing-lg 0 @spacing-base;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
|
||||
&__head,
|
||||
&__group,
|
||||
&__actions {
|
||||
padding: 0 @spacing-base;
|
||||
}
|
||||
|
||||
&__head {
|
||||
.flex(row, space-between, center);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: @primary-color;
|
||||
font-size: @font-size-base;
|
||||
|
||||
&:hover {
|
||||
color: @primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&__group {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-base;
|
||||
margin-top: @spacing-lg;
|
||||
}
|
||||
|
||||
&__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;
|
||||
/* 业务类型:整行可点,选中整行铺满底色 */
|
||||
&__rows {
|
||||
margin-top: @spacing-sm;
|
||||
}
|
||||
|
||||
&__option {
|
||||
height: 28px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: 1px solid transparent;
|
||||
border-radius: @radius-sm;
|
||||
background: @bg-color;
|
||||
&__row {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base;
|
||||
color: @text-color;
|
||||
font-size: @font-size-sm;
|
||||
font-size: @font-size-base;
|
||||
text-align: left;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @bg-color-light;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: @filter-selected-bg;
|
||||
color: @primary-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__box {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: 2px;
|
||||
|
||||
.case-filter__row.is-active & {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 4px;
|
||||
width: 4px;
|
||||
height: 7px;
|
||||
border: solid @white;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__row-label {
|
||||
flex: 1;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__row-count {
|
||||
color: @text-color-secondary;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 胶囊选项(两列;minmax(0,1fr) 防止内部长内容(日期区间)把列撑宽) */
|
||||
&__pills {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: @spacing-sm;
|
||||
margin-top: @spacing-sm;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
height: 34px;
|
||||
padding: 0 @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
.ellipsis();
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
@@ -305,20 +435,140 @@ function onClearChips(): void {
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
background: @primary-light;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
background: @pill-selected-bg;
|
||||
color: @primary-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: 4px;
|
||||
&__date {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @input-bg;
|
||||
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: 76px;
|
||||
background: transparent;
|
||||
color: @text-color;
|
||||
font-size: @font-size-sm;
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-range-separator) {
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
&.is-focus {
|
||||
background: @input-bg;
|
||||
}
|
||||
}
|
||||
|
||||
/* 已选条件 */
|
||||
&__selected {
|
||||
margin-top: @spacing-lg;
|
||||
padding: @spacing-base;
|
||||
border-radius: @radius-base;
|
||||
background: @chips-panel-bg;
|
||||
}
|
||||
|
||||
&__selected-head {
|
||||
.flex(row, space-between, center);
|
||||
}
|
||||
|
||||
&__selected-title {
|
||||
color: @primary-color;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__selected-empty {
|
||||
margin-top: @spacing-sm;
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 240px;
|
||||
&__chips {
|
||||
.flex(row, flex-start, center);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-sm;
|
||||
margin-top: @spacing-sm;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
height: 26px;
|
||||
padding: 0 @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @white;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-sm;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部操作 */
|
||||
&__actions {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
margin-top: @spacing-base;
|
||||
}
|
||||
|
||||
&__reset {
|
||||
width: 88px;
|
||||
height: 36px;
|
||||
border-radius: @radius-round;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__confirm {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,23 +4,34 @@ import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
cases: Case[]
|
||||
/** 当前筛选条件下的案例总数 */
|
||||
total: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ view: [id: string] }>()
|
||||
const emit = defineEmits<{
|
||||
view: [id: string]
|
||||
favorite: [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cases-grid">
|
||||
<template v-if="cases.length > 0">
|
||||
<p class="cases-grid__total">
|
||||
{{ total }}
|
||||
</p>
|
||||
|
||||
<div v-if="cases.length > 0" class="cases-grid__list">
|
||||
<CaseCard
|
||||
v-for="item in cases"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@view="emit('view', $event)"
|
||||
@favorite="emit('favorite', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-else class="cases-grid__empty text-secondary">
|
||||
暂无符合条件的案例,请调整筛选条件
|
||||
</p>
|
||||
@@ -29,12 +40,20 @@ const emit = defineEmits<{ view: [id: string] }>()
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: @spacing-base;
|
||||
&__total {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: @spacing-base;
|
||||
margin-top: @spacing-base;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: @spacing-xxl 0;
|
||||
text-align: center;
|
||||
font-size: @font-size-base;
|
||||
|
||||
Reference in New Issue
Block a user