恢复修改的数据

This commit is contained in:
wangzzpeng
2026-09-28 10:47:49 +08:00
parent f1e64ef864
commit 43c2dc0d8d
57 changed files with 4557 additions and 1642 deletions

View File

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

View File

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

View File

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

View File

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