同步 sgs-frontend-mobile 源码
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-27 11:26:01 +08:00
parent 575dca430f
commit 9ea1bfab71
181 changed files with 24395 additions and 5212 deletions

View File

@@ -1,726 +1,115 @@
<template>
<view
v-if="visible"
class="route-planner-panel"
:class="{ collapsed: isCollapsed }"
@touchstart.stop="handlePanelTouchStart"
@touchmove.stop="handlePanelTouchMove"
@touchend.stop="handlePanelTouchEnd"
@mousedown.stop="handlePanelMouseStart"
@mousemove.stop="handlePanelMouseMove"
@mouseup.stop="handlePanelMouseEnd"
>
<view class="panel-handle" @tap.stop="toggleCollapsed"></view>
<view v-if="isCollapsed" class="panel-collapsed" @tap="expandPanel">
<view class="panel-collapsed-copy">
<text class="panel-title">馆内导览</text>
<text class="panel-summary">{{ collapsedSummary }}</text>
<view v-if="visible" class="route-planner-panel">
<view class="route-panel-header">
<view class="route-panel-copy">
<text class="route-panel-title">馆内导览</text>
<text class="route-panel-subtitle">{{ panelSubtitle }}</text>
</view>
<view class="panel-expand">
<text class="panel-expand-text">展开</text>
<view class="route-panel-close" aria-label="关闭导览" @tap="emit('back')">
<text class="route-panel-close-text">×</text>
</view>
</view>
<template v-else>
<view class="panel-header">
<view class="panel-title-group">
<text class="panel-title">馆内导览</text>
<text v-if="summary" class="panel-summary">{{ summary }}</text>
</view>
<view class="panel-actions">
<view class="panel-light-action" @tap="handleBack">
<text class="panel-light-action-text">返回</text>
<view v-if="startPoint && !hasRoutePreview && !loading" class="route-endpoint-row start-row">
<view class="route-point-dot start"></view>
<view class="route-point-copy">
<text class="route-point-name">{{ normalizeVisitorPoiDisplayName(startPoint.name) }}</text>
<text class="route-point-meta">起点 · {{ pointMeta(startPoint) }}</text>
</view>
</view>
<view v-if="loading" class="route-state-card">
<text class="route-state-text">正在生成路线...</text>
</view>
<view v-else-if="error" class="route-state-card error">
<text class="route-state-text">{{ error }}</text>
</view>
<view v-else-if="hasRoutePreview" class="route-ready-card">
<text class="route-ready-summary">{{ summary }}</text>
<view class="route-primary-action" @tap="emit('simulateGuide')">
<text class="route-primary-action-text">模拟导览</text>
</view>
</view>
<view v-else class="route-start-card">
<view class="route-point-dot start"></view>
<view class="route-start-copy">
<text class="route-start-title">请点击地图选择起点</text>
<text class="route-start-desc">仅可选择已接入馆内路网的地点</text>
</view>
</view>
<view v-if="startCandidate" class="route-confirm-mask" @tap.stop="emit('cancelStart')">
<view class="route-confirm-card" @tap.stop>
<text class="route-confirm-title">确认选择</text>
<text class="route-confirm-message">{{ normalizeVisitorPoiDisplayName(startCandidate.name) }} 为起点吗</text>
<view class="route-confirm-actions">
<view class="route-confirm-action secondary" @tap="emit('cancelStart')">
<text class="route-confirm-action-text">取消</text>
</view>
<view class="panel-light-action" @tap="collapsePanel">
<text class="panel-light-action-text">收起</text>
</view>
<view class="panel-light-action" @tap="handleClear">
<text class="panel-light-action-text">清除</text>
<view class="route-confirm-action primary" @tap="emit('confirmStart', startCandidate)">
<text class="route-confirm-action-text">确定</text>
</view>
</view>
</view>
<view class="point-row">
<view class="point-card" @tap="openPicker('start')">
<view class="point-dot start"></view>
<text class="point-label">起点</text>
<text class="point-name" :class="{ placeholder: !startPoint }">
{{ startPoint?.name || '选择起点' }}
</text>
<text v-if="startPoint" class="point-meta">{{ pointMeta(startPoint) }}</text>
</view>
<view class="swap-btn" @tap="handleSwap">
<text class="swap-text"></text>
</view>
<view class="point-card" @tap="openPicker('end')">
<view class="point-dot end"></view>
<text class="point-label">终点</text>
<text class="point-name" :class="{ placeholder: !endPoint }">
{{ endPoint?.name || '选择终点' }}
</text>
<text v-if="endPoint" class="point-meta">{{ pointMeta(endPoint) }}</text>
</view>
</view>
<view v-if="startPoint && endPoint" class="route-options">
<view class="route-options-header">
<text class="route-options-title">{{ routeOptionsTitle }}</text>
<text class="route-options-note">偏好选择</text>
</view>
<view class="route-option-list">
<view
v-for="option in routeOptions"
:key="option.id"
class="route-option"
:class="{ active: option.active, disabled: option.disabled }"
>
<text class="route-option-title">{{ option.title }}</text>
<text class="route-option-meta">{{ option.meta }}</text>
</view>
</view>
</view>
<view v-if="loading || error || summary" class="panel-status">
<text v-if="loading" class="panel-status-text">{{ loadingText }}</text>
<text v-else-if="error" class="panel-status-text error">{{ error }}</text>
<text v-else class="panel-status-text">{{ summary }}</text>
</view>
<view
class="view-route-btn"
:class="{ disabled: !canPrimaryAction }"
@tap="handlePrimaryAction"
>
<text class="view-route-text">{{ primaryActionText }}</text>
</view>
</template>
<RoutePointPicker
:visible="pickerMode !== ''"
:title="pickerTitle"
:placeholder="pickerPlaceholder"
:options="pointOptions"
:selected-poi-id="activeSelectedPoiId"
:loading="pickerLoading"
:error="pickerError"
:empty-text="pickerEmptyText"
close-text="返回"
@close="closePicker"
@select="handlePointSelect"
@search="handlePickerSearch"
@keyword-change="handlePickerKeywordChange"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import RoutePointPicker, {
type RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
type PickerMode = '' | 'start' | 'end'
import { computed } from 'vue'
import type { RoutePointOption } from '@/components/navigation/RoutePointPicker.vue'
import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
const props = withDefaults(defineProps<{
visible?: boolean
startPoint?: RoutePointOption | null
endPoint?: RoutePointOption | null
pointOptions?: RoutePointOption[]
startCandidate?: RoutePointOption | null
hasRoutePreview?: boolean
loading?: boolean
error?: string
summary?: string
pickerLoading?: boolean
pickerError?: string
pickerEmptyText?: string
routeReady?: boolean
}>(), {
visible: true,
startPoint: null,
endPoint: null,
pointOptions: () => [] as RoutePointOption[],
startCandidate: null,
hasRoutePreview: false,
loading: false,
error: '',
summary: '',
pickerLoading: false,
pickerError: '',
pickerEmptyText: '暂无匹配地点',
routeReady: false
summary: ''
})
const emit = defineEmits<{
'update:startPoint': [point: RoutePointOption | null]
'update:endPoint': [point: RoutePointOption | null]
startChange: [point: RoutePointOption]
endChange: [point: RoutePointOption]
pickerOpen: [mode: 'start' | 'end']
pickerClose: []
search: [payload: { mode: 'start' | 'end'; keyword: string }]
swap: []
clear: []
viewRoute: [payload: { startPoint: RoutePointOption; endPoint: RoutePointOption }]
confirmStart: [point: RoutePointOption]
cancelStart: []
simulateGuide: []
back: []
}>()
const pickerMode = ref<PickerMode>('')
const isCollapsed = ref(false)
const panelTouchStartY = ref(0)
const panelTouchCurrentY = ref(0)
const pickerTitle = computed(() => (
pickerMode.value === 'start' ? '选择起点' : '选择终点'
))
const pickerPlaceholder = computed(() => (
pickerMode.value === 'start' ? '搜索起点' : '搜索终点'
))
const activeSelectedPoiId = computed(() => (
pickerMode.value === 'start'
? props.startPoint?.poiId || ''
: props.endPoint?.poiId || ''
))
const canViewRoute = computed(() => Boolean(
props.startPoint
&& props.endPoint
&& !props.loading
&& props.startPoint.poiId !== props.endPoint.poiId
))
const canPrimaryAction = computed(() => canViewRoute.value && !props.error)
const primaryActionText = computed(() => '查看位置关系')
const routeOptionsTitle = computed(() => '馆内导览')
const loadingText = computed(() => '正在生成馆内位置关系')
const collapsedSummary = computed(() => {
if (props.startPoint && props.endPoint) {
return `${props.startPoint.name}${props.endPoint.name}`
}
return '手动选择起点和终点'
})
const routeOptions = computed(() => {
const unavailable = Boolean(props.error)
const summaryText = props.summary || '选择后查看位置关系'
return [
{
id: 'recommended',
title: '推荐路线',
meta: unavailable
? '当前暂不可用'
: props.hasRoutePreview
? summaryText
: '查看起点终点位置关系',
active: !unavailable,
disabled: unavailable
},
{
id: 'stairs',
title: '少走楼梯',
meta: '偏好预览,待路线数据验证',
active: false,
disabled: true
},
{
id: 'elevator',
title: '电梯优先',
meta: '偏好预览,待路线数据验证',
active: false,
disabled: true
}
]
})
watch(
() => props.visible,
(visible) => {
if (visible) {
isCollapsed.value = false
} else {
pickerMode.value = ''
}
}
const pointMeta = (point: RoutePointOption) => (
point.categoryLabel ? `${point.floorLabel} · ${point.categoryLabel}` : point.floorLabel
)
const pointMeta = (point: RoutePointOption) => {
return point.categoryLabel
? `${point.floorLabel} · ${point.categoryLabel}`
: point.floorLabel
const pointTitle = (point: RoutePointOption) => {
const floorLabel = point.floorLabel?.trim()
const name = normalizeVisitorPoiDisplayName(point.name) || '未命名地点'
if (!floorLabel || name.startsWith(`${floorLabel} `)) return name
return `${floorLabel} ${name}`
}
const openPicker = (mode: 'start' | 'end') => {
pickerMode.value = mode
emit('search', { mode, keyword: '' })
emit('pickerOpen', mode)
}
const closePicker = () => {
pickerMode.value = ''
emit('pickerClose')
}
const handlePointSelect = (point: RoutePointOption) => {
if (pickerMode.value === 'start') {
emit('update:startPoint', point)
emit('startChange', point)
} else if (pickerMode.value === 'end') {
emit('update:endPoint', point)
emit('endChange', point)
const panelSubtitle = computed(() => {
if (props.startPoint && props.endPoint) {
return `${pointTitle(props.startPoint)} 前往 ${pointTitle(props.endPoint)}`
}
closePicker()
}
const handlePickerSearch = (keyword: string) => {
if (!pickerMode.value) return
emit('search', { mode: pickerMode.value, keyword })
}
const handlePickerKeywordChange = (keyword: string) => {
if (!pickerMode.value) return
emit('search', { mode: pickerMode.value, keyword })
}
const handleSwap = () => {
emit('swap')
}
const handleClear = () => {
emit('clear')
}
const handleBack = () => {
if (pickerMode.value) {
closePicker()
return
}
emit('back')
}
const collapsePanel = () => {
isCollapsed.value = true
}
const expandPanel = () => {
isCollapsed.value = false
}
const toggleCollapsed = () => {
isCollapsed.value = !isCollapsed.value
}
const getGestureClientY = (event: TouchEvent | MouseEvent) => {
if ('changedTouches' in event) {
return event.changedTouches?.[0]?.clientY
?? event.touches?.[0]?.clientY
?? 0
}
return event.clientY
}
const handlePanelTouchStart = (event: TouchEvent) => {
if (pickerMode.value) return
const clientY = getGestureClientY(event)
panelTouchStartY.value = clientY
panelTouchCurrentY.value = clientY
}
const handlePanelTouchMove = (event: TouchEvent) => {
if (pickerMode.value) return
panelTouchCurrentY.value = getGestureClientY(event)
}
const handlePanelTouchEnd = (event: TouchEvent) => {
if (pickerMode.value) return
panelTouchCurrentY.value = getGestureClientY(event)
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
collapsePanel()
}
}
const handlePanelMouseStart = (event: MouseEvent) => {
if (pickerMode.value) return
const clientY = getGestureClientY(event)
panelTouchStartY.value = clientY
panelTouchCurrentY.value = clientY
}
const handlePanelMouseMove = (event: MouseEvent) => {
if (pickerMode.value) return
panelTouchCurrentY.value = getGestureClientY(event)
}
const handlePanelMouseEnd = (event: MouseEvent) => {
if (pickerMode.value) return
panelTouchCurrentY.value = getGestureClientY(event)
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
collapsePanel()
}
}
const handleViewRoute = () => {
if (!canViewRoute.value || !props.startPoint || !props.endPoint) return
emit('viewRoute', {
startPoint: props.startPoint,
endPoint: props.endPoint
})
}
const handlePrimaryAction = () => {
if (!canViewRoute.value) return
if (props.hasRoutePreview) {
emit('simulateGuide')
return
}
handleViewRoute()
}
return props.endPoint ? `前往 ${pointTitle(props.endPoint)}` : '正在准备目的地'
})
</script>
<style scoped lang="scss">
.route-planner-panel {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
padding: 10px 14px 14px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.97);
border: 1px solid #e5e6de;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
z-index: 1003;
}
.route-planner-panel.collapsed {
padding: 8px 10px 10px;
}
.panel-handle {
width: 38px;
height: 4px;
margin: 0 auto 10px;
background: #d8dbd2;
border-radius: 999px;
}
.panel-collapsed {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-collapsed-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.panel-expand {
flex: 0 0 auto;
height: 30px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
background: #151713;
border-radius: 8px;
}
.panel-expand-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-accent);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-title-group {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.panel-title {
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #151713;
}
.panel-summary {
font-size: 12px;
line-height: 18px;
color: #696962;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel-actions {
flex: 0 0 auto;
display: flex;
gap: 6px;
}
.panel-light-action {
flex: 0 0 auto;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f5f7f2;
border: 1px solid #e4e5df;
border-radius: 8px;
}
.panel-light-action-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.point-row {
position: relative;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 52px;
}
.point-card {
position: relative;
min-width: 0;
min-height: 52px;
padding: 8px 10px 8px 34px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
column-gap: 8px;
align-items: center;
box-sizing: border-box;
background: #f6f7f4;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.point-dot {
position: absolute;
left: 12px;
top: 21px;
width: 8px;
height: 8px;
border-radius: 50%;
}
.point-dot.start {
background: #1fbf6b;
}
.point-dot.end {
background: #ef5552;
}
.point-label {
font-size: 11px;
line-height: 15px;
color: #8b8b84;
}
.point-name {
margin-top: 0;
font-size: 14px;
line-height: 20px;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.point-name.placeholder {
color: #545861;
}
.point-meta {
grid-column: 2;
margin-top: -2px;
font-size: 11px;
line-height: 15px;
color: #696962;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swap-btn {
position: absolute;
right: 0;
top: 0;
width: 42px;
height: 112px;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d7dad3;
border-radius: 8px;
}
.swap-text {
font-size: 22px;
line-height: 24px;
font-weight: 500;
color: #151713;
}
.panel-status {
margin-top: 10px;
min-height: 20px;
}
.panel-status-text {
font-size: 12px;
line-height: 18px;
color: #696962;
}
.panel-status-text.error {
color: #b44b42;
}
.view-route-btn {
margin-top: 12px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: #151713;
border-radius: 8px;
}
.view-route-btn.disabled {
background: #d8dbd2;
}
.view-route-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: var(--museum-accent);
}
.view-route-btn.disabled .view-route-text {
color: #8b8b84;
}
.route-options {
margin-top: 12px;
padding: 10px;
background: #f9fafb;
border: 1px solid #ecede7;
border-radius: 8px;
}
.route-options-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.route-options-title {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #151713;
}
.route-options-note {
font-size: 11px;
line-height: 15px;
color: #8b8b84;
}
.route-option-list {
margin-top: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.route-option {
min-width: 0;
min-height: 56px;
padding: 8px 6px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.route-option.active {
border-color: #151713;
background: #ffffff;
}
.route-option.disabled {
opacity: 0.68;
}
.route-option-title {
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: #151713;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.route-option-meta {
font-size: 10px;
line-height: 14px;
color: #696962;
text-align: center;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.route-planner-panel { position: absolute; left: 12px; right: 12px; bottom: calc(env(safe-area-inset-bottom) + 24px); padding: 12px 14px 14px; box-sizing: border-box; background: rgba(255, 255, 255, 0.97); border: 1px solid #e5e6de; border-radius: 8px; box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12); z-index: 1003; }
.route-panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }.route-panel-copy { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 3px; }.route-panel-title { color: #151713; font-size: 17px; font-weight: 700; line-height: 24px; }.route-panel-subtitle { overflow: hidden; color: #696962; font-size: 12px; line-height: 17px; text-overflow: ellipsis; white-space: nowrap; }.route-panel-close { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 28px; height: 28px; background: #f5f7f2; border: 1px solid #e4e5df; border-radius: 8px; box-sizing: border-box; }.route-panel-close-text { color: #545861; font-size: 18px; line-height: 20px; }
.route-endpoint-row, .route-start-card { position: relative; display: flex; align-items: center; gap: 10px; margin-top: 12px; padding: 10px 12px 10px 34px; box-sizing: border-box; background: #f5f7fc; border: 1px solid #d9e0f2; border-radius: 8px; }.route-point-dot { position: absolute; left: 13px; width: 9px; height: 9px; border-radius: 50%; }.route-point-dot.start { background: #36a96c; }.route-point-dot.end { background: #d75b57; }.route-point-copy, .route-start-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }.route-point-name, .route-start-title { overflow: hidden; color: #1f2329; font-size: 14px; font-weight: 600; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }.route-point-meta, .route-start-desc { color: #696f7d; font-size: 12px; line-height: 17px; }.route-start-card { background: #f7f8f4; border-color: #e5e6de; }.route-start-title { font-weight: 600; }
.route-state-card { margin-top: 10px; padding: 10px 12px; background: #f7f8f4; border-radius: 8px; }.route-state-card.error { background: #fff4f1; }.route-state-text { color: #696962; font-size: 12px; line-height: 18px; }.route-state-card.error .route-state-text { color: #ad4a40; }.route-ready-card { margin-top: 10px; }.route-ready-summary { display: block; color: #1f2329; font-size: 15px; font-weight: 600; line-height: 21px; }.route-ready-note { display: block; margin-top: 4px; color: #697386; font-size: 12px; line-height: 17px; }.route-primary-action { height: 42px; margin-top: 10px; display: flex; align-items: center; justify-content: center; background: #1565c0; border-radius: 8px; }.route-primary-action-text { color: #ffffff; font-size: 14px; font-weight: 600; line-height: 19px; }
.route-confirm-mask { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; background: rgba(18, 27, 45, 0.3); z-index: 1010; }.route-confirm-card { width: min(300px, calc(100vw - 48px)); padding: 22px 20px 18px; box-sizing: border-box; background: #ffffff; border-radius: 8px; box-shadow: 0 18px 38px rgba(31, 42, 77, 0.24); }.route-confirm-title { display: block; color: #1f2329; font-size: 18px; font-weight: 700; line-height: 25px; text-align: center; }.route-confirm-message { display: block; margin-top: 8px; color: #4d5564; font-size: 15px; line-height: 22px; text-align: center; }.route-confirm-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 20px; }.route-confirm-action { height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 8px; box-sizing: border-box; }.route-confirm-action.secondary { background: #f5f7fb; border: 1px solid #d9e0f2; }.route-confirm-action.primary { background: #1565c0; border: 1px solid #1565c0; }.route-confirm-action-text { color: #1a237e; font-size: 14px; font-weight: 600; line-height: 19px; }.route-confirm-action.primary .route-confirm-action-text { color: #ffffff; }
</style>