FurBall/pages/index/discovery.vue

1313 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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