Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+149
View File
@@ -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>
+171
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
export { default as MapFilterHeader } from './MapFilterHeader.vue'
export { default as PoiSummaryCard } from './PoiSummaryCard.vue'
+163
View File
@@ -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>
+127
View File
@@ -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>
+172
View File
@@ -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>
+91
View File
@@ -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>
+4
View File
@@ -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'
+195
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as TravelPoiCard } from './TravelPoiCard.vue'