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
+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'