feat 页面页尾

This commit is contained in:
Ironsp
2026-10-01 14:23:58 +08:00
parent b99b2a0a88
commit 0d2389e49a
11 changed files with 166 additions and 35 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -1,5 +1,11 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { computed } from 'vue'
import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
const route = useRoute()
// 需求提报 / 合同中心等特殊页不展示页脚(路由 meta.noFooter 控制)
const showFooter = computed(() => !route.meta.noFooter)
</script>
<template>
@@ -8,7 +14,7 @@ import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
<main class="layout__main">
<router-view />
</main>
<AppFooter />
<AppFooter v-if="showFooter" />
<!-- 全局文件预览弹窗(同源 OSS 预览,绕开桶强制下载) -->
<FilePreviewDialog />
</div>
@@ -16,12 +22,19 @@ import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
<style lang="less" scoped>
// 公共底栏全站常驻(设计稿 43 张中除登录类外均带 481 高页脚)
// sticky footer:.layout 至少占满视口 → 内容少时 footer 贴底不留白;
// 内容多时 .layout 随内容长高,footer 自然落在内容下方(滚动可见)。
// ⚠️ html 全局 zoom 会把内部 100vh 再缩一次,必须除以 @page-zoom(同 .auth-page)。
.layout {
.flex(column, flex-start, stretch);
min-height: calc(100vh / @page-zoom);
@media (max-width: 1286px) {
min-height: calc(100vh / @page-zoom--sm);
}
&__main {
flex: 1;
min-height: calc(100vh - @header-height - @footer-height);
}
}
</style>

View File

@@ -15,13 +15,13 @@ const layoutRoutes: RouteRecordRaw = {
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
// 需求提报依赖登录态:字典 / 业务类型 / 草稿 / 上传 / 提交接口匿名实测均 401
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true } },
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
// ── 登录区 ──
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
{ path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } },
{ path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } },
{ path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true } },
{ path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true, noFooter: true } },
{ path: 'contract/:id', name: 'ContractDetail', component: () => import('@/views/contract/detail.vue'), meta: { title: '合同详情', requiresAuth: true } },
{ path: 'contract/:id/sign', name: 'ContractSign', component: () => import('@/views/contract/sign.vue'), meta: { title: '签署合同', requiresAuth: true } },
{ path: 'finance', name: 'Finance', component: () => import('@/views/finance'), meta: { title: '财务中心', requiresAuth: true } },

View File

