恢复修改的数据

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;

View File

@@ -2,9 +2,9 @@
import { computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useCasesStore } from '@/stores/modules/cases'
import CaseHero from './cpns/CaseHero.vue'
import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
import CaseRichText from './cpns/CaseRichText.vue'
@@ -41,22 +41,32 @@ onMounted(load)
<template>
<div class="case-detail">
<nav class="case-detail__breadcrumb text-secondary">
<RouterLink to="/cases" class="link">
案例库
</RouterLink>
<span> &gt; </span>
<span>案例详情</span>
</nav>
<div class="container case-detail__crumb">
<nav class="text-secondary">
<RouterLink to="/cases" class="link">
案例库
</RouterLink>
<span> &gt; </span>
<span>案例详情</span>
</nav>
</div>
<template v-if="item">
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
<CaseRichText :item="item" />
<!-- 封面通栏:设计稿中不被内容容器收窄 -->
<CaseHero :item="item" />
<div class="case-detail__cta">
<AppButton type="primary" size="lg" @click="router.push('/demand')">
<div class="container case-detail__main">
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
<CaseRichText :item="item" />
<!-- 设计稿:内容区整宽蓝色按钮 -->
<button
class="case-detail__cta"
type="button"
@click="router.push('/demand')"
>
一键填报类似案例
</AppButton>
</button>
</div>
</template>
@@ -68,17 +78,35 @@ onMounted(load)
<style lang="less" scoped>
.case-detail {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
&__crumb {
padding-top: 6px;
padding-bottom: 6px;
font-size: @font-size-base;
}
// ⚠️ .flex 第三个参数是 align(默认 center),必须显式传 stretch,
// 否则各白块会被压成「内容宽 + 水平居中」(设计稿要求整宽)
&__main {
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
}
&__cta {
.flex(row, center);
padding-top: @spacing-base;
width: 100%;
height: 44px;
border-radius: @radius-base;
background: @primary-color;
color: @white;
font-size: @font-size-lg;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
&__empty {

View File

@@ -1,5 +1,7 @@
export { default } from './index.vue'
export { default as CasesFilter } from './cpns/CasesFilter.vue'
export { default as CasesSearchBar } from './cpns/CasesSearchBar.vue'
export { default as CasesGrid } from './cpns/CasesGrid.vue'
export { default as CaseHero } from './cpns/CaseHero.vue'
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
export { default as CaseRichText } from './cpns/CaseRichText.vue'

View File

@@ -7,16 +7,22 @@ import { PaginationBar } from '@/components'
import { useCasesStore } from '@/stores/modules/cases'
import type { CaseQuery } from '@/types/business/cases'
import CasesFilter from './cpns/CasesFilter.vue'
import CasesSearchBar from './cpns/CasesSearchBar.vue'
import CasesGrid from './cpns/CasesGrid.vue'
const router = useRouter()
const casesStore = useCasesStore()
const { query, list, total, counts } = storeToRefs(casesStore)
const { query, list, total, previewTotal, typeOptions, industryOptions, scaleOptions, caseTotal }
= storeToRefs(casesStore)
function onUpdateQuery(next: CaseQuery): void {
casesStore.setQuery(next)
}
function onUpdateKeyword(keyword: string): void {
casesStore.setQuery({ ...query.value, keyword })
}
function onSearch(): void {
void casesStore.search()
}
@@ -25,6 +31,14 @@ function onReset(): void {
void casesStore.resetQuery()
}
function onToggleFavoriteOnly(): void {
void casesStore.toggleFavoriteOnly()
}
function onFavorite(id: string): void {
void casesStore.toggleFavorite(id)
}
function onPageChange(page: number): void {
void casesStore.changePage(page)
}
@@ -34,48 +48,76 @@ function onViewDetail(id: string): void {
}
onMounted(() => {
void Promise.all([casesStore.fetchCases(), casesStore.fetchCounts()])
// 筛选面板选项全部来自接口:业务类型 / 所属行业 / 项目规模三组都走 /categories,完成时间由前端算
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
})
</script>
<template>
<div class="cases">
<header class="cases__head">
<h1 class="cases__title">
成功案例库
</h1>
<p class="cases__slogan text-secondary">
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
</p>
<header class="cases__hero">
<div class="container">
<h1 class="cases__title">
成功案例库
</h1>
<p class="cases__slogan">
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
</p>
</div>
</header>
<CasesFilter
:query="query"
:counts="counts"
:total="total"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<div class="container cases__body">
<CasesFilter
:query="query"
:type-options="typeOptions"
:industry-options="industryOptions"
:scale-options="scaleOptions"
:case-total="caseTotal"
:preview-total="previewTotal"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<CasesGrid :cases="list" @view="onViewDetail" />
<div class="cases__main">
<CasesSearchBar
:keyword="query.keyword"
:favorite-only="query.favoriteOnly"
@update:keyword="onUpdateKeyword"
@search="onSearch"
@reset="onReset"
@toggle-favorite-only="onToggleFavoriteOnly"
/>
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
<CasesGrid
:cases="list"
:total="total"
@view="onViewDetail"
@favorite="onFavorite"
/>
<div class="cases__pager">
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.cases {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__hero {
padding: @spacing-lg 0;
background: @white;
}
&__title {
font-size: @font-size-xxl;
font-weight: 600;
@@ -85,6 +127,24 @@ onMounted(() => {
&__slogan {
margin-top: @spacing-xs;
font-size: @font-size-base;
color: @text-color-secondary;
}
&__body {
.flex(row, flex-start, flex-start);
gap: @spacing-base;
padding-top: @spacing-base;
}
&__main {
.flex(column, flex-start);
flex: 1;
gap: @spacing-base;
min-width: 0;
}
&__pager {
.flex(row, flex-end);
}
}
</style>

View File

@@ -1,4 +1,7 @@
export { default } from './index.vue'
export { default as DemandStep1Business } from './cpns/DemandStep1Business.vue'
export { default as DemandStep2Info } from './cpns/DemandStep2Info.vue'
export { default as DemandStep3Confirm } from './cpns/DemandStep3Confirm.vue'
export { default as DemandBizTypes } from './cpns/DemandBizTypes.vue'
export { default as DemandInfoForm } from './cpns/DemandInfoForm.vue'
export { default as DemandConfirmForm } from './cpns/DemandConfirmForm.vue'
export { default as DemandAttachments } from './cpns/DemandAttachments.vue'
export { default as DemandActionBar } from './cpns/DemandActionBar.vue'
export { default as BizTypeIcon } from './cpns/BizTypeIcon.vue'

View File

@@ -1,131 +1,143 @@
<script setup lang="ts">
// 需求提报(单页三段卡片 + 底部固定操作条,数据全部走真实接口)
// 页面 → store → api;页面不直接 import api
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { AppButton, StepForm } from '@/components'
import { useDemandStore } from '@/stores/modules/demand'
import { useUserStore } from '@/stores/modules/user'
import type { BusinessType } from '@/types/api'
import type { Demand } from '@/types/business/demand'
import DemandStep1Business from './cpns/DemandStep1Business.vue'
import DemandStep2Info from './cpns/DemandStep2Info.vue'
import DemandStep3Confirm from './cpns/DemandStep3Confirm.vue'
import type { Demand, DemandAttachment } from '@/types/business/demand'
import DemandActionBar from './cpns/DemandActionBar.vue'
import DemandBizTypes from './cpns/DemandBizTypes.vue'
import DemandConfirmForm from './cpns/DemandConfirmForm.vue'
import DemandInfoForm from './cpns/DemandInfoForm.vue'
const STEP_TITLES = ['选择业务类型', '填写需求信息', '确认信息并补充备注']
const router = useRouter()
const demandStore = useDemandStore()
const userStore = useUserStore()
const { form, step, loading } = storeToRefs(demandStore)
const {
form,
bizTypeOptions,
budgetOptions,
loading,
saving,
submitting,
uploading,
} = storeToRefs(demandStore)
function onSelectBusiness(type: BusinessType): void {
demandStore.patchForm({ businessType: type })
function onBizType(value: string): void {
demandStore.patchForm({ bizType: value })
}
function onUpdateDemand(partial: Partial<Demand>): void {
function onUpdate(partial: Partial<Demand>): void {
demandStore.patchForm(partial)
}
function onUpload(file: File): void {
demandStore.addAttachment(file)
void demandStore.addAttachment(file)
}
function onRemoveFile(file: Demand['attachments'][number]): void {
function onRemoveFile(file: DemandAttachment): void {
demandStore.removeAttachment(file)
}
function goPrev(): void {
if (step.value > 1) demandStore.setStep(step.value - 1)
}
function goNext(): void {
if (step.value < 3) demandStore.setStep(step.value + 1)
}
async function onSubmit(): Promise<void> {
const ok = await demandStore.submit()
if (ok && userStore.isLoggedIn) {
void router.push('/project')
}
}
function onSaveDraft(): void {
void demandStore.saveDraft()
}
function onReset(): void {
demandStore.reset()
void demandStore.reset()
}
async function onSubmit(): Promise<void> {
await demandStore.submit()
}
onMounted(() => {
void demandStore.loadDraft()
void demandStore.init()
})
</script>
<template>
<div class="demand">
<header class="demand__head">
<h1 class="demand__title">
需求提报
</h1>
<p class="demand__subtitle text-secondary">
填写您的需求信息,专业顾问将快速与您联系
</p>
</header>
<div class="demand-page">
<div class="container demand" :class="{ 'is-loading': loading }">
<header class="demand__head">
<h1 class="demand__title">
需求提报
</h1>
<p class="demand__subtitle">
填写您的需求信息,专业顾问将快速与您联系
</p>
</header>
<StepForm :titles="STEP_TITLES" :current="step - 1">
<DemandStep1Business
v-if="step === 1"
:demand="form"
@select="onSelectBusiness"
/>
<DemandStep2Info
v-else-if="step === 2"
:demand="form"
@update:demand="onUpdateDemand"
@upload="onUpload"
@remove-file="onRemoveFile"
/>
<DemandStep3Confirm
v-else
:demand="form"
@update:demand="onUpdateDemand"
/>
</StepForm>
<!-- ① 选择业务类型 -->
<section class="demand__card">
<h2 class="demand__section">
<span class="demand__index">1</span>
选择业务类型<span class="demand__required">*</span>
</h2>
<DemandBizTypes
:options="bizTypeOptions"
:model-value="form.bizType"
@update:model-value="onBizType"
/>
</section>
<div class="demand__nav">
<AppButton :disabled="step === 1 || loading" @click="goPrev">
上一步
</AppButton>
<AppButton v-if="step < 3" type="primary" :disabled="loading" @click="goNext">
下一步
</AppButton>
<!-- ② 填写需求信息 -->
<section class="demand__card">
<h2 class="demand__section">
<span class="demand__index">2</span>
填写需求信息<span class="demand__required">*</span>
</h2>
<DemandInfoForm
:demand="form"
:budget-options="budgetOptions"
:uploading="uploading"
@update="onUpdate"
@upload="onUpload"
@remove-file="onRemoveFile"
/>
</section>
<!-- ③ 确认信息并补充备注 -->
<section class="demand__card">
<h2 class="demand__section">
<span class="demand__index">3</span>
确认信息并补充备注
</h2>
<DemandConfirmForm
:demand="form"
@update="onUpdate"
/>
</section>
</div>
<div class="demand__actions">
<AppButton type="primary" size="lg" :loading="loading" @click="onSubmit">
立即提报
</AppButton>
<AppButton size="lg" :disabled="loading" @click="onSaveDraft">
保存草稿
</AppButton>
<AppButton size="lg" :disabled="loading" @click="onReset">
重置
</AppButton>
</div>
<DemandActionBar
:saving="saving"
:submitting="submitting"
@save-draft="onSaveDraft"
@reset="onReset"
@submit="onSubmit"
/>
</div>
</template>
<style lang="less" scoped>
.demand {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
.demand-page {
padding-top: @spacing-lg;
// 固定底条 72px + 呼吸间距,避免遮住 ③ 卡片
padding-bottom: 104px;
&.is-loading {
.demand {
opacity: 0.6;
pointer-events: none;
}
}
}
.demand {
// 设计稿:页头下与首块间距 ≈ 20px
&__head {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
margin-bottom: @spacing-base;
}
&__title {
@@ -136,16 +148,44 @@ onMounted(() => {
&__subtitle {
font-size: @font-size-base;
color: @text-color-secondary;
}
&__nav {
.flex(row, space-between);
width: 100%;
&__card {
padding: @spacing-lg;
border-radius: @radius-base;
background: @white;
// 设计稿块间距 18px
& + & {
margin-top: 18px;
}
}
&__actions {
.flex(row, center);
gap: @spacing-base;
&__section {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
/** 区块序号:实心蓝圆 + 白色数字 */
&__index {
.flex(row, center, center);
width: 18px;
height: 18px;
border-radius: 50%;
background: @primary-color;
font-size: @font-size-sm;
font-weight: 600;
color: @white;
}
&__required {
margin-left: 2px;
color: @danger-color;
}
}
</style>

View File

@@ -16,24 +16,28 @@ defineProps<Props>()
</h3>
<span class="home-business__slogan text-secondary">研发智造一体化自营平台,全链路质量兜底</span>
</div>
<div class="home-business__grid">
<RouterLink
v-for="line in lines"
:key="line.id"
:to="`/business/${line.id}`"
class="home-business__card"
:class="`home-business__card--${line.id}`"
>
<div class="home-business__icon">
<!-- TODO(蓝湖): 替换业务图标切图 -->
<span>{{ line.name.slice(0, 2) }}</span>
<div class="home-business__card-head">
<span class="home-business__icon">
<!-- TODO(蓝湖): 替换业务图标切图 -->
{{ line.name.slice(0, 2) }}
</span>
<h4 class="home-business__name">
{{ line.name }}
</h4>
</div>
<h4 class="home-business__name">
{{ line.name }}
</h4>
<p class="home-business__desc text-secondary">
<p class="home-business__desc">
{{ line.desc }}
</p>
<span class="home-business__more">了解详细 ></span>
<span class="home-business__more">了解详情 ></span>
</RouterLink>
</div>
</section>
@@ -64,29 +68,31 @@ defineProps<Props>()
&__card {
.flex(column, flex-start);
gap: @spacing-sm;
gap: @spacing-base;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
background: @primary-light;
box-shadow: @shadow-base;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
.home-business__more {
color: @primary-color;
}
}
}
&__card-head {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__icon {
.flex(row, center);
width: 48px;
height: 48px;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: @radius-base;
background: @primary-light;
background: @white;
color: @primary-color;
font-size: @font-size-base;
font-weight: 600;
@@ -101,13 +107,68 @@ defineProps<Props>()
&__desc {
min-height: 40px;
font-size: @font-size-sm;
color: @text-color-secondary;
.multi-ellipsis(2);
}
&__more {
align-self: flex-end;
font-size: @font-size-sm;
color: @text-color-secondary;
transition: color 0.2s;
}
// 四类业务各自的主题色(与设计稿一致:卡片浅色底 + 同色系图标/文案)
&__card--internet {
background: #e8f0ff;
.home-business__icon {
color: #3370ff;
}
.home-business__desc,
.home-business__more {
color: #598bff;
}
}
&__card--hardware {
background: #e7f8ec;
.home-business__icon {
color: #22b14c;
}
.home-business__desc,
.home-business__more {
color: #34c724;
}
}
&__card--iot {
background: #f0eaff;
.home-business__icon {
color: #7c4dff;
}
.home-business__desc,
.home-business__more {
color: #9a7bff;
}
}
&__card--manufacturing {
background: #fff2e4;
.home-business__icon {
color: #ff8f1f;
}
.home-business__desc,
.home-business__more {
color: #ffa94d;
}
}
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CaseCard } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases'
@@ -46,16 +46,39 @@ function switchCategory(value: BusinessType | 'all'): void {
</button>
</div>
<RouterLink to="/cases" class="home-cases__more link">
查看更多 >
查看全部 >
</RouterLink>
</div>
<div class="home-cases__grid">
<CaseCard
<article
v-for="item in cases"
:key="item.id"
:item="item"
@view="$router.push(`/cases/${$event}`)"
/>
class="home-cases__card"
@click="$router.push(`/cases/${item.id}`)"
>
<div class="home-cases__cover">
<!-- TODO(蓝湖): 替换案例封面切图 -->
<img v-if="item.cover" v-lazy="item.cover" class="home-cases__img" :alt="item.title">
<div v-else class="home-cases__img home-cases__img--placeholder" />
<span class="home-cases__tag" :class="`home-cases__tag--${item.category}`">
{{ formatBusinessType(item.category) }}
</span>
</div>
<div class="home-cases__body">
<h4 class="home-cases__name">
{{ item.title }}
</h4>
<ul class="home-cases__meta">
<li>客户对象:{{ item.clientType }}</li>
<li>研发周期:{{ item.duration }}</li>
<li>团队规模:{{ item.teamSize }}人</li>
<li>完成时间:{{ item.finishDate }}</li>
</ul>
<span class="home-cases__view">查看详情</span>
</div>
</article>
</div>
</section>
</template>
@@ -64,6 +87,7 @@ function switchCategory(value: BusinessType | 'all'): void {
.home-cases {
&__head {
.flex(row, space-between);
gap: @spacing-base;
margin-bottom: @spacing-base;
}
@@ -75,6 +99,7 @@ function switchCategory(value: BusinessType | 'all'): void {
&__tabs {
.flex(row, center);
flex: 1;
gap: @spacing-sm;
}
@@ -102,5 +127,103 @@ function switchCategory(value: BusinessType | 'all'): void {
grid-template-columns: repeat(4, 1fr);
gap: @spacing-base;
}
&__card {
overflow: hidden;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
.home-cases__view {
background: @primary-color;
color: @white;
}
}
}
&__cover {
position: relative;
height: 130px;
overflow: hidden;
}
&__img {
width: 100%;
height: 100%;
object-fit: cover;
&--placeholder {
background: linear-gradient(135deg, @primary-light, @bg-color);
}
}
&__tag {
position: absolute;
top: @spacing-sm;
left: @spacing-sm;
padding: 2px @spacing-sm;
border-radius: @radius-sm;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
&--hardware {
background: #22b14c;
}
&--iot {
background: #7c4dff;
}
&--manufacturing {
background: #ff8f1f;
}
&--other {
background: @text-color-secondary;
}
}
&__body {
padding: @spacing-base;
}
&__name {
margin-bottom: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.multi-ellipsis(1);
}
&__meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
li {
.ellipsis();
}
}
&__view {
display: block;
width: fit-content;
margin: @spacing-base 0 0 auto;
padding: 2px @spacing-base;
border: 1px solid @primary-color;
border-radius: @radius-sm;
color: @primary-color;
font-size: @font-size-sm;
transition: all 0.2s;
}
}
</style>

View File

@@ -1,70 +1,185 @@
<script setup lang="ts">
// 快捷入口为固定模块链接(纯 UI,静态数据)
const entries = [
{ label: '财务台账', path: '/finance/bills', icon: '💰' },
{ label: '合同台账', path: '/contract', icon: '📄' },
{ label: '客服工作台', path: '/service', icon: '💬' },
{ label: '案例库管理', path: '/cases', icon: '📁' },
{ label: '工单需求池', path: '/demand', icon: '📋' },
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useHomeStore } from '@/stores/modules/home'
import type { PortalEntryCard } from '@/types/business/home'
import { toPortalEntryCards } from '@/utils/entryIcon'
import HomeAppDialog from './HomeAppDialog.vue'
/**
* 首页「快捷入口」卡片。
*
* 数据来自 store.quickEntries(← /vermins/portalEntryUser/list 的 groupType=1 项),
* 接口不可用(游客 / 无权限)时回退到内置默认入口,保证卡片不空。
* 「更多应用」打开 HomeAppDialog,可在快捷 / 其他两组之间调整入口。
*/
const homeStore = useHomeStore()
const { quickEntries } = storeToRefs(homeStore)
const router = useRouter()
const dialogVisible = ref(false)
/** 接口不可用时的默认入口(与改造前的静态入口保持一致) */
const FALLBACK_QUICK: PortalEntryCard[] = [
{ id: 0, label: '财务台账', route: '/finance/bills', groupType: '1', icon: '💰', isImage: false, tone: 'amber' },
{ id: 0, label: '合同台账', route: '/contract', groupType: '1', icon: '📄', isImage: false, tone: 'blue' },
{ id: 0, label: '客服工作台', route: '/service', groupType: '1', icon: '💬', isImage: false, tone: 'green' },
{ id: 0, label: '案例库管理', route: '/cases', groupType: '1', icon: '📁', isImage: false, tone: 'purple' },
{ id: 0, label: '工单需求池', route: '/demand', groupType: '1', icon: '📋', isImage: false, tone: 'cyan' },
]
/** 前端已注册的静态路由(route 无效时不跳转,避免出现无匹配路由) */
const knownPaths = new Set(router.getRoutes().map((item) => item.path))
/** 卡片展示的入口(最多 5 个) */
const entries = computed<PortalEntryCard[]>(() => {
const list = toPortalEntryCards(quickEntries.value)
return (list.length > 0 ? list : FALLBACK_QUICK).slice(0, 5)
})
/** 解析可跳转路径:优先后端 route,其次同名默认入口,最后回首页 */
function resolvePath(card: PortalEntryCard): string {
if (card.route && knownPaths.has(card.route)) return card.route
const fallback = FALLBACK_QUICK.find((item) => item.label === card.label)
if (fallback && knownPaths.has(fallback.route)) return fallback.route
return '/'
}
function onMore(): void {
// 打开时同步一次,确保分组是后端最新结果
void homeStore.fetchPortalEntries()
dialogVisible.value = true
}
</script>
<template>
<section class="home-quick">
<h3 class="home-quick__title">
快捷入口
</h3>
<div class="home-quick__head">
<span class="home-quick__head-icon">⊞</span>
<h3 class="home-quick__title">
快捷入口
</h3>
</div>
<div class="home-quick__list">
<RouterLink
v-for="entry in entries"
:key="entry.label"
:to="entry.path"
:key="`${entry.label}-${entry.id}`"
:to="resolvePath(entry)"
class="home-quick__item"
>
<span class="home-quick__icon">{{ entry.icon }}</span>
<span class="home-quick__icon" :class="`home-quick__icon--${entry.tone}`">
<img v-if="entry.isImage" :src="entry.icon" :alt="entry.label" class="home-quick__img">
<template v-else>{{ entry.icon }}</template>
</span>
<span class="home-quick__label">{{ entry.label }}</span>
</RouterLink>
</div>
<button class="home-quick__more link" type="button" @click="onMore">
更多应用 >
</button>
<HomeAppDialog v-model:visible="dialogVisible" />
</section>
</template>
<style lang="less" scoped>
.home-quick {
.flex(column, flex-start, stretch);
flex-shrink: 0;
width: 320px;
gap: @spacing-base;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__head-icon {
color: @heading-color;
font-size: @font-size-lg;
}
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(row, space-between);
gap: @spacing-base;
.flex(row, space-between, flex-start);
gap: @spacing-sm;
}
&__item {
.flex(column, center);
.flex(column, flex-start, center);
gap: @spacing-sm;
flex: 1;
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
transition: transform 0.2s, box-shadow 0.2s;
transition: transform 0.2s;
&:hover {
box-shadow: @shadow-lg;
transform: translateY(-2px);
}
}
&__icon {
font-size: 24px;
.flex(row, center);
width: 44px;
height: 44px;
border-radius: @radius-base;
font-size: @font-size-xl;
&--amber {
background: #fff3e0;
}
&--blue {
background: @primary-light;
}
&--green {
background: #e7f8ec;
}
&--purple {
background: #f0eaff;
}
&--red {
background: #ffeceb;
}
&--cyan {
background: #e4f6fa;
}
}
&__img {
width: 24px;
height: 24px;
object-fit: contain;
}
&__label {
font-size: @font-size-base;
font-size: @font-size-sm;
color: @text-color;
text-align: center;
}
&__more {
align-self: flex-end;
padding: 0;
border: 0;
background: transparent;
font-size: @font-size-sm;
color: @text-color-secondary;
cursor: pointer;
}
}
</style>

View File

@@ -7,10 +7,11 @@ interface Props {
defineProps<Props>()
/** 展示顺序与设计稿一致:累积项目 / 进行中 / 已提交 */
const items = [
{ key: 'pending', label: '待您处理' },
{ key: 'total', label: '累积项目' },
{ key: 'inProgress', label: '进行中' },
{ key: 'total', label: '累计项目' },
{ key: 'pending', label: '已提交' },
] as const
</script>
@@ -22,24 +23,23 @@ const items = [
class="home-stats__item"
>
<span class="home-stats__value">{{ stats[item.key] }}</span>
<span class="home-stats__label text-secondary">{{ item.label }}</span>
<span class="home-stats__label">{{ item.label }}</span>
</div>
</section>
</template>
<style lang="less" scoped>
.home-stats {
.flex(row, space-between);
.flex(row, flex-end);
gap: @spacing-base;
&__item {
.flex(column, center);
flex: 1;
.flex(column, center, center);
gap: @spacing-xs;
padding: @spacing-base @spacing-lg;
min-width: 84px;
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
background: rgba(255, 255, 255, 0.92);
}
&__value {
@@ -49,7 +49,8 @@ const items = [
}
&__label {
font-size: @font-size-base;
font-size: @font-size-sm;
color: @text-color-secondary;
}
}
</style>

View File

@@ -18,57 +18,42 @@ const greeting = computed(() => {
</script>
<template>
<section class="home-welcome">
<div class="home-welcome__info">
<h2 class="home-welcome__greeting">
{{ greeting }}
</h2>
<p class="home-welcome__summary text-secondary">
{{ summary }}
</p>
</div>
<div class="home-welcome__brand">
<!-- TODO(蓝湖): 替换头像切图 -->
<span class="home-welcome__avatar">王</span>
<span class="home-welcome__appName">虫子智造</span>
</div>
</section>
<div class="home-welcome">
<h2 class="home-welcome__greeting">
{{ greeting }}
<span class="home-welcome__wave">👋</span>
</h2>
<p class="home-welcome__summary">
{{ summary }}
</p>
</div>
</template>
<style lang="less" scoped>
.home-welcome {
.flex(row, space-between);
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: linear-gradient(120deg, @primary-color, @primary-hover);
color: @white;
.flex(column, center, flex-start);
gap: @spacing-sm;
&__greeting {
.flex(row, flex-start);
gap: @spacing-sm;
font-size: @font-size-xxl;
font-weight: 600;
color: @white;
}
&__summary {
margin-top: @spacing-xs;
color: rgba(255, 255, 255, 0.85);
}
&__brand {
&__wave {
.flex(row, center);
gap: @spacing-sm;
}
&__avatar {
.flex(row, center);
width: 40px;
height: 40px;
width: 32px;
height: 32px;
border-radius: @radius-round;
background: rgba(255, 255, 255, 0.25);
font-size: @font-size-lg;
}
&__appName {
font-size: @font-size-base;
&__summary {
font-size: @font-size-lg;
color: rgba(255, 255, 255, 0.9);
}
}
</style>

View File

@@ -1,12 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 业务详情页:数据只经 useHomeStore,不直接 import api
import { useHomeStore } from '@/stores/modules/home'
import type { BusinessType } from '@/types/api'
import HomeServiceTabs from './cpns/HomeServiceTabs.vue'
import HomeServiceContents from './cpns/HomeServiceContents.vue'
import HomeServiceTypes from './cpns/HomeServiceTypes.vue'
const route = useRoute()
const router = useRouter()
const homeStore = useHomeStore()
const line = computed(() => homeStore.currentBusiness)
@@ -47,10 +50,15 @@ onMounted(load)
</div>
</header>
<HomeServiceTabs
:service-types="line.serviceTypes"
:service-content="line.serviceContent"
/>
<HomeServiceTypes :service-types="line.serviceTypes" />
<HomeServiceContents :contents="line.contents" />
<div class="business-detail__cta">
<AppButton type="primary" size="lg" @click="router.push('/demand')">
立即提报
</AppButton>
</div>
</template>
<p v-else class="text-secondary">
@@ -104,5 +112,13 @@ onMounted(load)
&__slogan {
font-size: @font-size-base;
}
&__cta {
padding-top: @spacing-base;
.app-button {
width: 100%;
}
}
}
</style>

View File

@@ -1,7 +1,11 @@
export { default } from './index.vue'
export { default as HomeBanner } from './cpns/HomeBanner.vue'
export { default as HomeWelcome } from './cpns/HomeWelcome.vue'
export { default as HomeStats } from './cpns/HomeStats.vue'
export { default as HomeQuickEntries } from './cpns/HomeQuickEntries.vue'
export { default as HomeAppDialog } from './cpns/HomeAppDialog.vue'
export { default as HomeBusinessCards } from './cpns/HomeBusinessCards.vue'
export { default as HomeCaseShowcase } from './cpns/HomeCaseShowcase.vue'
export { default as HomeServiceTabs } from './cpns/HomeServiceTabs.vue'
export { default as HomeServiceTypes } from './cpns/HomeServiceTypes.vue'
export { default as HomeServiceContents } from './cpns/HomeServiceContents.vue'

View File

@@ -4,23 +4,18 @@ import { storeToRefs } from 'pinia'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useHomeStore } from '@/stores/modules/home'
import type { BusinessType } from '@/types/api'
import HomeBanner from './cpns/HomeBanner.vue'
import HomeWelcome from './cpns/HomeWelcome.vue'
import HomeStats from './cpns/HomeStats.vue'
import HomeQuickEntries from './cpns/HomeQuickEntries.vue'
import HomeBusinessCards from './cpns/HomeBusinessCards.vue'
import HomeCaseShowcase from './cpns/HomeCaseShowcase.vue'
import HomeServiceTabs from './cpns/HomeServiceTabs.vue'
const homeStore = useHomeStore()
const { stats, businessLines, featuredCases } = storeToRefs(homeStore)
const { stats, banners, businessLines, featuredCases } = storeToRefs(homeStore)
const todaySummary = computed(() => `今天有${stats.value.inProgress}个项目正在进行`)
// 服务 Tab 展示互联网软件线内容(服务内容区默认锚点)
const serviceLine = computed(
() => businessLines.value.find((line) => line.id === 'internet') ?? businessLines.value[0],
)
function onCategoryChange(category: BusinessType | 'all'): void {
void homeStore.fetchFeaturedCases(category)
}
@@ -32,19 +27,28 @@ onMounted(() => {
<template>
<div class="home">
<HomeWelcome :summary="todaySummary" />
<HomeStats :stats="stats" />
<HomeQuickEntries />
<HomeBusinessCards :lines="businessLines" />
<HomeCaseShowcase
:cases="featuredCases"
@category-change="onCategoryChange"
/>
<HomeServiceTabs
v-if="serviceLine"
:service-types="serviceLine.serviceTypes"
:service-content="serviceLine.serviceContent"
/>
<div class="container">
<HomeBanner :banners="banners" />
</div>
<div class="container home__dashboard">
<section class="home__dashboard-main">
<HomeWelcome :summary="todaySummary" />
<HomeStats :stats="stats" />
</section>
<HomeQuickEntries />
</div>
<div class="container">
<HomeBusinessCards :lines="businessLines" />
</div>
<div class="container">
<HomeCaseShowcase
:cases="featuredCases"
@category-change="onCategoryChange"
/>
</div>
</div>
</template>
@@ -54,5 +58,20 @@ onMounted(() => {
gap: @spacing-xl;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__dashboard {
.flex(row, space-between, stretch);
gap: @spacing-base;
}
&__dashboard-main {
.flex(row, space-between, center);
flex: 1;
gap: @spacing-lg;
padding: @spacing-xl;
border-radius: @radius-lg;
background: linear-gradient(120deg, @primary-color, #6a5cff);
box-shadow: @shadow-base;
}
}
</style>

View File

@@ -15,8 +15,9 @@ const userStore = useUserStore()
const activeTab = ref<LoginTab>('account')
const account = ref('')
const password = ref('')
// 演示/联调默认账号:登录页打开即自动预填,可直接点登录(生产环境请清空)
const account = ref('admin')
const password = ref('admin123')
const phone = ref('')
const code = ref('')
const agree = ref(false)

View File

@@ -2,45 +2,30 @@
import { onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { PasswordStrength, SmsCodeInput } from '@/components'
import { PasswordStrength } from '@/components'
import { useUserStore } from '@/stores/modules/user'
import { isPhone, isValidPassword } from '@/utils/validate'
const router = useRouter()
const userStore = useUserStore()
const phone = ref('')
const code = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const agree = ref(false)
let redirectTimer: number | null = null
async function onGetCode(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
await userStore.sendSmsCode(phone.value)
}
catch {
// 失败提示已由 store 统一弹出,此处仅避免未处理的 rejection
}
}
async function onRegister(): Promise<void> {
if (!isPhone(phone.value)) {
ElMessage.warning('手机号格式不正确')
if (!username.value.trim()) {
ElMessage.warning('请输入账号')
return
}
if (!/^\d{6}$/.test(code.value)) {
ElMessage.warning('请输入6位验证码')
if (username.value.trim().length < 2 || username.value.trim().length > 30) {
ElMessage.warning('账号长度应为2-30位')
return
}
if (!isValidPassword(password.value)) {
ElMessage.warning('密码长度应为6-20位')
if (password.value.length < 5 || password.value.length > 30) {
ElMessage.warning('密码长度应为5-30位')
return
}
if (password.value !== confirmPassword.value) {
@@ -52,11 +37,9 @@ async function onRegister(): Promise<void> {
return
}
const ok = await userStore.register({
phone: phone.value,
code: code.value,
username: username.value.trim(),
password: password.value,
confirmPassword: confirmPassword.value,
agree: agree.value,
})
if (ok) {
redirectTimer = window.setTimeout(() => {
@@ -94,20 +77,19 @@ onBeforeUnmount(() => {
<form class="auth-page__form" @submit.prevent="onRegister">
<input
v-model="phone"
v-model="username"
class="auth-page__input"
type="text"
placeholder="请输入手机号"
maxlength="11"
autocomplete="tel"
placeholder="请输入账号(2-30位)"
maxlength="30"
autocomplete="username"
>
<SmsCodeInput v-model:code="code" @get-code="onGetCode" />
<div class="auth-page__pwd-wrap">
<input
v-model="password"
class="auth-page__input"
type="password"
placeholder="设置密码(6-20位)"
placeholder="设置密码(5-30位)"
autocomplete="new-password"
>
<PasswordStrength :password="password" />

View File

@@ -109,7 +109,7 @@ onMounted(() => {
</script>
<template>
<div class="after-sales">
<div class="container after-sales">
<nav class="after-sales__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
@@ -247,7 +247,7 @@ onMounted(() => {
<style lang="less" scoped>
.after-sales {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
@@ -257,12 +257,12 @@ onMounted(() => {
}
&__section {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-lg;
}
&__field {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
}
@@ -343,7 +343,7 @@ onMounted(() => {
}
&__files {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}

View File

@@ -69,7 +69,7 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
<style lang="less" scoped>
.project-after-sales {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
@@ -77,7 +77,7 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
box-shadow: @shadow-base;
&__card {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
padding: @spacing-base;
border: 1px solid @border-color;

View File

@@ -64,7 +64,7 @@ const emit = defineEmits<{
<style lang="less" scoped>
.project-files {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
@@ -78,7 +78,7 @@ const emit = defineEmits<{
}
&__list {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
}
@@ -92,7 +92,7 @@ const emit = defineEmits<{
}
&__info {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
min-width: 0;
}

View File

@@ -1,13 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FilterPanel, type FilterChip } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { ProjectQuery, ProjectStatus } from '@/types/business/project'
import { computed, ref } from 'vue'
import { PROJECT_STATUS_LABEL, PROJECT_STATUS_OPTIONS, PROJECT_TIME_OPTIONS } from '@/utils/projectMeta'
import type { ProjectQuery, ProjectTypeCount } from '@/types/business/project'
/**
* 项目中心左侧筛选栏(按项目中心设计稿还原)。
*
* <p>⚠️ 业务类型的**值域、文案、件数全部来自 store**(`typeCounts` ← `GET /web/requirementPool/getFilterConditions`
* 的 `businessTypeCounts`,全量口径),组件内不定义业务值域;
* 只有后端未下发的组(项目状态 / 提交时间)才用 `utils/projectMeta.ts` 里的前端常量。</p>
*
* <p>「不限」统一用**空串**表示(不是 'all'):空串不会出现在 querystring 里,
* 正好对上后端 `eqIfText` 的「有值才等值匹配」。</p>
*/
interface Props {
query: ProjectQuery
/** 当前筛选命中的项目数(确定筛选按钮上的「N个结果」) */
total: number
typeCounts: ProjectTypeCount[]
}
interface Chip {
key: string
label: string
}
const props = defineProps<Props>()
@@ -18,48 +34,31 @@ const emit = defineEmits<{
reset: []
}>()
const businessTypeOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
/** 自选时间区间是否展开(设计稿只给了按钮态,展开后是两个原生日期输入) */
const rangeOpen = ref(false)
const statusOptions: Array<{ label: string; value: ProjectStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '待立项', value: '待立项' },
{ label: '进行中', value: '进行中' },
{ label: '待验收', value: '待验收' },
{ label: '已完成', value: '已完成' },
{ label: '售后中', value: '售后中' },
{ label: '已取消', value: '已取消' },
]
const timeShortcuts: Array<{ label: string; value: ProjectQuery['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 rangeLabel = computed(() => {
const { startDate, endDate } = props.query
if (!startDate && !endDate) return '开始时间—结束时间'
return `${startDate || '不限'} — ${endDate || '不限'}`
})
const chips = computed<FilterChip[]>(() => {
const list: FilterChip[] = []
if (props.query.businessType !== 'all') {
list.push({ key: 'businessType', label: formatBusinessType(props.query.businessType) })
const chips = computed<Chip[]>(() => {
const list: Chip[] = []
if (props.query.businessType) {
const found = props.typeCounts.find((item) => item.value === props.query.businessType)
list.push({ key: 'businessType', label: found?.label ?? props.query.businessType })
}
if (props.query.status !== 'all') {
list.push({ key: 'status', label: props.query.status })
if (props.query.status) {
list.push({ key: 'status', label: PROJECT_STATUS_LABEL[props.query.status] ?? props.query.status })
}
if (props.query.timeShortcut !== 'all') {
const label = timeShortcuts.find((item) => item.value === props.query.timeShortcut)?.label ?? ''
const label = PROJECT_TIME_OPTIONS.find((item) => item.value === props.query.timeShortcut)?.label ?? ''
list.push({ key: 'time', label })
}
if (props.query.startDate || props.query.endDate) {
list.push({ key: 'range', label: `${props.query.startDate || '不限'}~${props.query.endDate || '不限'}` })
}
return list
})
@@ -67,125 +66,386 @@ function patch(partial: Partial<ProjectQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
/** 快捷区间与自选区间互斥:点快捷区间会清掉自选日期 */
function onPickShortcut(value: ProjectQuery['timeShortcut']): void {
rangeOpen.value = false
patch({ timeShortcut: value, startDate: '', endDate: '' })
}
/** 选了自选日期就把快捷区间复位(避免两组条件同时生效) */
function onPickDate(partial: { startDate?: string; endDate?: string }): void {
patch({ timeShortcut: 'all', ...partial })
}
function onRemoveChip(key: string): void {
if (key === 'businessType') patch({ businessType: 'all' })
else if (key === 'status') patch({ status: 'all' })
if (key === 'businessType') patch({ businessType: '' })
else if (key === 'status') patch({ status: '' })
else if (key === 'time') patch({ timeShortcut: 'all' })
else if (key === 'range') patch({ startDate: '', endDate: '' })
}
function onClearChips(): void {
emit('update:query', {
...props.query,
businessType: 'all',
status: 'all',
businessType: '',
status: '',
timeShortcut: 'all',
startDate: '',
endDate: '',
})
}
</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="project-filter__group">
<span class="project-filter__group-title">业务类型</span>
<div class="project-filter__options">
<button
v-for="option in businessTypeOptions"
:key="option.value"
class="project-filter__option"
:class="{ 'is-active': query.businessType === option.value }"
@click="patch({ businessType: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
<aside class="project-filter">
<h2 class="project-filter__title">
筛选条件
</h2>
<!-- 项目状态(单选) -->
<div class="project-filter__group">
<span class="project-filter__group-title">项目状态</span>
<div class="project-filter__options">
<!-- 业务类型:复选外观的单选列表(值域 / 件数均来自后端统计) -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
业务类型
</h3>
<ul class="project-filter__list">
<li v-for="item in typeCounts" :key="item.value">
<button
type="button"
class="project-filter__row"
:class="{ 'is-active': query.businessType === item.value }"
@click="patch({ businessType: item.value === 'all' ? '' : item.value })"
>
<span class="project-filter__box" aria-hidden="true" />
<span class="project-filter__row-label">{{ item.label }}</span>
<span class="project-filter__row-count">{{ item.count }}</span>
</button>
</li>
</ul>
</section>
<!-- 项目状态:2 列胶囊网格 -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
项目状态
</h3>
<div class="project-filter__grid">
<button
v-for="option in statusOptions"
v-for="option in PROJECT_STATUS_OPTIONS"
:key="option.value"
class="project-filter__option"
type="button"
class="project-filter__pill"
:class="{ 'is-active': query.status === option.value }"
@click="patch({ status: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
</section>
<!-- 提交时间(快捷区间) -->
<div class="project-filter__group">
<span class="project-filter__group-title">提交时间</span>
<div class="project-filter__options">
<!-- 提交时间:快捷区间 + 自选区间 -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
提交时间
</h3>
<div class="project-filter__grid">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="project-filter__option"
:class="{ 'is-active': query.timeShortcut === shortcut.value }"
@click="patch({ timeShortcut: shortcut.value })"
v-for="option in PROJECT_TIME_OPTIONS"
:key="option.value"
type="button"
class="project-filter__pill"
:class="{ 'is-active': query.timeShortcut === option.value && !query.startDate && !query.endDate }"
@click="onPickShortcut(option.value)"
>
{{ shortcut.label }}
{{ option.label }}
</button>
<button
type="button"
class="project-filter__pill project-filter__pill--wide"
:class="{ 'is-active': rangeOpen || Boolean(query.startDate || query.endDate) }"
@click="rangeOpen = !rangeOpen"
>
{{ rangeLabel }}
</button>
</div>
<div v-if="rangeOpen" class="project-filter__range">
<input
class="project-filter__date"
type="date"
:value="query.startDate"
aria-label="开始时间"
@change="onPickDate({ startDate: ($event.target as HTMLInputElement).value })"
>
<span class="project-filter__range-sep">—</span>
<input
class="project-filter__date"
type="date"
:value="query.endDate"
aria-label="结束时间"
@change="onPickDate({ endDate: ($event.target as HTMLInputElement).value })"
>
</div>
</section>
<!-- 已选条件 -->
<section class="project-filter__chips">
<div class="project-filter__chips-head">
<span class="project-filter__chips-title">已选条件({{ chips.length }}个)</span>
<button
type="button"
class="project-filter__chips-clear"
@click="onClearChips"
>
清空
</button>
</div>
<div v-if="chips.length > 0" class="project-filter__chips-list">
<button
v-for="chip in chips"
:key="chip.key"
type="button"
class="project-filter__chip"
@click="onRemoveChip(chip.key)"
>
{{ chip.label }}
<span class="project-filter__chip-x" aria-hidden="true">×</span>
</button>
</div>
</section>
<!-- 底部操作 -->
<div class="project-filter__actions">
<button
type="button"
class="project-filter__reset"
@click="emit('reset')"
>
重置
</button>
<button
type="button"
class="project-filter__submit"
@click="emit('search')"
>
确定筛选({{ total }}个结果)
</button>
</div>
</FilterPanel>
</aside>
</template>
<style lang="less" scoped>
.project-filter {
// 左栏固定宽(设计稿实测 313 / 卡片间距 17 / 容器 1353)
flex: 0 0 313px;
padding: 20px 28px 24px;
border-radius: @radius-base;
background: @white;
&__title {
font-size: 24px;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__group {
.flex(row, flex-start);
gap: @spacing-base;
margin-top: 18px;
&:first-of-type {
margin-top: 22px;
}
}
&__group-title {
flex-shrink: 0;
width: 70px;
padding-top: 5px;
font-size: @font-size-base;
margin-bottom: 12px;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
// ── 业务类型列表 ──
&__list {
display: flex;
flex-direction: column;
}
&__option {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
&__row {
.flex(row, flex-start, center);
width: 100%;
height: 31px;
padding: 0 6px;
border-radius: @radius-sm;
background: @bg-color;
font-size: @font-size-base;
color: @heading-color;
transition: background-color 0.2s, color 0.2s;
&:hover {
background: @bg-color;
}
&.is-active {
background: @filter-selected-bg;
color: @primary-color;
}
}
&__box {
flex-shrink: 0;
width: 14px;
height: 14px;
margin-right: 10px;
border: 1px solid @border-color-dark;
border-radius: 2px;
background: @white;
}
&__row-label {
flex: 1;
text-align: left;
.ellipsis();
}
&__row-count {
flex-shrink: 0;
margin-left: @spacing-sm;
font-size: @font-size-base;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
}
// ── 胶囊网格(项目状态 / 提交时间)──
&__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
&__pill {
height: 27px;
padding: 0 @spacing-sm;
border-radius: @radius-round;
background: @pill-bg;
color: @text-color;
font-size: @font-size-base;
transition: background-color 0.2s, color 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
&--wide {
grid-column: span 2;
}
}
// ── 自选日期区间(点开后才出现)──
&__range {
.flex(row, space-between, center);
margin-top: 9px;
gap: @spacing-xs;
}
&__date {
flex: 1;
min-width: 0;
height: 27px;
padding: 0 @spacing-xs;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
color: @text-color;
font-size: @font-size-sm;
}
&__range-sep {
color: @text-color-secondary;
}
// ── 已选条件 ──
&__chips {
margin-top: 18px;
padding: 12px;
border-radius: @radius-base;
background: @chips-panel-bg;
}
&__chips-head {
.flex(row, space-between, center);
}
&__chips-title {
font-size: @font-size-sm;
color: @primary-color;
}
&__chips-clear {
font-size: @font-size-sm;
color: @primary-color;
&:hover {
opacity: 0.75;
}
}
&__chips-list {
.flex(row, flex-start, center);
flex-wrap: wrap;
margin-top: 8px;
gap: @spacing-sm;
}
&__chip {
.flex(row, center, center);
height: 22px;
padding: 0 10px;
border-radius: @radius-round;
background: @white;
color: @primary-color;
font-size: @font-size-sm;
&:hover {
background: @primary-light;
}
}
&__chip-x {
margin-left: 4px;
}
// ── 底部操作 ──
&__actions {
.flex(row, space-between, center);
margin-top: 16px;
gap: 10px;
}
&__reset {
flex: 0 0 32%;
height: 26px;
border-radius: @radius-round;
background: @pill-bg;
color: @text-color;
font-size: @font-size-base;
&:hover {
color: @primary-color;
}
}
&__submit {
flex: 1;
height: 26px;
border-radius: @radius-round;
background: @primary-color;
color: @white;
font-size: @font-size-base;
&:hover {
background: @primary-hover;
}
}
}
</style>

View File

@@ -130,7 +130,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<style lang="less" scoped>
.project-finance {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
@@ -150,7 +150,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__plan {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
width: 320px;
padding: @spacing-base;
@@ -171,7 +171,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__plan-meta {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}
@@ -222,7 +222,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__files {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-sm;
}
@@ -237,7 +237,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
}
&__file-info {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
min-width: 0;
}

View File

@@ -13,6 +13,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'待内部验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
@@ -70,7 +71,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
}
&__meta-item {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}

View File

@@ -138,7 +138,7 @@ defineProps<Props>()
}
&__body {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
}

View File

@@ -1,26 +1,58 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
import { computed } from 'vue'
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
import type { ProjectTypeCount, RdProjectVo } from '@/types/business/project'
/**
* 项目中心列表(按设计稿还原 + 后端 RdProjectVo 契约)。
*
* <p>设计稿要点:表头浅灰底 + 加粗居中、整表 #bbbbbb 网格线、行高约 76、
* 进度列**只给百分比文字**(不画进度条)、状态列是纯文字(不是彩色标签)、
* 最后一列是操作(「详情」,已完成的额外给「申请售后」)。</p>
*
* <p>三处「后端没有 / 前端不能编」的兜底:<br>
* 1. 业务类型列:SQL 里 `businessType` 已 JOIN 提报表**翻译成中文类型名**(如「互联网软件」),
* 直接渲染即可;`typeLabels`(提报 id → 名称)当前命不中,只作为后端改回下发 id 时的兜底。<br>
* 2. 进度列:`progress` 取项目表(未立项必为 null)⇒ 显示「—」,不假装成 0%。<br>
* 3. 状态列:取 `COALESCE(项目状态, 需求状态)`,未立项回落 `INITIATING`(待立项)。</p>
*/
interface Props {
projects: Project[]
projects: RdProjectVo[]
/** 业务类型件数列表(含「全部」);用它建 id → 中文 的映射 */
typeCounts: ProjectTypeCount[]
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
filtered: boolean
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
view: [id: string]
afterSales: [id: string]
}>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
/** businessTypeId → 业务类型中文 */
const typeLabels = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
for (const item of props.typeCounts) {
if (item.value !== 'all') map[item.value] = item.label
}
return map
})
function typeLabel(value: string | null | undefined): string {
if (!value) return '—'
return typeLabels.value[value] ?? value
}
function statusLabel(value: string | null | undefined): string {
if (!value) return '—'
return PROJECT_STATUS_LABEL[value] ?? value
}
function progressLabel(value: number | null | undefined): string {
return value == null ? '—' : `${value}%`
}
</script>
@@ -35,7 +67,7 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
<th>业务类型</th>
<th>进度</th>
<th>状态</th>
<th>操作</th>
<th />
</tr>
</thead>
<tbody>
@@ -45,37 +77,30 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
<td class="project-table__name" :title="item.projectName">
{{ item.projectName }}
</td>
<td>{{ formatBusinessType(item.businessType) }}</td>
<td>
<div class="project-table__progress">
<div class="project-table__track">
<div
class="project-table__bar"
:style="{ width: `${item.progress}%` }"
/>
</div>
<span class="project-table__percent">{{ item.progress }}%</span>
</div>
</td>
<td>
<StatusTag :label="item.status" :variant="STATUS_VARIANTS[item.status]" />
</td>
<td class="project-table__actions">
<button class="project-table__btn" @click="emit('view', item.id)">
详情
</button>
<td>{{ typeLabel(item.businessType) }}</td>
<td>{{ progressLabel(item.progress) }}</td>
<td>{{ statusLabel(item.status) }}</td>
<td class="project-table__ops">
<button
v-if="item.status === '已完成'"
class="project-table__btn project-table__btn--accent"
v-if="item.status === 'COMPLETED'"
type="button"
class="project-table__link"
@click="emit('afterSales', item.id)"
>
申请售后
</button>
<button
type="button"
class="project-table__link"
@click="emit('view', item.id)"
>
详情
</button>
</td>
</tr>
<tr v-if="projects.length === 0">
<td colspan="7" class="project-table__empty text-secondary">
暂无符合条件的项目
<td colspan="7" class="project-table__empty">
{{ filtered ? '暂无符合条件的项目' : '暂无项目' }}
</td>
</tr>
</tbody>
@@ -86,98 +111,71 @@ const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' |
<style lang="less" scoped>
.project-table {
overflow-x: auto;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
.scrollbar();
&__grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: @font-size-base;
th,
td {
padding: @spacing-base @spacing-base;
text-align: left;
border-bottom: 1px solid @border-color;
white-space: nowrap;
border: 1px solid @project-table-line;
text-align: center;
vertical-align: middle;
// 设计稿表格正文实测 12px(比左栏正文小一号),沿用 14px 会把操作列挤到换行
font-size: @font-size-sm;
}
th {
background: @bg-color-light;
height: 44px;
background: @project-table-head-bg;
color: @heading-color;
font-weight: 600;
}
td {
color: @text-color;
height: 76px;
color: @heading-color;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: @bg-color-light;
}
// 列宽按设计稿实测比例(1002 总宽 → 61/294/218/107/144/90/88)
th:nth-child(1) { width: 6.09%; }
th:nth-child(2) { width: 29.34%; }
th:nth-child(3) { width: 21.76%; }
th:nth-child(4) { width: 10.68%; }
th:nth-child(5) { width: 14.37%; }
th:nth-child(6) { width: 8.98%; }
th:nth-child(7) { width: 8.78%; }
}
&__name {
max-width: 220px;
color: @heading-color;
padding: 0 @spacing-sm;
.ellipsis();
}
&__progress {
.flex(row, center);
gap: @spacing-sm;
min-width: 140px;
&__ops {
.flex(row, center, center);
gap: 10px;
white-space: nowrap;
}
&__track {
flex: 1;
height: 6px;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__percent {
flex-shrink: 0;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
}
&__btn {
font-size: @font-size-base;
&__link {
flex: 0 0 auto;
width: auto;
color: @primary-color;
font-size: @font-size-sm;
white-space: nowrap;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--accent {
color: @warning-color;
}
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
color: @text-color-secondary;
}
}
</style>

View File

@@ -73,7 +73,7 @@ defineProps<Props>()
}
&__info {
.flex(column, flex-start);
.flex(column, flex-start, stretch);
gap: 2px;
}

View File

@@ -36,16 +36,20 @@ function load(): void {
void projectStore.fetchDetail(projectId.value)
}
/**
* ⚠️ 上传 / 删除附件、售后提交都还没有后端实现
* (`ProjectRequirementUserController` 只有 create / list / get / serviceTypeList)。
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据现在来自真实接口,
* 混入前端自造条目会让刷新前后不一致。
*/
function onUpload(file: File): void {
void projectStore.uploadFile(file).then(() => {
ElMessage.success('文件已上传')
})
void file
ElMessage.info('附件上传接口待接入')
}
function onDeleteFile(index: number): void {
void projectStore.deleteFile(index).then(() => {
ElMessage.success('文件已删除')
})
void index
ElMessage.info('附件删除接口待接入')
}
function onPreviewFile(): void {
@@ -87,7 +91,7 @@ onMounted(load)
</script>
<template>
<div class="project-detail">
<div class="container project-detail">
<nav class="project-detail__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
@@ -166,7 +170,9 @@ onMounted(load)
<style lang="less" scoped>
.project-detail {
.flex(column, flex-start);
// ⚠️ mixin 签名是 .flex(@direction; @justify; @align) —— 第二参是 justify、**第三参才是 align**。
// 原来只传两参 ⇒ align 取默认的 center ⇒ 纵向容器里所有卡片被压成内容宽度、水平居中(380px 窄条)。
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;

View File

@@ -1,6 +1,8 @@
export { default } from './index.vue'
export { default as ProjectFilter } from './cpns/ProjectFilter.vue'
export { default as ProjectTable } from './cpns/ProjectTable.vue'
export { default as ProjectToolbar } from './cpns/ProjectToolbar.vue'
export { default as ProjectPager } from './cpns/ProjectPager.vue'
export { default as ProjectInfoCard } from './cpns/ProjectInfoCard.vue'
export { default as ProjectTeam } from './cpns/ProjectTeam.vue'
export { default as ProjectMilestones } from './cpns/ProjectMilestones.vue'

View File

@@ -1,22 +1,37 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useProjectStore } from '@/stores/modules/project'
import type { ProjectQuery } from '@/types/business/project'
import ProjectFilter from './cpns/ProjectFilter.vue'
import ProjectPager from './cpns/ProjectPager.vue'
import ProjectTable from './cpns/ProjectTable.vue'
import ProjectToolbar from './cpns/ProjectToolbar.vue'
const router = useRouter()
const projectStore = useProjectStore()
const { query, list, total } = storeToRefs(projectStore)
const { query, list, total, typeCounts } = storeToRefs(projectStore)
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
const filtered = computed(() => Boolean(
query.value.keyword
|| query.value.businessType
|| query.value.status
|| query.value.timeShortcut !== 'all'
|| query.value.startDate
|| query.value.endDate,
))
function onUpdateQuery(next: ProjectQuery): void {
projectStore.setQuery(next)
}
function onUpdateKeyword(keyword: string): void {
projectStore.setQuery({ ...query.value, keyword })
}
function onSearch(): void {
void projectStore.search()
}
@@ -25,8 +40,8 @@ function onReset(): void {
void projectStore.resetQuery()
}
function onPageChange(page: number): void {
void projectStore.changePage(page)
function onPageChange(pageNum: number): void {
void projectStore.changePage(pageNum)
}
function onView(id: string): void {
@@ -37,61 +52,101 @@ function onAfterSales(id: string): void {
void router.push(`/project/${id}/after-sales`)
}
// 进页面并发两件事:列表(/list)+ 业务类型筛选条件(/getFilterConditions,幂等只拉一次)
onMounted(() => {
void projectStore.fetchList()
void projectStore.ensureFilterConditions()
void projectStore.load()
})
</script>
<template>
<div class="project">
<header class="project__head">
<h1 class="project__title">
项目中心
</h1>
<p class="project__slogan text-secondary">
查看和管理您的所有项目,实时跟踪项目进度
</p>
</header>
<!-- 标题区(白底通栏,与案例库 hero 同一做法) -->
<div class="project__head">
<div class="container">
<h1 class="project__title">
项目中心
</h1>
<p class="project__slogan">
查看和管理您的所有项目,实时跟踪项目进度
</p>
</div>
</div>
<ProjectFilter
:query="query"
:total="total"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<div class="container">
<div class="project__body">
<ProjectFilter
:query="query"
:total="total"
:type-counts="typeCounts"
@update:query="onUpdateQuery"
@search="onSearch"
@reset="onReset"
/>
<ProjectTable
:projects="list"
@view="onView"
@after-sales="onAfterSales"
/>
<section class="project__main">
<ProjectToolbar
:keyword="query.keyword"
@update:keyword="onUpdateKeyword"
@search="onSearch"
@reset="onReset"
/>
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
<ProjectTable
:projects="list"
:type-counts="typeCounts"
:filtered="filtered"
@view="onView"
@after-sales="onAfterSales"
/>
<ProjectPager
:page="query.pageNum"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</section>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.project {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__head {
padding: 16px 0 32px;
background: @white;
}
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__slogan {
margin-top: @spacing-xs;
margin-top: 14px;
font-size: @font-size-base;
color: @text-color-secondary;
}
// 两栏:左筛选栏固定宽、右列表自适应;align 用 stretch 让两卡等高
&__body {
.flex(row, flex-start, stretch);
gap: 17px;
padding-top: 18px;
}
&__main {
flex: 1;
min-width: 0;
padding: 18px 12px 20px;
border-radius: @radius-base;
background: @white;
}
}
</style>