1313 lines
32 KiB
Vue
1313 lines
32 KiB
Vue
<template>
|
||
<view class="discovery-page">
|
||
<scroll-view
|
||
class="discovery-scroll"
|
||
scroll-y
|
||
:scroll-with-animation="true"
|
||
:style="{ paddingBottom: scrollPaddingBottom + 'px' }"
|
||
>
|
||
<view class="hero-panel">
|
||
<view class="hero-copy">
|
||
<text class="hero-kicker">发现</text>
|
||
<text class="hero-title">附近服务与养宠百科</text>
|
||
<text class="hero-desc">
|
||
需要就医、查知识、找协寻入口时,先来这里快速判断下一步。
|
||
</text>
|
||
</view>
|
||
|
||
<view class="hero-stats">
|
||
<view class="hero-stat">
|
||
<text class="hero-stat-value">{{ hospitalCountText }}</text>
|
||
<text class="hero-stat-label">医院</text>
|
||
</view>
|
||
<view class="hero-stat-divider" />
|
||
<view class="hero-stat">
|
||
<text class="hero-stat-value">{{ articleCountText }}</text>
|
||
<text class="hero-stat-label">百科</text>
|
||
</view>
|
||
<view class="hero-stat-divider" />
|
||
<view class="hero-stat">
|
||
<text class="hero-stat-value">{{ nearestDistanceText }}</text>
|
||
<text class="hero-stat-label">最近</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="search-card">
|
||
<TnSearchBox
|
||
v-model="keyword"
|
||
placeholder="搜索医院、症状、疫苗、驱虫"
|
||
shape="round"
|
||
size="lg"
|
||
border-color="#FED7AA"
|
||
search-button-bg-color="#F97316"
|
||
search-button-text-color="#FFFFFF"
|
||
search-button-text="搜索"
|
||
@search="handleSearch"
|
||
@clear="handleClearSearch"
|
||
/>
|
||
</view>
|
||
|
||
<view class="tab-card">
|
||
<TnTabs
|
||
v-model="currentTabIndex"
|
||
height="76rpx"
|
||
bar-width="38rpx"
|
||
bar-color="#F97316"
|
||
active-color="#F97316"
|
||
color="#64748B"
|
||
bg-color="#FFFFFF"
|
||
font-size="25rpx"
|
||
active-font-size="27rpx"
|
||
:bottom-shadow="false"
|
||
>
|
||
<TnTabsItem v-for="tab in mainTabs" :key="tab" :title="tab" />
|
||
</TnTabs>
|
||
</view>
|
||
|
||
<view v-if="currentTabIndex === 0" class="panel-section">
|
||
<view class="section-head">
|
||
<view class="section-title-block">
|
||
<text class="section-title">附近宠物医院</text>
|
||
<text class="section-subtitle">{{ locationHint }}</text>
|
||
</view>
|
||
<TnButton
|
||
:custom-style="outlineButtonStyle"
|
||
text-color="#F97316"
|
||
shape="round"
|
||
width="172"
|
||
height="66"
|
||
:font-size="24"
|
||
@click="requestLocation"
|
||
>
|
||
<view class="button-inner">
|
||
<TnIcon name="location" :size="24" />
|
||
<text>{{ locationButtonText }}</text>
|
||
</view>
|
||
</TnButton>
|
||
</view>
|
||
|
||
<view class="distance-filter">
|
||
<view
|
||
v-for="range in distanceRanges"
|
||
:key="range.value"
|
||
class="distance-chip"
|
||
:class="{ 'distance-chip-active': selectedDistance === range.value }"
|
||
@click="selectDistance(range.value)"
|
||
>
|
||
<text>{{ range.label }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="loadingHospitals" class="loading-state">
|
||
<TnLoading :show="true" mode="circle" color="#F97316" :size="52" />
|
||
<text class="loading-text">正在整理附近医院...</text>
|
||
</view>
|
||
|
||
<view v-else-if="filteredHospitals.length === 0" class="empty-state">
|
||
<TnEmpty mode="data" color="#F97316" size="lg" />
|
||
<text class="empty-title">暂时没有匹配的医院</text>
|
||
<text class="empty-desc">换个关键词,或切换到更大的距离范围看看。</text>
|
||
</view>
|
||
|
||
<view v-else class="hospital-list">
|
||
<view
|
||
v-for="hospital in filteredHospitals"
|
||
:key="hospital._id"
|
||
class="hospital-card"
|
||
hover-class="card-active"
|
||
@click="previewHospital(hospital)"
|
||
>
|
||
<view class="hospital-accent" :class="{ 'accent-open': hospital.is24Hour }" />
|
||
<view class="hospital-main">
|
||
<view class="hospital-title-row">
|
||
<text class="hospital-name">{{ hospital.name }}</text>
|
||
<TnTag
|
||
v-if="hospital.verified"
|
||
bg-color="#ECFDF5"
|
||
text-color="#059669"
|
||
shape="round"
|
||
size="sm"
|
||
>
|
||
已认证
|
||
</TnTag>
|
||
</view>
|
||
<text class="hospital-address">{{ hospital.address }}</text>
|
||
<view class="hospital-meta">
|
||
<view class="meta-pill">
|
||
<TnIcon name="star-fill" :size="21" color="#F59E0B" />
|
||
<text>{{ formatRating(hospital.rating) }}</text>
|
||
</view>
|
||
<view class="meta-pill">
|
||
<TnIcon name="location" :size="21" color="#64748B" />
|
||
<text>{{ hospital.distanceText || '距离未知' }}</text>
|
||
</view>
|
||
<view v-if="hospital.is24Hour" class="meta-pill urgent-pill">
|
||
<text>24小时</text>
|
||
</view>
|
||
</view>
|
||
<view class="tag-row">
|
||
<TnTag
|
||
v-for="tag in hospital.tags.slice(0, 3)"
|
||
:key="tag"
|
||
bg-color="#FFF7ED"
|
||
text-color="#F97316"
|
||
shape="round"
|
||
size="sm"
|
||
>
|
||
{{ tag }}
|
||
</TnTag>
|
||
</view>
|
||
</view>
|
||
<view class="hospital-actions" @click.stop>
|
||
<view
|
||
class="icon-button"
|
||
:class="{ 'icon-button-disabled': !hospital.phone }"
|
||
@click="callHospital(hospital)"
|
||
>
|
||
<TnIcon name="tel" :size="28" :color="hospital.phone ? '#0D9488' : '#CBD5E1'" />
|
||
</view>
|
||
<view class="icon-button" @click="openHospitalLocation(hospital)">
|
||
<TnIcon name="map" :size="28" color="#F97316" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else-if="currentTabIndex === 1" class="panel-section">
|
||
<view class="section-head">
|
||
<view class="section-title-block">
|
||
<text class="section-title">养宠百科</text>
|
||
<text class="section-subtitle">按场景查,先处理紧急程度,再补记录。</text>
|
||
</view>
|
||
<TnTag bg-color="#F0FDFA" text-color="#0D9488" shape="round" size="sm">
|
||
{{ filteredArticles.length }} 篇
|
||
</TnTag>
|
||
</view>
|
||
|
||
<scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
|
||
<view class="category-track">
|
||
<view
|
||
v-for="category in articleCategories"
|
||
:key="category.value"
|
||
class="category-chip"
|
||
:class="{ 'category-chip-active': selectedArticleCategory === category.value }"
|
||
@click="selectArticleCategory(category.value)"
|
||
>
|
||
<TnIcon :name="category.icon" :size="24" />
|
||
<text>{{ category.label }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view v-if="loadingArticles" class="loading-state">
|
||
<TnLoading :show="true" mode="circle" color="#0D9488" :size="52" />
|
||
<text class="loading-text">正在读取百科内容...</text>
|
||
</view>
|
||
|
||
<view v-else-if="filteredArticles.length === 0" class="empty-state">
|
||
<TnEmpty mode="search" color="#0D9488" size="lg" />
|
||
<text class="empty-title">没搜到相关百科</text>
|
||
<text class="empty-desc">试试「呕吐」「疫苗」「驱虫」这类更短的关键词。</text>
|
||
</view>
|
||
|
||
<view v-else class="article-list">
|
||
<view
|
||
v-for="article in filteredArticles"
|
||
:key="article._id"
|
||
class="article-card"
|
||
hover-class="card-active"
|
||
@click="openArticle(article)"
|
||
>
|
||
<view class="article-cover" :style="{ background: getArticleCover(article.category) }">
|
||
<TnIcon :name="getArticleIcon(article.category)" :size="48" color="#FFFFFF" />
|
||
</view>
|
||
<view class="article-main">
|
||
<view class="article-title-row">
|
||
<text class="article-title">{{ article.title }}</text>
|
||
<TnTag :bg-color="getCategoryStyle(article.category).bg" :text-color="getCategoryStyle(article.category).color" shape="round" size="sm">
|
||
{{ getCategoryLabel(article.category) }}
|
||
</TnTag>
|
||
</view>
|
||
<text class="article-summary">{{ article.summary }}</text>
|
||
<view class="article-meta">
|
||
<view class="meta-pill">
|
||
<TnIcon name="eye" :size="20" color="#64748B" />
|
||
<text>{{ article.viewCount || 0 }}</text>
|
||
</view>
|
||
<view class="meta-pill">
|
||
<TnIcon name="floral" :size="20" color="#64748B" />
|
||
<text>{{ getPetTypeLabel(article.petType) }}</text>
|
||
</view>
|
||
<view class="article-tags">
|
||
<text v-for="tag in article.tags.slice(0, 2)" :key="tag">#{{ tag }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<TnIcon name="right" :size="28" color="#CBD5E1" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="panel-section">
|
||
<view class="assist-card">
|
||
<view class="assist-icon">
|
||
<TnIcon name="notice-fill" :size="54" color="#FFFFFF" />
|
||
</view>
|
||
<view class="assist-copy">
|
||
<text class="assist-title">宠物丢失协寻</text>
|
||
<text class="assist-desc">
|
||
产品文档把协寻列为社区可选能力。当前先保留发布入口与流程说明,等内容安全审核和附近推送接入后即可开放。
|
||
</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="assist-steps">
|
||
<view v-for="step in lostPetSteps" :key="step.title" class="step-row">
|
||
<view class="step-icon">
|
||
<TnIcon :name="step.icon" :size="30" color="#F97316" />
|
||
</view>
|
||
<view class="step-copy">
|
||
<text class="step-title">{{ step.title }}</text>
|
||
<text class="step-desc">{{ step.desc }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<TnButton
|
||
text-color="#FFFFFF"
|
||
shape="round"
|
||
:bold="true"
|
||
:custom-style="primaryButtonStyle"
|
||
width="100%"
|
||
height="88"
|
||
:font-size="28"
|
||
@click="publishLostPet"
|
||
>
|
||
<view class="button-inner">
|
||
<TnIcon name="add-lack" :size="28" />
|
||
<text>发布丢失启事</text>
|
||
</view>
|
||
</TnButton>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import TnButton from '@/uni_modules/tuniaoui-vue3/components/button/src/button.vue'
|
||
import TnEmpty from '@/uni_modules/tuniaoui-vue3/components/empty/src/empty.vue'
|
||
import TnIcon from '@/uni_modules/tuniaoui-vue3/components/icon/src/icon.vue'
|
||
import TnLoading from '@/uni_modules/tuniaoui-vue3/components/loading/src/loading.vue'
|
||
import TnSearchBox from '@/uni_modules/tuniaoui-vue3/components/search-box/src/search-box.vue'
|
||
import TnTabs from '@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs.vue'
|
||
import TnTabsItem from '@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs-item.vue'
|
||
import TnTag from '@/uni_modules/tuniaoui-vue3/components/tag/src/tag.vue'
|
||
|
||
interface Hospital {
|
||
_id : string
|
||
name : string
|
||
address : string
|
||
location ?: {
|
||
type : string
|
||
coordinates : number[]
|
||
}
|
||
phone ?: string
|
||
hours ?: string
|
||
is24Hour : boolean
|
||
tags : string[]
|
||
rating ?: number
|
||
reviewCount ?: number
|
||
verified ?: boolean
|
||
status ?: string
|
||
distance ?: number | null
|
||
distanceText ?: string
|
||
}
|
||
|
||
interface Article {
|
||
_id : string
|
||
title : string
|
||
category : string
|
||
petType : string
|
||
cover ?: string
|
||
content ?: string
|
||
summary : string
|
||
tags : string[]
|
||
viewCount ?: number
|
||
status ?: string
|
||
createdAt ?: number | string
|
||
}
|
||
|
||
interface CategoryStyle {
|
||
label : string
|
||
icon : string
|
||
color : string
|
||
bg : string
|
||
cover : string
|
||
}
|
||
|
||
const mainTabs = ['附近医院', '养宠百科', '丢失协寻']
|
||
const currentTabIndex = ref(0)
|
||
const keyword = ref('')
|
||
const activeKeyword = ref('')
|
||
const loadingHospitals = ref(true)
|
||
const loadingArticles = ref(true)
|
||
const selectedDistance = ref(5000)
|
||
const selectedArticleCategory = ref('all')
|
||
const userLocation = ref<{ longitude : number; latitude : number } | null>(null)
|
||
const hospitals = ref<Hospital[]>([])
|
||
const articles = ref<Article[]>([])
|
||
const scrollPaddingBottom = ref(78)
|
||
|
||
const primaryButtonStyle = {
|
||
background: 'linear-gradient(135deg, #F97316, #FB923C)',
|
||
}
|
||
|
||
const outlineButtonStyle = {
|
||
background: '#FFF7ED',
|
||
border: '1rpx solid #FED7AA',
|
||
}
|
||
|
||
const distanceRanges = [
|
||
{ label: '3km', value: 3000 },
|
||
{ label: '5km', value: 5000 },
|
||
{ label: '10km', value: 10000 },
|
||
]
|
||
|
||
const categoryStyles : Record<string, CategoryStyle> = {
|
||
vaccine: {
|
||
label: '疫苗',
|
||
icon: 'safe-fill',
|
||
color: '#2563EB',
|
||
bg: '#EFF6FF',
|
||
cover: 'linear-gradient(135deg, #2563EB, #60A5FA)',
|
||
},
|
||
deworm: {
|
||
label: '驱虫',
|
||
icon: 'notice-fill',
|
||
color: '#7C3AED',
|
||
bg: '#F5F3FF',
|
||
cover: 'linear-gradient(135deg, #7C3AED, #A78BFA)',
|
||
},
|
||
disease: {
|
||
label: '疾病',
|
||
icon: 'medical',
|
||
color: '#DC2626',
|
||
bg: '#FEF2F2',
|
||
cover: 'linear-gradient(135deg, #DC2626, #FB7185)',
|
||
},
|
||
nutrition: {
|
||
label: '营养',
|
||
icon: 'shop-fill',
|
||
color: '#0D9488',
|
||
bg: '#F0FDFA',
|
||
cover: 'linear-gradient(135deg, #0D9488, #2DD4BF)',
|
||
},
|
||
behavior: {
|
||
label: '行为',
|
||
icon: 'floral',
|
||
color: '#F97316',
|
||
bg: '#FFF7ED',
|
||
cover: 'linear-gradient(135deg, #F97316, #FB923C)',
|
||
},
|
||
other: {
|
||
label: '其他',
|
||
icon: 'inventory-fill',
|
||
color: '#475569',
|
||
bg: '#F8FAFC',
|
||
cover: 'linear-gradient(135deg, #64748B, #94A3B8)',
|
||
},
|
||
}
|
||
|
||
const articleCategories = [
|
||
{ label: '全部', value: 'all', icon: 'menu-grille' },
|
||
...Object.entries(categoryStyles).map(([value, item]) => ({
|
||
label: item.label,
|
||
value,
|
||
icon: item.icon,
|
||
})),
|
||
]
|
||
|
||
const lostPetSteps = [
|
||
{ title: '填写信息', desc: '照片、特征、丢失地点、时间和联系方式。', icon: 'edit-write' },
|
||
{ title: '内容审核', desc: '接入平台内容安全后,先审核再展示。', icon: 'safe' },
|
||
{ title: '附近推送', desc: '按地理位置提醒附近用户,发现者可反馈位置。', icon: 'location' },
|
||
]
|
||
|
||
const fallbackHospitals : Hospital[] = [
|
||
{
|
||
_id: 'seed-hospital-1',
|
||
name: '暖爪宠物医院',
|
||
address: '示例城市 梧桐路 18 号',
|
||
location: { type: 'Point', coordinates: [121.4737, 31.2304] },
|
||
phone: '400-000-1024',
|
||
hours: '09:00-21:00',
|
||
is24Hour: false,
|
||
tags: ['疫苗', '猫科', '体检'],
|
||
rating: 4.8,
|
||
reviewCount: 236,
|
||
verified: true,
|
||
status: 'active',
|
||
},
|
||
{
|
||
_id: 'seed-hospital-2',
|
||
name: '夜航动物急诊中心',
|
||
address: '示例城市 星河大道 66 号',
|
||
location: { type: 'Point', coordinates: [121.489, 31.222] },
|
||
phone: '400-000-2048',
|
||
hours: '24小时',
|
||
is24Hour: true,
|
||
tags: ['急诊', '影像', '住院'],
|
||
rating: 4.6,
|
||
reviewCount: 189,
|
||
verified: true,
|
||
status: 'active',
|
||
},
|
||
{
|
||
_id: 'seed-hospital-3',
|
||
name: '小橘子宠物诊所',
|
||
address: '示例城市 花园街 9 号',
|
||
location: { type: 'Point', coordinates: [121.451, 31.238] },
|
||
phone: '400-000-4096',
|
||
hours: '10:00-20:00',
|
||
is24Hour: false,
|
||
tags: ['皮肤科', '驱虫', '绝育'],
|
||
rating: 4.5,
|
||
reviewCount: 94,
|
||
verified: false,
|
||
status: 'active',
|
||
},
|
||
]
|
||
|
||
const fallbackArticles : Article[] = [
|
||
{
|
||
_id: 'seed-article-1',
|
||
title: '幼猫第一年疫苗怎么安排',
|
||
category: 'vaccine',
|
||
petType: 'cat',
|
||
summary: '梳理妙三多、狂犬和加强针的常见节奏,帮助你和医生确认计划。',
|
||
tags: ['幼猫', '妙三多', '狂犬'],
|
||
viewCount: 1280,
|
||
status: 'published',
|
||
},
|
||
{
|
||
_id: 'seed-article-2',
|
||
title: '猫咪呕吐时先观察什么',
|
||
category: 'disease',
|
||
petType: 'cat',
|
||
summary: '从频率、精神、食欲、异物风险判断是否需要尽快就医。',
|
||
tags: ['呕吐', '急诊', '观察'],
|
||
viewCount: 2136,
|
||
status: 'published',
|
||
},
|
||
{
|
||
_id: 'seed-article-3',
|
||
title: '体内外驱虫的间隔怎么记',
|
||
category: 'deworm',
|
||
petType: 'all',
|
||
summary: '按品牌和生活环境估算默认周期,再同步到提醒日程。',
|
||
tags: ['驱虫', '提醒', '周期'],
|
||
viewCount: 986,
|
||
status: 'published',
|
||
},
|
||
{
|
||
_id: 'seed-article-4',
|
||
title: '换粮期便便变软怎么办',
|
||
category: 'nutrition',
|
||
petType: 'dog',
|
||
summary: '用 7 天过渡法降低肠胃压力,并记录体重和便便状态。',
|
||
tags: ['换粮', '软便', '营养'],
|
||
viewCount: 742,
|
||
status: 'published',
|
||
},
|
||
]
|
||
|
||
const hospitalCountText = computed(() => String(hospitals.value.length))
|
||
const articleCountText = computed(() => String(articles.value.length))
|
||
|
||
const nearestDistanceText = computed(() => {
|
||
const nearest = filteredHospitals.value.find((item) => item.distanceText)
|
||
return nearest?.distanceText || '待定位'
|
||
})
|
||
|
||
const locationHint = computed(() => {
|
||
if (userLocation.value) return `已按 ${selectedDistance.value / 1000}km 范围优先排序`
|
||
return '授权定位后,可按距离排序并一键导航'
|
||
})
|
||
|
||
const locationButtonText = computed(() => (userLocation.value ? '重定位' : '定位'))
|
||
|
||
const filteredHospitals = computed(() => {
|
||
const text = normalizeKeyword(activeKeyword.value)
|
||
return hospitals.value
|
||
.filter((hospital) => {
|
||
const distanceOk = !userLocation.value || !hospital.distance || hospital.distance <= selectedDistance.value
|
||
if (!distanceOk) return false
|
||
if (!text) return true
|
||
return normalizeKeyword([
|
||
hospital.name,
|
||
hospital.address,
|
||
hospital.hours,
|
||
...(hospital.tags || []),
|
||
].join(' ')).includes(text)
|
||
})
|
||
.sort((a, b) => {
|
||
if (a.distance != null && b.distance != null) return a.distance - b.distance
|
||
if (a.is24Hour !== b.is24Hour) return a.is24Hour ? -1 : 1
|
||
return (b.rating || 0) - (a.rating || 0)
|
||
})
|
||
})
|
||
|
||
const filteredArticles = computed(() => {
|
||
const text = normalizeKeyword(activeKeyword.value)
|
||
return articles.value.filter((article) => {
|
||
const categoryOk = selectedArticleCategory.value === 'all' || article.category === selectedArticleCategory.value
|
||
if (!categoryOk) return false
|
||
if (!text) return true
|
||
return normalizeKeyword([
|
||
article.title,
|
||
article.summary,
|
||
getCategoryLabel(article.category),
|
||
getPetTypeLabel(article.petType),
|
||
...(article.tags || []),
|
||
].join(' ')).includes(text)
|
||
})
|
||
})
|
||
|
||
onMounted(() => {
|
||
calcLayout()
|
||
loadDiscoveryData()
|
||
})
|
||
|
||
function calcLayout() {
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
scrollPaddingBottom.value = (systemInfo.safeAreaInsets?.bottom || 0) + 78
|
||
}
|
||
|
||
async function loadDiscoveryData() {
|
||
await Promise.all([loadHospitals(), loadArticles()])
|
||
}
|
||
|
||
async function loadHospitals() {
|
||
loadingHospitals.value = true
|
||
try {
|
||
const db = uniCloud.database()
|
||
const res = await db.collection('hospitals')
|
||
.where('status == "active"')
|
||
.limit(50)
|
||
.get()
|
||
const list = normalizeHospitals((res.result?.data || res.data || []) as Hospital[])
|
||
hospitals.value = list.length ? enrichHospitalDistance(list) : enrichHospitalDistance(fallbackHospitals)
|
||
} catch (e) {
|
||
console.warn('加载医院数据失败,使用本地种子数据', e)
|
||
hospitals.value = enrichHospitalDistance(fallbackHospitals)
|
||
} finally {
|
||
loadingHospitals.value = false
|
||
}
|
||
}
|
||
|
||
async function loadArticles() {
|
||
loadingArticles.value = true
|
||
try {
|
||
const db = uniCloud.database()
|
||
const res = await db.collection('articles')
|
||
.where('status == "published"')
|
||
.orderBy('createdAt', 'desc')
|
||
.limit(30)
|
||
.get()
|
||
const list = normalizeArticles((res.result?.data || res.data || []) as Article[])
|
||
articles.value = list.length ? list : fallbackArticles
|
||
} catch (e) {
|
||
console.warn('加载百科数据失败,使用本地种子数据', e)
|
||
articles.value = fallbackArticles
|
||
} finally {
|
||
loadingArticles.value = false
|
||
}
|
||
}
|
||
|
||
function normalizeHospitals(list : Hospital[]) {
|
||
return list.map((item) => ({
|
||
...item,
|
||
is24Hour: Boolean(item.is24Hour),
|
||
tags: Array.isArray(item.tags) ? item.tags : [],
|
||
distance: item.distance ?? null,
|
||
distanceText: item.distanceText || formatDistance(item.distance),
|
||
}))
|
||
}
|
||
|
||
function normalizeArticles(list : Article[]) {
|
||
return list.map((item) => ({
|
||
...item,
|
||
petType: item.petType || 'all',
|
||
summary: item.summary || '暂无摘要,点击查看详情。',
|
||
tags: Array.isArray(item.tags) ? item.tags : [],
|
||
viewCount: item.viewCount || 0,
|
||
}))
|
||
}
|
||
|
||
function enrichHospitalDistance(list : Hospital[]) {
|
||
if (!userLocation.value) {
|
||
return list.map((item) => ({ ...item, distanceText: item.distanceText || formatDistance(item.distance) }))
|
||
}
|
||
return list.map((item) => {
|
||
const coordinates = item.location?.coordinates
|
||
const distance = coordinates?.length === 2
|
||
? calcDistance(userLocation.value!.latitude, userLocation.value!.longitude, coordinates[1], coordinates[0])
|
||
: item.distance
|
||
return {
|
||
...item,
|
||
distance,
|
||
distanceText: formatDistance(distance),
|
||
}
|
||
})
|
||
}
|
||
|
||
function requestLocation() {
|
||
loadingHospitals.value = true
|
||
uni.getLocation({
|
||
type: 'gcj02',
|
||
success: (res) => {
|
||
userLocation.value = {
|
||
longitude: res.longitude,
|
||
latitude: res.latitude,
|
||
}
|
||
hospitals.value = enrichHospitalDistance(hospitals.value.length ? hospitals.value : fallbackHospitals)
|
||
uni.showToast({ title: '已按距离更新', icon: 'success' })
|
||
},
|
||
fail: () => {
|
||
uni.showModal({
|
||
title: '需要定位权限',
|
||
content: '开启定位后才能按距离筛选附近宠物医院,也可以先查看默认推荐。',
|
||
confirmText: '知道了',
|
||
showCancel: false,
|
||
})
|
||
},
|
||
complete: () => {
|
||
loadingHospitals.value = false
|
||
},
|
||
})
|
||
}
|
||
|
||
function selectDistance(value : number) {
|
||
selectedDistance.value = value
|
||
}
|
||
|
||
function selectArticleCategory(value : string) {
|
||
selectedArticleCategory.value = value
|
||
}
|
||
|
||
function handleSearch(value : string) {
|
||
activeKeyword.value = value.trim()
|
||
}
|
||
|
||
function handleClearSearch() {
|
||
keyword.value = ''
|
||
activeKeyword.value = ''
|
||
}
|
||
|
||
function previewHospital(hospital : Hospital) {
|
||
uni.showModal({
|
||
title: hospital.name,
|
||
content: `${hospital.address}\n${hospital.hours || '营业时间待补充'}\n${hospital.tags.join(' / ') || '暂无特色标签'}`,
|
||
confirmText: '导航',
|
||
cancelText: '关闭',
|
||
success: (res) => {
|
||
if (res.confirm) openHospitalLocation(hospital)
|
||
},
|
||
})
|
||
}
|
||
|
||
function callHospital(hospital : Hospital) {
|
||
if (!hospital.phone) {
|
||
uni.showToast({ title: '暂无电话', icon: 'none' })
|
||
return
|
||
}
|
||
uni.makePhoneCall({
|
||
phoneNumber: hospital.phone,
|
||
fail: () => uni.showToast({ title: '无法拨打电话', icon: 'none' }),
|
||
})
|
||
}
|
||
|
||
function openHospitalLocation(hospital : Hospital) {
|
||
const coordinates = hospital.location?.coordinates
|
||
if (!coordinates || coordinates.length !== 2) {
|
||
uni.showToast({ title: '暂无导航位置', icon: 'none' })
|
||
return
|
||
}
|
||
uni.openLocation({
|
||
longitude: coordinates[0],
|
||
latitude: coordinates[1],
|
||
name: hospital.name,
|
||
address: hospital.address,
|
||
fail: () => uni.showToast({ title: '无法打开地图', icon: 'none' }),
|
||
})
|
||
}
|
||
|
||
function openArticle(article : Article) {
|
||
uni.showModal({
|
||
title: article.title,
|
||
content: article.content || article.summary,
|
||
confirmText: '记录相关提醒',
|
||
cancelText: '关闭',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
uni.navigateTo({ url: '/pages/record/type-select' })
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
function publishLostPet() {
|
||
uni.showModal({
|
||
title: '功能建设中',
|
||
content: '丢失协寻需要先接入内容安全审核与附近推送。当前可先通过健康记录和宠物档案保存关键信息。',
|
||
confirmText: '去宠物档案',
|
||
cancelText: '关闭',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
uni.switchTab({ url: '/pages/index/index' })
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
function normalizeKeyword(value : string) {
|
||
return value.trim().toLowerCase()
|
||
}
|
||
|
||
function formatRating(value ?: number) {
|
||
return value ? value.toFixed(1) : '暂无'
|
||
}
|
||
|
||
function formatDistance(value ?: number | null) {
|
||
if (value == null || Number.isNaN(value)) return ''
|
||
if (value < 1000) return `${Math.round(value)}m`
|
||
return `${(value / 1000).toFixed(1)}km`
|
||
}
|
||
|
||
function calcDistance(lat1 : number, lng1 : number, lat2 : number, lng2 : number) {
|
||
const radius = 6371000
|
||
const radLat1 = toRadians(lat1)
|
||
const radLat2 = toRadians(lat2)
|
||
const deltaLat = toRadians(lat2 - lat1)
|
||
const deltaLng = toRadians(lng2 - lng1)
|
||
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
|
||
+ Math.cos(radLat1) * Math.cos(radLat2)
|
||
* Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2)
|
||
return radius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||
}
|
||
|
||
function toRadians(value : number) {
|
||
return value * Math.PI / 180
|
||
}
|
||
|
||
function getCategoryStyle(category : string) {
|
||
return categoryStyles[category] || categoryStyles.other
|
||
}
|
||
|
||
function getCategoryLabel(category : string) {
|
||
return getCategoryStyle(category).label
|
||
}
|
||
|
||
function getArticleIcon(category : string) {
|
||
return getCategoryStyle(category).icon
|
||
}
|
||
|
||
function getArticleCover(category : string) {
|
||
return getCategoryStyle(category).cover
|
||
}
|
||
|
||
function getPetTypeLabel(type : string) {
|
||
const map : Record<string, string> = { cat: '猫咪', dog: '狗狗', all: '通用' }
|
||
return map[type] || '通用'
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
$primary: #F97316;
|
||
$primary-light: #FB923C;
|
||
$primary-bg: #FFF7ED;
|
||
$teal: #0D9488;
|
||
$text-dark: #0F172A;
|
||
$text-body: #475569;
|
||
$text-muted: #64748B;
|
||
$border: #E2E8F0;
|
||
$card-bg: #FFFFFF;
|
||
$shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
|
||
$shadow-active: 0 12rpx 34rpx rgba(15, 23, 42, 0.1);
|
||
|
||
.discovery-page {
|
||
position: relative;
|
||
height: 100%;
|
||
background: #F8FAFC;
|
||
}
|
||
|
||
.discovery-scroll {
|
||
height: 100vh;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.hero-panel {
|
||
margin: 24rpx 24rpx 0;
|
||
padding: 32rpx 28rpx 26rpx;
|
||
border-radius: 28rpx;
|
||
background: linear-gradient(135deg, #F97316 0%, #FB923C 58%, #14B8A6 150%);
|
||
box-shadow: 0 14rpx 36rpx rgba(249, 115, 22, 0.2);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.hero-copy {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.hero-kicker {
|
||
font-size: 23rpx;
|
||
font-weight: 800;
|
||
color: rgba(255, 255, 255, 0.78);
|
||
}
|
||
|
||
.hero-title {
|
||
margin-top: 8rpx;
|
||
font-size: 40rpx;
|
||
font-weight: 900;
|
||
color: #FFFFFF;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.hero-desc {
|
||
margin-top: 12rpx;
|
||
font-size: 25rpx;
|
||
line-height: 1.5;
|
||
color: rgba(255, 255, 255, 0.84);
|
||
}
|
||
|
||
.hero-stats {
|
||
margin-top: 28rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 18rpx 0;
|
||
border-radius: 18rpx;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
}
|
||
|
||
.hero-stat {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.hero-stat-value {
|
||
max-width: 176rpx;
|
||
font-size: 31rpx;
|
||
font-weight: 900;
|
||
color: #FFFFFF;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.hero-stat-label {
|
||
font-size: 21rpx;
|
||
color: rgba(255, 255, 255, 0.78);
|
||
}
|
||
|
||
.hero-stat-divider {
|
||
width: 1rpx;
|
||
height: 42rpx;
|
||
background: rgba(255, 255, 255, 0.28);
|
||
}
|
||
|
||
.search-card,
|
||
.tab-card {
|
||
margin: 20rpx 24rpx 0;
|
||
padding: 6rpx;
|
||
border-radius: 22rpx;
|
||
background: $card-bg;
|
||
box-shadow: $shadow;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tab-card {
|
||
display: grid;
|
||
place-items: center;
|
||
height: 80rpx;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
}
|
||
|
||
.tab-card :deep(.tn-tabs) {
|
||
width: 100%;
|
||
align-self: center;
|
||
}
|
||
|
||
.tab-card :deep(.tn-tabs__container) {
|
||
align-items: center;
|
||
}
|
||
|
||
.tab-card :deep(.tn-tabs__bar-container) {
|
||
bottom: 4rpx;
|
||
}
|
||
|
||
.panel-section {
|
||
padding: 26rpx 24rpx 0;
|
||
}
|
||
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 18rpx;
|
||
margin-bottom: 18rpx;
|
||
}
|
||
|
||
.section-title-block {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 31rpx;
|
||
font-weight: 900;
|
||
color: $text-dark;
|
||
}
|
||
|
||
.section-subtitle {
|
||
font-size: 23rpx;
|
||
color: $text-muted;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.distance-filter {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.distance-chip,
|
||
.category-chip {
|
||
min-height: 56rpx;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8rpx;
|
||
padding: 0 22rpx;
|
||
border-radius: 999rpx;
|
||
background: #FFFFFF;
|
||
border: 1rpx solid $border;
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: $text-body;
|
||
transition: opacity 0.18s ease;
|
||
|
||
&:active {
|
||
opacity: 0.72;
|
||
}
|
||
}
|
||
|
||
.distance-chip-active,
|
||
.category-chip-active {
|
||
background: $primary-bg;
|
||
border-color: #FDBA74;
|
||
color: $primary;
|
||
}
|
||
|
||
.loading-state,
|
||
.empty-state {
|
||
min-height: 420rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 18rpx;
|
||
}
|
||
|
||
.loading-text,
|
||
.empty-desc {
|
||
font-size: 25rpx;
|
||
color: $text-muted;
|
||
text-align: center;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.empty-title {
|
||
font-size: 31rpx;
|
||
font-weight: 800;
|
||
color: $text-dark;
|
||
}
|
||
|
||
.hospital-list,
|
||
.article-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.hospital-card,
|
||
.article-card {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18rpx;
|
||
min-height: 150rpx;
|
||
padding: 22rpx 20rpx 22rpx 24rpx;
|
||
border-radius: 24rpx;
|
||
background: $card-bg;
|
||
box-shadow: $shadow;
|
||
overflow: hidden;
|
||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||
|
||
&.card-active {
|
||
transform: scale(0.985);
|
||
box-shadow: $shadow-active;
|
||
}
|
||
}
|
||
|
||
.hospital-accent {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 7rpx;
|
||
background: linear-gradient(180deg, #F97316, #FDBA74);
|
||
|
||
&.accent-open {
|
||
background: linear-gradient(180deg, #0D9488, #2DD4BF);
|
||
}
|
||
}
|
||
|
||
.hospital-main,
|
||
.article-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.hospital-title-row,
|
||
.article-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
min-width: 0;
|
||
}
|
||
|
||
.hospital-name,
|
||
.article-title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 29rpx;
|
||
font-weight: 850;
|
||
color: $text-dark;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.hospital-address,
|
||
.article-summary {
|
||
font-size: 24rpx;
|
||
color: $text-muted;
|
||
line-height: 1.45;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
|
||
.hospital-meta,
|
||
.article-meta,
|
||
.tag-row {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 9rpx;
|
||
}
|
||
|
||
.meta-pill {
|
||
max-width: 190rpx;
|
||
min-height: 38rpx;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5rpx;
|
||
padding: 0 11rpx;
|
||
border-radius: 999rpx;
|
||
background: #F8FAFC;
|
||
border: 1rpx solid $border;
|
||
font-size: 21rpx;
|
||
color: $text-body;
|
||
|
||
text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.urgent-pill {
|
||
background: #ECFDF5;
|
||
border-color: #CCFBF1;
|
||
color: $teal;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.hospital-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.icon-button {
|
||
width: 62rpx;
|
||
height: 62rpx;
|
||
border-radius: 20rpx;
|
||
background: #F8FAFC;
|
||
border: 1rpx solid $border;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: opacity 0.18s ease;
|
||
|
||
&:active {
|
||
opacity: 0.68;
|
||
}
|
||
}
|
||
|
||
.icon-button-disabled {
|
||
background: #F8FAFC;
|
||
}
|
||
|
||
.category-scroll {
|
||
width: 100%;
|
||
white-space: nowrap;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.category-track {
|
||
display: inline-flex;
|
||
gap: 12rpx;
|
||
padding-right: 24rpx;
|
||
}
|
||
|
||
.article-cover {
|
||
width: 96rpx;
|
||
height: 96rpx;
|
||
border-radius: 24rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.1);
|
||
}
|
||
|
||
.article-tags {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
gap: 10rpx;
|
||
font-size: 21rpx;
|
||
color: #94A3B8;
|
||
|
||
text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.assist-card {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 20rpx;
|
||
padding: 26rpx;
|
||
border-radius: 24rpx;
|
||
background: #FFFFFF;
|
||
box-shadow: $shadow;
|
||
}
|
||
|
||
.assist-icon {
|
||
width: 92rpx;
|
||
height: 92rpx;
|
||
border-radius: 26rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.assist-copy,
|
||
.step-copy {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.assist-title,
|
||
.step-title {
|
||
font-size: 30rpx;
|
||
font-weight: 850;
|
||
color: $text-dark;
|
||
}
|
||
|
||
.assist-desc,
|
||
.step-desc {
|
||
font-size: 24rpx;
|
||
line-height: 1.55;
|
||
color: $text-muted;
|
||
}
|
||
|
||
.assist-steps {
|
||
margin: 18rpx 0 26rpx;
|
||
padding: 0 24rpx;
|
||
border-radius: 24rpx;
|
||
background: #FFFFFF;
|
||
box-shadow: $shadow;
|
||
}
|
||
|
||
.step-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18rpx;
|
||
padding: 24rpx 0;
|
||
border-bottom: 1rpx solid #F1F5F9;
|
||
|
||
&:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.step-icon {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
border-radius: 18rpx;
|
||
background: $primary-bg;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.button-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 9rpx;
|
||
}
|
||
</style>
|