@@ -3,6 +3,8 @@ import { computed, ref } from 'vue'
import type { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases'
import type {
Banner,
BannerVo,
BusinessLine,
HomeStats,
ProjectStatusCount,
@@ -14,6 +16,7 @@ import type {
} from '@/types/business/home'
import type { PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
import {
getCarouselList,
getPortalEntryList,
getProjectStatusCount,
getServiceTypeDetail,
@@ -52,6 +55,17 @@ function stripHtml(html: string): string {
.trim()
}
/** 后端轮播 VO → 页面 Banner(仅启用项,按 sort 升序) */
function toBanners(list: BannerVo[]): Banner[] {
return list
.filter((item) => item.enabled !== false && Boolean(item.imageUrl))
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.map((item) => ({
id: item.id ?? 0,
imageUrl: item.imageUrl ?? '',
}))
}
/** 后端「项目提报」→ 业务线卡片 / 业务详情 */
function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
@@ -130,6 +144,7 @@ export const useHomeStore = defineStore('home', () => {
const casesStore = useCasesStore()
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
const banners = ref<Banner[]>([])
const businessLines = ref<BusinessLine[]>([])
const featuredCases = ref<Case[]>([])
const currentBusiness = ref<BusinessLine | null>(null)
@@ -185,10 +200,11 @@ export const useHomeStore = defineStore('home', () => {
/** 首页初始化:看板 + 轮播图 + 业务服务线 + 案例精选 */
async function fetchHomeData(): Promise<void> {
const [statusCount, submissionList, cases] = await Promise.all([
const [statusCount, submissionList, cases, carouselList] = await Promise.all([
getProjectStatusCount(),
getServiceTypeList(),
getFeaturedCasesApi(),
getCarouselList().catch(() => [] as BannerVo[]),
// 案例标签需要「提报 id → 类型名」字典,与首页其余数据并行取(幂等,已加载则不发请求)
casesStore.ensureCategories(),
])
@@ -201,6 +217,7 @@ export const useHomeStore = defineStore('home', () => {
submissions.value = submissionList
businessLines.value = submissionList.map(toBusinessLine)
featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById))
banners.value = toBanners(carouselList ?? [])
// 入口个性化接口失败已被内部吞掉,不会影响上面的首页数据
await fetchPortalEntries()
}
@@ -240,6 +257,7 @@ export const useHomeStore = defineStore('home', () => {
return {
stats,
banners,
businessLines,
featuredCases,
currentBusiness,

View File

@@ -16,6 +16,12 @@ export interface HomeStats {
total: number
}
/** 首页头图轮播(VIEW 层,由 BannerVo 映射) */
export interface Banner {
id: number
imageUrl: string
}
/** 快捷入口(VIEW 层占位,实际入口由门户接口驱动) */
export interface QuickLink {
label: string

View File

@@ -166,12 +166,13 @@ function onClose(): void {
&__panel {
.flex(column, flex-start, stretch);
width: 560px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 64px);
max-width: calc((100vw - 32px) / 1.5);
max-height: calc((100vh - 64px) / 1.5);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
zoom: 1.5;
}
&__head {

View File

@@ -1,10 +1,24 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import bannerIllustration from '@/assets/home/banner-illustration.png'
import type { Banner } from '@/types/business/home'
const props = defineProps<{
banners: Banner[]
}>()
// 设计稿顶部为静态头图(非轮播、不走接口):底 #F3F4F8 渐变,
// 左侧标题 60px + 副标题 24px + 双按钮 183×55,右侧插画 723×598(蓝湖设计稿切图)
const router = useRouter()
const activeIndex = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
/** 接口无图时用设计稿插画兜底,保证头图始终有一层视觉 */
const slides = computed(() => {
if (props.banners.length > 0) {
return props.banners.map((item) => item.imageUrl)
}
return [bannerIllustration]
})
function goDemand(): void {
router.push('/demand')
@@ -13,10 +27,50 @@ function goDemand(): void {
function goCases(): void {
router.push('/cases')
}
function goTo(index: number): void {
activeIndex.value = index
}
function startAutoplay(): void {
stopAutoplay()
if (slides.value.length <= 1) return
timer = setInterval(() => {
activeIndex.value = (activeIndex.value + 1) % slides.value.length
}, 5000)
}
function stopAutoplay(): void {
if (timer) {
clearInterval(timer)
timer = null
}
}
watch(slides, () => {
activeIndex.value = 0
startAutoplay()
})
onMounted(startAutoplay)
onBeforeUnmount(stopAutoplay)
</script>
<template>
<section class="home-banner">
<!-- 底层:图片轮播(文案层始终叠在其上) -->
<div class="home-banner__carousel" aria-hidden="true">
<div
v-for="(src, index) in slides"
:key="`${src}-${index}`"
class="home-banner__slide"
:class="{ 'home-banner__slide--active': index === activeIndex }"
>
<img class="home-banner__image" :src="src" alt="">
</div>
</div>
<!-- 顶层:标题 / 副标题 / 按钮,轮播切换时位置不变 -->
<div class="home-banner__inner">
<h1 class="home-banner__title">虫子智造研发一体化平台</h1>
<p class="home-banner__subtitle">一站式智能软硬件研发与量产综合服务商</p>
@@ -29,36 +83,66 @@ function goCases(): void {
案例中心
</button>
</div>
<div class="home-banner__visual">
<img
class="home-banner__illustration"
:src="bannerIllustration"
alt="虫子智造研发一体化平台"
>
</div>
<div v-if="slides.length > 1" class="home-banner__dots" role="tablist" aria-label="头图轮播">
<button
v-for="(_, index) in slides"
:key="index"
class="home-banner__dot"
:class="{ 'home-banner__dot--active': index === activeIndex }"
type="button"
role="tab"
:aria-selected="index === activeIndex"
@click="goTo(index)"
/>
</div>
</section>
</template>
<style lang="less" scoped>
.home-banner {
// 设计稿头图通栏 1920×661(画板 y73,位于顶栏 72 之下)
position: relative;
height: 661px;
overflow: hidden;
background: @home-banner-bg;
// .home 是 column flex 容器,需显式占满整行,否则被内容宽度收缩
width: 100%;
flex: 0 0 auto;
// 内容区按设计稿 1920 画布居中;两侧内容一律相对它定位,保证 1:1
// 底层轮播:通栏铺满,文案层叠在上面
&__carousel {
position: absolute;
inset: 0;
z-index: 0;
}
&__slide {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.6s ease;
&--active {
opacity: 1;
}
}
&__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
// 顶层文案:相对内容区定位,不随轮播切换
&__inner {
position: relative;
z-index: 1;
width: 100%;
max-width: @container-width;
height: 100%;
margin: 0 auto;
pointer-events: none;
}
// 设计稿标题 x150 y257(画板绝对)→ banner 内 y257−73=184
@@ -87,13 +171,14 @@ function goCases(): void {
white-space: nowrap;
}
// 设计稿按钮组 x150 y465 → banner 内 y392;两钮 183×55,间距 20(150+183=333 → 353)
// 设计稿按钮组 x150 y465 → banner 内 y392;两钮 183×55,间距 20
&__actions {
position: absolute;
top: 392px;
left: 150px;
.flex(row, flex-start, center);
gap: 20px;
pointer-events: auto;
}
&__btn {
@@ -111,7 +196,6 @@ function goCases(): void {
color: @white;
}
// 设计稿:2px 渐变描边 + 白底 + 蓝色文字(实测 #2B5FF0)
&--ghost {
position: relative;
background: @white;
@@ -138,20 +222,29 @@ function goCases(): void {
}
}
// 设计稿插画 723×598 @ canvas (928,104) → banner 内 y104−73=31
&__visual {
&__dots {
position: absolute;
top: 31px;
left: 928px;
width: 723px;
height: 598px;
right: 80px;
bottom: 40px;
z-index: 1;
.flex(row, flex-end, center);
gap: 8px;
}
&__illustration {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
&__dot {
width: 8px;
height: 8px;
padding: 0;
border: none;
border-radius: 50%;
background: fade(@home-banner-dot, 40%);
cursor: pointer;
&--active {
width: 24px;
border-radius: 4px;
background: @home-banner-dot;
}
}
}
</style>

View File

@@ -12,7 +12,7 @@ import HomeBusinessCards from './cpns/HomeBusinessCards.vue'
import HomeCaseShowcase from './cpns/HomeCaseShowcase.vue'
const homeStore = useHomeStore()
const { stats, businessLines, featuredCases } = storeToRefs(homeStore)
const { banners, stats, businessLines, featuredCases } = storeToRefs(homeStore)
const todaySummary = computed(() => `今天有${stats.value.inProgress}个项目正在进行`)
@@ -27,8 +27,8 @@ onMounted(() => {
<template>
<div class="home">
<!-- 设计稿头图为通栏(0..1920),静态设计图,故不包 .container -->
<HomeBanner />
<!-- 设计稿头图为通栏(0..1920),文案层固定叠在轮播图之上 -->
<HomeBanner :banners="banners" />
<!-- 首页设计稿单独基准:内容区 1680 / 留白 120,故用 container--home -->
<div class="container container--home home__dashboard">