forked from zhouruizhe/gmTouringMiniApp
Initial commit: gmTouringMiniApp project
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiCategory } from '@/domain/poi'
|
||||
|
||||
defineProps<{
|
||||
categories: PoiCategory[]
|
||||
activeCategoryCode: string | null
|
||||
resultCount: number
|
||||
coverageLabel: string
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [categoryCode: string | null]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="map-filter-header">
|
||||
<view class="map-filter-header__heading">
|
||||
<view class="map-filter-header__title-block">
|
||||
<text class="map-filter-header__title">光明区文旅资源</text>
|
||||
<text class="map-filter-header__scope">{{ coverageLabel }}</text>
|
||||
</view>
|
||||
<text class="map-filter-header__count">共 {{ resultCount }} 个点位</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="map-filter-header__scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="map-filter-header__chips">
|
||||
<button
|
||||
class="category-chip"
|
||||
:class="{ 'category-chip--active': activeCategoryCode === null }"
|
||||
:disabled="loading"
|
||||
hover-class="category-chip--hover"
|
||||
@click="emit('select', null)"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.code"
|
||||
class="category-chip"
|
||||
:class="{ 'category-chip--active': activeCategoryCode === category.code }"
|
||||
:disabled="loading"
|
||||
hover-class="category-chip--hover"
|
||||
@click="emit('select', category.code)"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.map-filter-header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: none;
|
||||
padding: 20rpx 0 16rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 8rpx 28rpx rgb(24 32 29 / 8%);
|
||||
}
|
||||
|
||||
.map-filter-header__heading {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 64rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.map-filter-header__title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-filter-header__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
line-height: 46rpx;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.map-filter-header__scope,
|
||||
.map-filter-header__count {
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
color: #5e6b65;
|
||||
}
|
||||
|
||||
.map-filter-header__count {
|
||||
flex: none;
|
||||
font-weight: 600;
|
||||
color: #167a5b;
|
||||
}
|
||||
|
||||
.map-filter-header__scroll {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
margin-top: 8rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-filter-header__chips {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
align-items: center;
|
||||
height: 80rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.category-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 104rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 28rpx;
|
||||
margin: 0;
|
||||
font-size: 26rpx;
|
||||
line-height: 64rpx;
|
||||
color: #425049;
|
||||
white-space: nowrap;
|
||||
background: #f0f4f2;
|
||||
border: 0;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.category-chip::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.category-chip--active {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
}
|
||||
|
||||
.category-chip--hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.category-chip[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiSummary } from '@/domain/poi'
|
||||
import PoiImage from '@/components/poi/PoiImage.vue'
|
||||
import PoiTagList from '@/components/poi/PoiTagList.vue'
|
||||
import { formatRecommendationLabel } from '@/domain/poi'
|
||||
|
||||
defineProps<{
|
||||
poi: PoiSummary
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
detail: [poiId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="poi-summary">
|
||||
<PoiImage
|
||||
class="poi-summary__image"
|
||||
:src="poi.coverImage.url"
|
||||
:alt="poi.coverImage.alt"
|
||||
width="192rpx"
|
||||
height="192rpx"
|
||||
radius="16rpx"
|
||||
/>
|
||||
|
||||
<view class="poi-summary__content">
|
||||
<view class="poi-summary__heading">
|
||||
<view class="poi-summary__heading-copy">
|
||||
<text class="poi-summary__name">{{ poi.name }}</text>
|
||||
<text class="poi-summary__category">{{ poi.categoryName }}</text>
|
||||
</view>
|
||||
<button class="poi-summary__close" aria-label="关闭点位摘要" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<text class="poi-summary__description">{{ poi.summary }}</text>
|
||||
<view class="poi-summary__recommendation">
|
||||
{{ formatRecommendationLabel(poi.recommendationSource, poi.recommendationIndex) }}
|
||||
</view>
|
||||
<PoiTagList :tags="poi.tags" :limit="2" compact />
|
||||
|
||||
<button class="poi-summary__detail" hover-class="poi-summary__detail--hover" @click="emit('detail', poi.id)">
|
||||
查看详情
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poi-summary {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
min-height: 264rpx;
|
||||
padding: 24rpx;
|
||||
margin: 24rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #dde5e1;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgb(24 32 29 / 10%);
|
||||
}
|
||||
|
||||
.poi-summary__image {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.poi-summary__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.poi-summary__heading {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.poi-summary__heading-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.poi-summary__name {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 44rpx;
|
||||
color: #18201d;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.poi-summary__category {
|
||||
margin-top: 2rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
color: #167a5b;
|
||||
}
|
||||
|
||||
.poi-summary__close {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
padding: 0;
|
||||
margin: -18rpx -18rpx 0 0;
|
||||
font-size: 44rpx;
|
||||
line-height: 72rpx;
|
||||
color: #8a9690;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poi-summary__close::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poi-summary__description {
|
||||
display: -webkit-box;
|
||||
margin: 8rpx 0;
|
||||
overflow: hidden;
|
||||
font-size: 26rpx;
|
||||
line-height: 38rpx;
|
||||
color: #5e6b65;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.poi-summary__recommendation {
|
||||
align-self: flex-start;
|
||||
padding: 5rpx 12rpx;
|
||||
margin-bottom: 10rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
line-height: 32rpx;
|
||||
color: #934b00;
|
||||
background: #fff3e0;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.poi-summary__detail {
|
||||
width: 100%;
|
||||
height: 64rpx;
|
||||
padding: 0 24rpx;
|
||||
margin: auto 0 0;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
line-height: 64rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border: 0;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.poi-summary__detail::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poi-summary__detail--hover {
|
||||
background: #0e6247;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as MapFilterHeader } from './MapFilterHeader.vue'
|
||||
export { default as PoiSummaryCard } from './PoiSummaryCard.vue'
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
type PageStateType = 'loading' | 'empty' | 'error' | 'unavailable'
|
||||
|
||||
interface Props {
|
||||
state: PageStateType
|
||||
title: string
|
||||
description?: string
|
||||
actionLabel?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
actionLabel: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="page-state"
|
||||
:class="`page-state--${state}`"
|
||||
:aria-live="state === 'loading' ? 'polite' : 'assertive'"
|
||||
>
|
||||
<view class="page-state__visual" aria-hidden="true">
|
||||
<view v-if="state === 'loading'" class="page-state__spinner" />
|
||||
<text v-else-if="state === 'error'" class="page-state__symbol">!</text>
|
||||
<text v-else-if="state === 'unavailable'" class="page-state__symbol">i</text>
|
||||
<view v-else class="page-state__empty-icon">
|
||||
<view class="page-state__empty-line" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="page-state__title">{{ title }}</text>
|
||||
<text v-if="description" class="page-state__description">{{ description }}</text>
|
||||
|
||||
<button
|
||||
v-if="actionLabel && state !== 'loading'"
|
||||
class="page-state__action"
|
||||
hover-class="page-state__action--hover"
|
||||
@click="emit('action')"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-state {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 480rpx;
|
||||
padding: 64rpx 48rpx;
|
||||
color: #18201d;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-state__visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
margin-bottom: 28rpx;
|
||||
color: #167a5b;
|
||||
background: #e8f4ee;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.page-state__spinner {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border: 6rpx solid #bcd8cc;
|
||||
border-top-color: #167a5b;
|
||||
border-radius: 50%;
|
||||
animation: page-state-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.page-state__symbol {
|
||||
font-size: 50rpx;
|
||||
font-weight: 700;
|
||||
line-height: 72rpx;
|
||||
}
|
||||
|
||||
.page-state__empty-icon {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 54rpx;
|
||||
height: 42rpx;
|
||||
border: 4rpx solid currentcolor;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.page-state__empty-line {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
left: 11rpx;
|
||||
width: 24rpx;
|
||||
height: 4rpx;
|
||||
background: currentcolor;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.page-state--error .page-state__visual {
|
||||
color: #b84235;
|
||||
background: #fff0ee;
|
||||
}
|
||||
|
||||
.page-state--unavailable .page-state__visual {
|
||||
color: #68756f;
|
||||
background: #f0f2f1;
|
||||
}
|
||||
|
||||
.page-state__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 46rpx;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.page-state__description {
|
||||
max-width: 560rpx;
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
color: #66736d;
|
||||
}
|
||||
|
||||
.page-state__action {
|
||||
width: auto;
|
||||
min-width: 216rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 36rpx;
|
||||
margin: 32rpx 0 0;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
line-height: 72rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border: 0;
|
||||
border-radius: 36rpx;
|
||||
}
|
||||
|
||||
.page-state__action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.page-state__action--hover {
|
||||
background: #0e6247;
|
||||
}
|
||||
|
||||
@keyframes page-state-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiImageAsset } from '@/domain/poi'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import PoiImage from './PoiImage.vue'
|
||||
|
||||
interface Props {
|
||||
images: ReadonlyArray<PoiImageAsset>
|
||||
name: string
|
||||
initialIndex?: number
|
||||
height?: string | number
|
||||
fallbackSrc?: string
|
||||
showIndicator?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initialIndex: 0,
|
||||
height: '422rpx',
|
||||
fallbackSrc: '/static/poi/placeholder.png',
|
||||
showIndicator: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [index: number]
|
||||
imageError: [image: PoiImageAsset, index: number, event: unknown]
|
||||
}>()
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const galleryHeight = computed(() => (
|
||||
typeof props.height === 'number' ? `${props.height}rpx` : props.height
|
||||
))
|
||||
const hasImages = computed(() => props.images.length > 0)
|
||||
|
||||
function clampIndex(index: number): number {
|
||||
if (!hasImages.value)
|
||||
return 0
|
||||
|
||||
return Math.min(Math.max(0, Math.floor(index)), props.images.length - 1)
|
||||
}
|
||||
|
||||
function handleChange(event: { detail: { current: number } }): void {
|
||||
currentIndex.value = clampIndex(Number(event.detail.current))
|
||||
emit('change', currentIndex.value)
|
||||
}
|
||||
|
||||
function handleImageError(image: PoiImageAsset, index: number, event: unknown): void {
|
||||
emit('imageError', image, index, event)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.initialIndex, props.images.length],
|
||||
() => {
|
||||
currentIndex.value = clampIndex(props.initialIndex)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="poi-hero-gallery" :style="{ height: galleryHeight }">
|
||||
<swiper
|
||||
v-if="hasImages"
|
||||
class="poi-hero-gallery__swiper"
|
||||
:current="currentIndex"
|
||||
:circular="images.length > 1"
|
||||
:duration="300"
|
||||
@change="handleChange"
|
||||
>
|
||||
<swiper-item v-for="(image, index) in images" :key="image.id">
|
||||
<PoiImage
|
||||
:src="image.url"
|
||||
:alt="image.alt || `${name}图片${index + 1}`"
|
||||
:fallback-src="fallbackSrc"
|
||||
width="100%"
|
||||
height="100%"
|
||||
@error="handleImageError(image, index, $event)"
|
||||
/>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<PoiImage
|
||||
v-else
|
||||
src=""
|
||||
:alt="`${name}图片暂不可用`"
|
||||
:fallback-src="fallbackSrc"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-if="showIndicator && images.length > 1"
|
||||
class="poi-hero-gallery__indicator"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ currentIndex + 1 }} / {{ images.length }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poi-hero-gallery {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #e8eeeb;
|
||||
}
|
||||
|
||||
.poi-hero-gallery__swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.poi-hero-gallery__indicator {
|
||||
position: absolute;
|
||||
right: 28rpx;
|
||||
bottom: 24rpx;
|
||||
box-sizing: border-box;
|
||||
min-width: 80rpx;
|
||||
padding: 8rpx 18rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
line-height: 32rpx;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: rgb(0 0 0 / 58%);
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
type ImageMode =
|
||||
| 'scaleToFill'
|
||||
| 'aspectFit'
|
||||
| 'aspectFill'
|
||||
| 'widthFix'
|
||||
| 'heightFix'
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'center'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top left'
|
||||
| 'top right'
|
||||
| 'bottom left'
|
||||
| 'bottom right'
|
||||
|
||||
interface Props {
|
||||
src?: string
|
||||
alt?: string
|
||||
fallbackSrc?: string
|
||||
mode?: ImageMode
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
radius?: string | number
|
||||
lazyLoad?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
src: '',
|
||||
alt: '点位图片',
|
||||
fallbackSrc: '/static/poi/placeholder.png',
|
||||
mode: 'aspectFill',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
radius: 0,
|
||||
lazyLoad: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
load: [event: unknown]
|
||||
error: [event: unknown]
|
||||
fallback: [failedSrc: string]
|
||||
}>()
|
||||
|
||||
const currentSrc = ref('')
|
||||
const fallbackFailed = ref(false)
|
||||
const lastReportedFallbackSource = ref<string | null>(null)
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
width: normalizeSize(props.width),
|
||||
height: normalizeSize(props.height),
|
||||
borderRadius: normalizeSize(props.radius),
|
||||
}))
|
||||
|
||||
function normalizeSize(value: string | number): string {
|
||||
return typeof value === 'number' ? `${value}rpx` : value
|
||||
}
|
||||
|
||||
function resetSource(): void {
|
||||
const source = props.src.trim()
|
||||
const fallback = props.fallbackSrc.trim()
|
||||
currentSrc.value = source || fallback
|
||||
fallbackFailed.value = !currentSrc.value
|
||||
}
|
||||
|
||||
function handleLoad(event: unknown): void {
|
||||
emit('load', event)
|
||||
}
|
||||
|
||||
function handleError(event: unknown): void {
|
||||
emit('error', event)
|
||||
|
||||
const fallback = props.fallbackSrc.trim()
|
||||
if (fallback && currentSrc.value !== fallback) {
|
||||
const failedSrc = currentSrc.value
|
||||
currentSrc.value = fallback
|
||||
if (lastReportedFallbackSource.value !== failedSrc) {
|
||||
lastReportedFallbackSource.value = failedSrc
|
||||
emit('fallback', failedSrc)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fallbackFailed.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
lastReportedFallbackSource.value = null
|
||||
resetSource()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(() => props.fallbackSrc, resetSource)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="poi-image"
|
||||
:style="imageStyle"
|
||||
role="img"
|
||||
:aria-label="alt"
|
||||
>
|
||||
<image
|
||||
v-if="!fallbackFailed"
|
||||
class="poi-image__asset"
|
||||
:src="currentSrc"
|
||||
:mode="mode"
|
||||
:lazy-load="lazyLoad"
|
||||
:alt="alt"
|
||||
@load="handleLoad"
|
||||
@error="handleError"
|
||||
/>
|
||||
<view v-else class="poi-image__unavailable">
|
||||
<text class="poi-image__unavailable-mark">GM</text>
|
||||
<text class="poi-image__unavailable-text">图片暂不可用</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poi-image {
|
||||
position: relative;
|
||||
display: block;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
background: #e8eeeb;
|
||||
}
|
||||
|
||||
.poi-image__asset {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.poi-image__unavailable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 120rpx;
|
||||
color: #6d7d75;
|
||||
background: linear-gradient(145deg, #edf3f0, #dce6e1);
|
||||
}
|
||||
|
||||
.poi-image__unavailable-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 64rpx;
|
||||
color: #527064;
|
||||
border: 2rpx solid #91a39a;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.poi-image__unavailable-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiTag } from '@/domain/poi'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
tags: ReadonlyArray<Pick<PoiTag, 'code' | 'name'>>
|
||||
limit?: number
|
||||
compact?: boolean
|
||||
showOverflowCount?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
limit: Number.POSITIVE_INFINITY,
|
||||
compact: false,
|
||||
showOverflowCount: true,
|
||||
})
|
||||
|
||||
const normalizedLimit = computed(() => {
|
||||
if (!Number.isFinite(props.limit))
|
||||
return props.tags.length
|
||||
|
||||
return Math.max(0, Math.floor(props.limit))
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.tags.slice(0, normalizedLimit.value))
|
||||
const hiddenCount = computed(() => Math.max(0, props.tags.length - visibleTags.value.length))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="visibleTags.length > 0 || (showOverflowCount && hiddenCount > 0)"
|
||||
class="poi-tag-list"
|
||||
:class="{ 'poi-tag-list--compact': compact }"
|
||||
aria-label="特色标签"
|
||||
>
|
||||
<text
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.code"
|
||||
class="poi-tag-list__tag"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</text>
|
||||
<text
|
||||
v-if="showOverflowCount && hiddenCount > 0"
|
||||
class="poi-tag-list__tag poi-tag-list__tag--overflow"
|
||||
:aria-label="`另有 ${hiddenCount} 个标签`"
|
||||
>
|
||||
+{{ hiddenCount }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poi-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.poi-tag-list__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
color: #24664f;
|
||||
white-space: nowrap;
|
||||
background: #f1f8f5;
|
||||
border: 1rpx solid #c8ddd3;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.poi-tag-list__tag--overflow {
|
||||
color: #6b7771;
|
||||
background: #f5f7f6;
|
||||
border-color: #d9e1dd;
|
||||
}
|
||||
|
||||
.poi-tag-list--compact {
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.poi-tag-list--compact .poi-tag-list__tag {
|
||||
min-height: 36rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 30rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as PageState } from './PageState.vue'
|
||||
export { default as PoiHeroGallery } from './PoiHeroGallery.vue'
|
||||
export { default as PoiImage } from './PoiImage.vue'
|
||||
export { default as PoiTagList } from './PoiTagList.vue'
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiResolved } from '@/domain/poi'
|
||||
import PoiImage from '@/components/poi/PoiImage.vue'
|
||||
import PoiTagList from '@/components/poi/PoiTagList.vue'
|
||||
|
||||
interface Props {
|
||||
poi: PoiResolved
|
||||
index?: number
|
||||
timeText?: string
|
||||
reason?: string
|
||||
activity?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
index: 0,
|
||||
timeText: '',
|
||||
reason: '',
|
||||
activity: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
detail: [poiId: string]
|
||||
map: [poiId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="travel-poi-card">
|
||||
<view class="travel-poi-card__media">
|
||||
<PoiImage
|
||||
:src="poi.coverImage.url"
|
||||
:alt="poi.coverImage.alt"
|
||||
width="176rpx"
|
||||
height="160rpx"
|
||||
radius="16rpx"
|
||||
/>
|
||||
<text v-if="index > 0" class="travel-poi-card__index">{{ index }}</text>
|
||||
</view>
|
||||
|
||||
<view class="travel-poi-card__content">
|
||||
<view class="travel-poi-card__heading">
|
||||
<text class="travel-poi-card__name">{{ poi.name }}</text>
|
||||
<text class="travel-poi-card__category">{{ poi.category.name }}</text>
|
||||
</view>
|
||||
<text v-if="timeText" class="travel-poi-card__time">{{ timeText }}</text>
|
||||
<text class="travel-poi-card__activity">{{ activity || poi.summary }}</text>
|
||||
<PoiTagList class="travel-poi-card__tags" :tags="poi.tags" :limit="2" compact />
|
||||
<view v-if="reason" class="travel-poi-card__reason">
|
||||
<text class="travel-poi-card__spark">推荐</text>
|
||||
<text>{{ reason }}</text>
|
||||
</view>
|
||||
<view class="travel-poi-card__actions">
|
||||
<button @click="emit('detail', poi.id)">查看详情</button>
|
||||
<button class="travel-poi-card__map" @click="emit('map', poi.id)">地图定位</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.travel-poi-card {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #dce6e1;
|
||||
border-radius: 22rpx;
|
||||
box-shadow: 0 10rpx 28rpx rgb(20 78 53 / 6%);
|
||||
}
|
||||
|
||||
.travel-poi-card__media {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.travel-poi-card__index {
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
left: 10rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 46rpx;
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
border: 3rpx solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.travel-poi-card__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.travel-poi-card__heading {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.travel-poi-card__name {
|
||||
flex: 1;
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
line-height: 40rpx;
|
||||
color: #18201d;
|
||||
}
|
||||
|
||||
.travel-poi-card__category {
|
||||
flex: none;
|
||||
padding: 4rpx 10rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 28rpx;
|
||||
color: #167a5b;
|
||||
background: #eaf5f0;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.travel-poi-card__time {
|
||||
margin-top: 4rpx;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
line-height: 32rpx;
|
||||
color: #167a5b;
|
||||
}
|
||||
|
||||
.travel-poi-card__activity {
|
||||
display: -webkit-box;
|
||||
margin-top: 8rpx;
|
||||
overflow: hidden;
|
||||
font-size: 22rpx;
|
||||
line-height: 33rpx;
|
||||
color: #637069;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.travel-poi-card__tags {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.travel-poi-card__reason {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 12rpx;
|
||||
margin-top: 12rpx;
|
||||
font-size: 19rpx;
|
||||
line-height: 29rpx;
|
||||
color: #436154;
|
||||
background: #f0f8f4;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
|
||||
.travel-poi-card__spark {
|
||||
flex: none;
|
||||
font-size: 17rpx;
|
||||
font-weight: 800;
|
||||
color: #078e55;
|
||||
}
|
||||
|
||||
.travel-poi-card__actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.travel-poi-card__actions button {
|
||||
width: auto;
|
||||
height: 54rpx;
|
||||
padding: 0 18rpx;
|
||||
margin: 0;
|
||||
font-size: 20rpx;
|
||||
line-height: 54rpx;
|
||||
color: #435149;
|
||||
background: #f1f4f2;
|
||||
border: 0;
|
||||
border-radius: 27rpx;
|
||||
}
|
||||
|
||||
.travel-poi-card__actions button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.travel-poi-card__actions .travel-poi-card__map {
|
||||
color: #fff;
|
||||
background: #167a5b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TravelPoiCard } from './TravelPoiCard.vue'
|
||||
Reference in New Issue
Block a user