This commit is contained in:
wangzzpeng
2026-09-28 09:27:51 +08:00
parent 7bc84bbe5e
commit 6eb2bfcbfd
25 changed files with 2887 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useHomeStore } from '@/stores/modules/home'
import type { PortalEntryCard, PortalEntryGroup } from '@/types/business/home'
import { toPortalEntryCards } from '@/utils/entryIcon'
/**
* 「更多应用」弹窗:快捷入口 / 其他入口 两组,图标角标可把入口在两组之间互转。
*
* 数据来自 store.quickEntries / store.otherEntries(← /vermins/portalEntryUser/list)。
* 接口不可用(游客、无权限)时回退到内置默认入口,保证弹窗不空。
*/
const visible = defineModel<boolean>('visible', { default: false })
const homeStore = useHomeStore()
const router = useRouter()
/** 接口不可用时的默认快捷入口(与首页卡片一致的固定模块) */
const FALLBACK_QUICK: Array<Pick<PortalEntryCard, 'label' | 'route' | 'icon' | 'tone'>> = [
{ label: '财务台账', route: '/finance/bills', icon: '💰', tone: 'amber' },
{ label: '合同台账', route: '/contract', icon: '📄', tone: 'blue' },
{ label: '客服工作台', route: '/service', icon: '💬', tone: 'green' },
{ label: '案例库管理', route: '/cases', icon: '📁', tone: 'purple' },
{ label: '工单需求池', route: '/demand', icon: '📋', tone: 'cyan' },
]
/** 前端已注册的静态路由集合(用于判断入口 route 是否可跳转) */
const knownPaths = computed(() => new Set(router.getRoutes().map((item) => item.path)))
/** 快捷入口组 */
const quickCards = computed<PortalEntryCard[]>(() => {
const list = toPortalEntryCards(homeStore.quickEntries)
if (list.length > 0) return list
return FALLBACK_QUICK.map((item) => ({
...item,
id: 0,
groupType: '1' as PortalEntryGroup,
isImage: false,
}))
})
/** 其他入口组 */
const otherCards = computed<PortalEntryCard[]>(() => toPortalEntryCards(homeStore.otherEntries))
/** 入口是否可点击跳转(route 为空或未注册时不跳,避免落到 404) */
function canOpen(card: PortalEntryCard): boolean {
return card.route.length > 0 && knownPaths.value.has(card.route)
}
/** 是否是后端下发的入口(默认兜底入口 id 为 0,不可切换分组) */
function switchable(card: PortalEntryCard): boolean {
return card.id > 0
}
/** 点击图标:跳转对应路由 */
function onOpen(card: PortalEntryCard): void {
if (!canOpen(card)) return
visible.value = false
void router.push(card.route)
}
/** 点击角标:快捷 ⇄ 其他 互转 */
async function onToggle(card: PortalEntryCard): Promise<void> {
if (!switchable(card)) return
const target: PortalEntryGroup = card.groupType === '1' ? '0' : '1'
try {
await homeStore.switchEntryGroup(card.id, target)
} catch {
// 失败提示已由请求层统一弹出,这里只保证不产生未捕获的 rejection
}
}
function onClose(): void {
visible.value = false
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="home-app-dialog" @click.self="onClose">
<div class="home-app-dialog__panel">
<div class="home-app-dialog__body">
<section class="home-app-dialog__group">
<h4 class="home-app-dialog__group-title">
快捷入口
</h4>
<div class="home-app-dialog__grid">
<div
v-for="card in quickCards"
:key="`quick-${card.label}-${card.id}`"
class="home-app-dialog__item"
:class="{ 'is-link': canOpen(card) }"
>
<button
class="home-app-dialog__icon"
:class="`home-app-dialog__icon--${card.tone}`"
type="button"
@click="onOpen(card)"
>
<img v-if="card.isImage" :src="card.icon" :alt="card.label" class="home-app-dialog__img">
<span v-else>{{ card.icon }}</span>
</button>
<button
v-if="switchable(card)"
class="home-app-dialog__badge home-app-dialog__badge--remove"
type="button"
:aria-label="`将${card.label}移出快捷入口`"
@click.stop="onToggle(card)"
>
−
</button>
<span class="home-app-dialog__label">{{ card.label }}</span>
</div>
</div>
</section>
<section v-if="otherCards.length > 0" class="home-app-dialog__group">
<h4 class="home-app-dialog__group-title">
其他入口
</h4>
<div class="home-app-dialog__grid">
<div
v-for="card in otherCards"
:key="`other-${card.label}-${card.id}`"
class="home-app-dialog__item"
:class="{ 'is-link': canOpen(card) }"
>
<button
class="home-app-dialog__icon"
:class="`home-app-dialog__icon--${card.tone}`"
type="button"
@click="onOpen(card)"
>
<img v-if="card.isImage" :src="card.icon" :alt="card.label" class="home-app-dialog__img">
<span v-else>{{ card.icon }}</span>
</button>
<button
class="home-app-dialog__badge home-app-dialog__badge--add"
type="button"
:aria-label="`将${card.label}加入快捷入口`"
@click.stop="onToggle(card)"
>
+
</button>
<span class="home-app-dialog__label">{{ card.label }}</span>
</div>
</div>
</section>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.home-app-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
padding: @spacing-base;
background: rgba(0, 0, 0, 0.45);
&__panel {
.flex(column, flex-start, stretch);
width: 560px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 64px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__head {
.flex(row, space-between, center);
margin-bottom: @spacing-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__close {
.flex(row, center);
width: 24px;
height: 24px;
border: 0;
border-radius: @radius-sm;
background: transparent;
color: @text-color-secondary;
font-size: @font-size-lg;
line-height: 1;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: @bg-color;
color: @heading-color;
}
}
&__body {
overflow-y: auto;
}
&__group {
& + & {
margin-top: @spacing-lg;
}
}
&__group-title {
margin-bottom: @spacing-base;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: @spacing-base @spacing-sm;
}
&__item {
position: relative;
.flex(column, flex-start, center);
gap: @spacing-sm;
}
&__icon {
.flex(row, center);
width: 44px;
height: 44px;
border: 0;
border-radius: @radius-base;
font-size: @font-size-xl;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: @shadow-base;
}
&--purple {
background: #f0eaff;
}
&--green {
background: #e7f8ec;
}
&--blue {
background: @primary-light;
}
&--amber {
background: #fff3e0;
}
&--red {
background: #ffeceb;
}
&--cyan {
background: #e4f6fa;
}
}
&__img {
width: 24px;
height: 24px;
object-fit: contain;
}
&__badge {
position: absolute;
top: -6px;
right: 50%;
margin-right: -28px;
.flex(row, center);
width: 16px;
height: 16px;
border: 1px solid @border-color;
border-radius: @radius-round;
background: @white;
color: @text-color-secondary;
font-size: @font-size-sm;
line-height: 1;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&--remove:hover {
border-color: @danger-color;
color: @danger-color;
}
}
&__label {
font-size: @font-size-sm;
color: @text-color;
text-align: center;
line-height: 1.4;
}
}
</style>

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { BannerVo } from '@/types/business/home'
interface Props {
banners: BannerVo[]
}
const props = defineProps<Props>()
/** 仅保留启用且带图的轮播项,按后端 sort 升序 */
const slides = computed(() =>
props.banners
.filter((item) => item.enabled !== false && !!item.imageUrl)
.slice()
.sort((prev, next) => (prev.sort ?? 0) - (next.sort ?? 0)),
)
const current = ref(0)
let timer: number | undefined
function stop(): void {
if (timer !== undefined) {
window.clearInterval(timer)
timer = undefined
}
}
function go(index: number): void {
const total = slides.value.length
if (total === 0) return
current.value = (index + total) % total
}
function start(): void {
stop()
if (slides.value.length <= 1) return
timer = window.setInterval(() => go(current.value + 1), 5000)
}
watch(
() => slides.value.length,
() => {
current.value = 0
start()
},
)
onMounted(start)
onBeforeUnmount(stop)
</script>
<template>
<section v-if="slides.length" class="home-banner">
<img
v-for="(slide, index) in slides"
v-show="index === current"
:key="slide.id ?? index"
class="home-banner__img"
:src="slide.imageUrl ?? ''"
:alt="`轮播图 ${index + 1}`"
>
<template v-if="slides.length > 1">
<button
class="home-banner__arrow home-banner__arrow--prev"
type="button"
aria-label="上一张"
@click="go(current - 1)"
>
‹
</button>
<button
class="home-banner__arrow home-banner__arrow--next"
type="button"
aria-label="下一张"
@click="go(current + 1)"
>
›
</button>
</template>
</section>
</template>
<style lang="less" scoped>
.home-banner {
position: relative;
height: 240px;
overflow: hidden;
border-radius: @radius-lg;
background: @bg-color;
box-shadow: @shadow-base;
&__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
&__arrow {
position: absolute;
top: 50%;
width: 32px;
height: 32px;
transform: translateY(-50%);
border-radius: @radius-round;
background: rgba(31, 39, 51, 0.35);
color: @white;
font-size: @font-size-xl;
line-height: 1;
opacity: 0;
transition: opacity 0.2s;
&--prev {
left: @spacing-base;
}
&--next {
right: @spacing-base;
}
}
&:hover &__arrow {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { ServiceContentBlock } from '@/types/business/home'
interface Props {
/** 服务内容区块列表:后端每条富文本目录一项 */
contents: ServiceContentBlock[]
}
defineProps<Props>()
</script>
<template>
<!-- 设计稿:一个「服务内容」区块 = 一条富文本目录(块内含媒体与要点列表,可多块) -->
<div v-if="contents.length" class="home-contents">
<section
v-for="(block, index) in contents"
:key="`${block.title}-${index}`"
class="home-contents__block"
>
<h3 class="home-contents__title">
服务内容
</h3>
<!-- 后端富文本原文(含内嵌图片/视频),取自后台维护内容 -->
<div class="home-contents__rich" v-html="block.content" />
</section>
</div>
</template>
<style lang="less" scoped>
.home-contents {
&__block {
margin-bottom: @spacing-lg;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&:last-child {
margin-bottom: 0;
}
}
&__title {
position: relative;
margin-bottom: @spacing-base;
padding-left: @spacing-sm;
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 16px;
border-radius: @radius-round;
background: @primary-color;
transform: translateY(-50%);
}
}
&__rich {
font-size: @font-size-sm;
line-height: 1.9;
color: @text-color;
word-break: break-word;
:deep(p) {
margin: 0 0 @spacing-sm;
}
:deep(ol),
:deep(ul) {
margin: 0 0 @spacing-sm;
padding-left: 1.4em;
}
:deep(li) {
margin-bottom: 2px;
}
:deep(img),
:deep(video) {
display: block;
max-width: 100%;
margin: 0 auto @spacing-base;
border-radius: @radius-base;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4) {
margin: @spacing-base 0 @spacing-sm;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin-bottom: @spacing-sm;
}
:deep(td),
:deep(th) {
padding: @spacing-xs @spacing-sm;
border: 1px solid @border-color;
font-size: @font-size-sm;
}
:deep(a) {
color: @primary-color;
}
}
}
</style>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { ServiceType } from '@/types/business/home'
interface Props {
serviceTypes: ServiceType[]
}
defineProps<Props>()
/** 后端 icon 可能是图片地址;不是地址(或为空)时用名称首字兜底展示 */
function isImageIcon(icon?: string): boolean {
if (!icon) return false
return /^(https?:)?\/\//.test(icon) || icon.startsWith('/')
}
</script>
<template>
<section v-if="serviceTypes.length" class="home-service-types">
<h3 class="home-service-types__title">
服务类型
</h3>
<ul class="home-service-types__list">
<li
v-for="(type, index) in serviceTypes"
:key="`${type.label}-${index}`"
class="home-service-types__item"
>
<span class="home-service-types__icon">
<img
v-if="isImageIcon(type.icon)"
:src="type.icon"
:alt="type.label"
class="home-service-types__img"
>
<template v-else>
{{ type.label.slice(0, 1) }}
</template>
</span>
<span class="home-service-types__label">{{ type.label }}</span>
</li>
</ul>
</section>
</template>
<style lang="less" scoped>
.home-service-types {
margin-bottom: @spacing-lg;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
position: relative;
margin-bottom: @spacing-lg;
padding-left: @spacing-sm;
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 16px;
border-radius: @radius-round;
background: @primary-color;
transform: translateY(-50%);
}
}
&__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: @spacing-lg @spacing-base;
}
&__item {
.flex(column, center);
gap: @spacing-sm;
}
&__icon {
.flex(row, center);
width: 56px;
height: 56px;
border-radius: @radius-base;
background: @primary-light;
color: @primary-color;
font-size: @font-size-lg;
font-weight: 600;
overflow: hidden;
}
&__img {
width: 100%;
height: 100%;
object-fit: contain;
}
&__label {
font-size: @font-size-sm;
color: @text-color;
text-align: center;
.ellipsis();
}
}
</style>