first1
This commit is contained in:
317
src/views/home/cpns/HomeAppDialog.vue
Normal file
317
src/views/home/cpns/HomeAppDialog.vue
Normal 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>
|
||||
128
src/views/home/cpns/HomeBanner.vue
Normal file
128
src/views/home/cpns/HomeBanner.vue
Normal 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>
|
||||
120
src/views/home/cpns/HomeServiceContents.vue
Normal file
120
src/views/home/cpns/HomeServiceContents.vue
Normal 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>
|
||||
110
src/views/home/cpns/HomeServiceTypes.vue
Normal file
110
src/views/home/cpns/HomeServiceTypes.vue
Normal 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>
|
||||
Reference in New Issue
Block a user