feat 页面页尾
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB |
BIN
shot-login.png
BIN
shot-login.png
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 |
@@ -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>
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
src/types/business/home.d.ts
vendored
6
src/types/business/home.d.ts
vendored
@@ -16,6 +16,12 @@ export interface HomeStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 首页头图轮播(VIEW 层,由 BannerVo 映射) */
|
||||
export interface Banner {
|
||||
id: number
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
/** 快捷入口(VIEW 层占位,实际入口由门户接口驱动) */
|
||||
export interface QuickLink {
|
||||
label: string
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user