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

View File

@@ -15,13 +15,13 @@ const layoutRoutes: RouteRecordRaw = {
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } }, { path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } }, { path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
// 需求提报依赖登录态:字典 / 业务类型 / 草稿 / 上传 / 提交接口匿名实测均 401 // 需求提报依赖登录态:字典 / 业务类型 / 草稿 / 上传 / 提交接口匿名实测均 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', 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', 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: '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', 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: '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 } }, { 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 { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases' import type { Case } from '@/types/business/cases'
import type { import type {
Banner,
BannerVo,
BusinessLine, BusinessLine,
HomeStats, HomeStats,
ProjectStatusCount, ProjectStatusCount,
@@ -14,6 +16,7 @@ import type {
} from '@/types/business/home' } from '@/types/business/home'
import type { PortalEntryGroup, PortalEntryVo } from '@/types/business/home' import type { PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
import { import {
getCarouselList,
getPortalEntryList, getPortalEntryList,
getProjectStatusCount, getProjectStatusCount,
getServiceTypeDetail, getServiceTypeDetail,
@@ -52,6 +55,17 @@ function stripHtml(html: string): string {
.trim() .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 { function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({ const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
@@ -130,6 +144,7 @@ export const useHomeStore = defineStore('home', () => {
const casesStore = useCasesStore() const casesStore = useCasesStore()
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 }) const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
const banners = ref<Banner[]>([])
const businessLines = ref<BusinessLine[]>([]) const businessLines = ref<BusinessLine[]>([])
const featuredCases = ref<Case[]>([]) const featuredCases = ref<Case[]>([])
const currentBusiness = ref<BusinessLine | null>(null) const currentBusiness = ref<BusinessLine | null>(null)
@@ -185,10 +200,11 @@ export const useHomeStore = defineStore('home', () => {
/** 首页初始化:看板 + 轮播图 + 业务服务线 + 案例精选 */ /** 首页初始化:看板 + 轮播图 + 业务服务线 + 案例精选 */
async function fetchHomeData(): Promise<void> { async function fetchHomeData(): Promise<void> {
const [statusCount, submissionList, cases] = await Promise.all([ const [statusCount, submissionList, cases, carouselList] = await Promise.all([
getProjectStatusCount(), getProjectStatusCount(),
getServiceTypeList(), getServiceTypeList(),
getFeaturedCasesApi(), getFeaturedCasesApi(),
getCarouselList().catch(() => [] as BannerVo[]),
// 案例标签需要「提报 id → 类型名」字典,与首页其余数据并行取(幂等,已加载则不发请求) // 案例标签需要「提报 id → 类型名」字典,与首页其余数据并行取(幂等,已加载则不发请求)
casesStore.ensureCategories(), casesStore.ensureCategories(),
]) ])
@@ -201,6 +217,7 @@ export const useHomeStore = defineStore('home', () => {
submissions.value = submissionList submissions.value = submissionList
businessLines.value = submissionList.map(toBusinessLine) businessLines.value = submissionList.map(toBusinessLine)
featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById)) featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById))
banners.value = toBanners(carouselList ?? [])
// 入口个性化接口失败已被内部吞掉,不会影响上面的首页数据 // 入口个性化接口失败已被内部吞掉,不会影响上面的首页数据
await fetchPortalEntries() await fetchPortalEntries()
} }
@@ -240,6 +257,7 @@ export const useHomeStore = defineStore('home', () => {
return { return {
stats, stats,
banners,
businessLines, businessLines,
featuredCases, featuredCases,
currentBusiness, currentBusiness,

View File

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

View File

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

View File

@@ -1,10 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import bannerIllustration from '@/assets/home/banner-illustration.png' 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 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 { function goDemand(): void {
router.push('/demand') router.push('/demand')
@@ -13,10 +27,50 @@ function goDemand(): void {
function goCases(): void { function goCases(): void {
router.push('/cases') 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> </script>
<template> <template>
<section class="home-banner"> <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"> <div class="home-banner__inner">
<h1 class="home-banner__title">虫子智造研发一体化平台</h1> <h1 class="home-banner__title">虫子智造研发一体化平台</h1>
<p class="home-banner__subtitle">一站式智能软硬件研发与量产综合服务商</p> <p class="home-banner__subtitle">一站式智能软硬件研发与量产综合服务商</p>
@@ -29,36 +83,66 @@ function goCases(): void {
案例中心 案例中心
</button> </button>
</div> </div>
</div>
<div class="home-banner__visual"> <div v-if="slides.length > 1" class="home-banner__dots" role="tablist" aria-label="头图轮播">
<img <button
class="home-banner__illustration" v-for="(_, index) in slides"
:src="bannerIllustration" :key="index"
alt="虫子智造研发一体化平台" class="home-banner__dot"
> :class="{ 'home-banner__dot--active': index === activeIndex }"
</div> type="button"
role="tab"
:aria-selected="index === activeIndex"
@click="goTo(index)"
/>
</div> </div>
</section> </section>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.home-banner { .home-banner {
// 设计稿头图通栏 1920×661(画板 y73,位于顶栏 72 之下)
position: relative; position: relative;
height: 661px; height: 661px;
overflow: hidden; overflow: hidden;
background: @home-banner-bg; background: @home-banner-bg;
// .home 是 column flex 容器,需显式占满整行,否则被内容宽度收缩
width: 100%; width: 100%;
flex: 0 0 auto; 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 { &__inner {
position: relative; position: relative;
z-index: 1;
width: 100%; width: 100%;
max-width: @container-width; max-width: @container-width;
height: 100%; height: 100%;
margin: 0 auto; margin: 0 auto;
pointer-events: none;
} }
// 设计稿标题 x150 y257(画板绝对)→ banner 内 y257−73=184 // 设计稿标题 x150 y257(画板绝对)→ banner 内 y257−73=184
@@ -87,13 +171,14 @@ function goCases(): void {
white-space: nowrap; white-space: nowrap;
} }
// 设计稿按钮组 x150 y465 → banner 内 y392;两钮 183×55,间距 20(150+183=333 → 353) // 设计稿按钮组 x150 y465 → banner 内 y392;两钮 183×55,间距 20
&__actions { &__actions {
position: absolute; position: absolute;
top: 392px; top: 392px;
left: 150px; left: 150px;
.flex(row, flex-start, center); .flex(row, flex-start, center);
gap: 20px; gap: 20px;
pointer-events: auto;
} }
&__btn { &__btn {
@@ -111,7 +196,6 @@ function goCases(): void {
color: @white; color: @white;
} }
// 设计稿:2px 渐变描边 + 白底 + 蓝色文字(实测 #2B5FF0)
&--ghost { &--ghost {
position: relative; position: relative;
background: @white; background: @white;
@@ -138,20 +222,29 @@ function goCases(): void {
} }
} }
// 设计稿插画 723×598 @ canvas (928,104) → banner 内 y104−73=31 &__dots {
&__visual {
position: absolute; position: absolute;
top: 31px; right: 80px;
left: 928px; bottom: 40px;
width: 723px; z-index: 1;
height: 598px; .flex(row, flex-end, center);
gap: 8px;
} }
&__illustration { &__dot {
display: block; width: 8px;
width: 100%; height: 8px;
height: 100%; padding: 0;
object-fit: contain; border: none;
border-radius: 50%;
background: fade(@home-banner-dot, 40%);
cursor: pointer;
&--active {
width: 24px;
border-radius: 4px;
background: @home-banner-dot;
}
} }
} }
</style> </style>

View File

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