Files
frontend-miniapp/docs/superpowers/plans/2026-06-25-sgs-sdk-data-switch-viewmodels.md
2026-06-26 04:25:08 +08:00

25 KiB
Raw Blame History

SGS SDK Data Switch ViewModel Convergence Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Complete the SGS SDK data-source switch by keeping static/api/sdk mode behavior intact while moving remaining page-level mapping into the existing adapter/view-model middle layers.

Architecture: Keep the existing split: providers/adapters map source payloads to domain models, repositories/use cases expose business operations, and src/view-models/* maps domain models into presentation props. The implementation does not rewrite the SGS provider, repository, renderer, or pages; it only closes remaining mapping leaks and adds observable mode diagnostics.

Tech Stack: uni-app H5, Vue 3 <script setup>, TypeScript, vue-tsc, ESLint, existing GuideUseCase, GuideRepository, GuideMapShell, ThreeMap, and SgsMapRenderer.


Current State Summary

Already present and should be preserved:

VITE_DATA_SOURCE_MODE=static -> StaticGuideRepository + ThreeMap
VITE_DATA_SOURCE_MODE=api    -> SgsSdkGuideRepository + ThreeMap
VITE_DATA_SOURCE_MODE=sdk    -> SgsSdkGuideRepository + SgsMapRenderer

Evidence from source audit:

  • src/config/dataSource.ts supports static | api | sdk and centralizes SGS API/SDK config.
  • src/repositories/createGuideRepository.ts selects SgsSdkGuideRepository for api and sdk.
  • src/components/navigation/GuideMapShell.vue selects SgsMapRenderer only when isSgsSdkMode() is true.
  • src/data/adapters/sgsSdkGuideAdapter.ts maps SGS payloads to MuseumFloor, MuseumPoi, diagnostics, and route readiness.
  • src/view-models/guideViewModels.ts and src/view-models/explainViewModels.ts already exist, but src/pages/index/index.vue still owns some route/explain/card mapping.

File Structure

Modify these files only:

  • src/view-models/guideViewModels.ts

    • Add guide presentation view models for selected POI cards and route-point picker options.
    • Add conversion helpers so pages do not build route picker props from domain models inline.
  • src/view-models/explainViewModels.ts

    • Move explain hall selector item mapping from src/pages/index/index.vue into the view-model layer.
  • src/components/navigation/RoutePointPicker.vue

    • Consume RoutePointOptionViewModel from src/view-models/guideViewModels.ts and re-export it as RoutePointOption for existing imports.
  • src/components/navigation/RoutePlannerPanel.vue

    • Consume the same RoutePointOption alias exported by RoutePointPicker.vue; no behavior change.
  • src/components/explain/ExplainHallSelect.vue

    • Consume ExplainHallSelectItem from src/view-models/explainViewModels.ts instead of defining presentation item shape locally.
  • src/pages/index/index.vue

    • Replace inline mapping helpers with imports from view-models.
    • Keep page state and user interactions in the page.
    • Keep the existing GuideUseCase/guideRouteUseCase calls.
  • src/config/dataSource.ts

    • Add a small diagnostics helper that reports selected data source and renderer mode.
  • src/components/navigation/GuideMapShell.vue

    • Log the mode diagnostic once in dev/H5 so QA can confirm static/api/sdk mode without inspecting code.

Do not modify:

  • src/data/providers/sgsSdkApiProvider.ts
  • src/data/adapters/sgsSdkGuideAdapter.ts
  • src/repositories/GuideRepository.ts
  • src/repositories/createGuideRepository.ts
  • src/components/map/SgsMapRenderer.vue
  • src/services/sgs/SgsMapService.ts
  • src/services/sgs/SgsMapScriptLoader.ts

Task 1: Add Guide ViewModel Mappers

Files:

  • Modify: src/view-models/guideViewModels.ts

  • Step 1: Add imports for route target and renderer POI types

At the top of src/view-models/guideViewModels.ts, replace the existing imports with:

import type {
  GuideLocationPreview,
  GuideLocationPreviewPlan,
  GuideRouteTarget,
  MuseumFloor,
  MuseumPoi
} from '@/domain/museum'
import type {
  GuideRenderPoi
} from '@/domain/guideModel'

Expected: the existing GuideLocationPreview, GuideLocationPreviewPlan, and MuseumPoi usages still compile, and the file can now define route picker and selected POI card mappers.

  • Step 2: Add route point and selected POI card view-model interfaces

After TargetPoiFocusRequestViewModel, add:

export interface RoutePointOptionViewModel {
  poiId: string
  name: string
  floorId: string
  floorLabel: string
  categoryLabel?: string
}

export interface SelectedGuidePoiCardViewModel {
  id: string
  name: string
  subtitle: string
  collapsedSubtitle: string
}

Expected: these types become the presentation contract for RoutePointPicker, RoutePlannerPanel, and the selected POI card in src/pages/index/index.vue.

  • Step 3: Add mappers for route targets and selected POI cards

After toTargetFocusRequestViewModel, add:

export const toRoutePointOptionViewModel = (target: GuideRouteTarget): RoutePointOptionViewModel => ({
  poiId: target.poiId,
  name: target.name,
  floorId: target.floorId,
  floorLabel: target.floorLabel,
  categoryLabel: target.categoryLabel
})

export const toSelectedGuidePoiCardViewModel = (
  poi: GuideRenderPoi,
  floors: MuseumFloor[]
): SelectedGuidePoiCardViewModel => {
  const floor = floors.find((item) => item.id === poi.floorId)
  const floorLabel = floor?.label || poi.floorId
  const categoryLabel = poi.primaryCategoryZh || '位置预览'

  return {
    id: poi.id,
    name: poi.name,
    subtitle: `${floorLabel} · ${categoryLabel}`,
    collapsedSubtitle: '位置预览 · 点按展开操作'
  }
}

Expected: src/pages/index/index.vue no longer needs to know how to format selected POI subtitles or route-point picker items.

  • Step 4: Run type-check for this isolated change

Run:

pnpm type-check

Expected: PASS. If it fails because imports are unused, continue to Task 2 and Task 5 before re-running.

  • Step 5: Commit if authorized

Do not commit unless the user explicitly authorizes commits in this session. If authorized, run:

git add src/view-models/guideViewModels.ts
git commit -m @'
refactor: add guide presentation view models

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Expected: one commit containing only src/view-models/guideViewModels.ts.


Task 2: Move Route Picker Presentation Type To ViewModel Layer

Files:

  • Modify: src/components/navigation/RoutePointPicker.vue

  • Modify: src/components/navigation/RoutePlannerPanel.vue

  • Step 1: Update RoutePointPicker type source

In src/components/navigation/RoutePointPicker.vue, replace the local RoutePointOption interface with this import/export block immediately after import { computed, ref, watch } from 'vue':

import type {
  RoutePointOptionViewModel
} from '@/view-models/guideViewModels'

export type RoutePointOption = RoutePointOptionViewModel

Remove this old block from the same file:

export interface RoutePointOption {
  poiId: string
  name: string
  floorId: string
  floorLabel: string
  categoryLabel?: string
}

Expected: RoutePointPicker still accepts the same prop shape, but the contract is owned by src/view-models/guideViewModels.ts.

  • Step 2: Confirm RoutePlannerPanel remains compatible

Leave this import in src/components/navigation/RoutePlannerPanel.vue unchanged:

import RoutePointPicker, {
  type RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'

Expected: because RoutePointPicker.vue re-exports the view-model alias, no RoutePlannerPanel behavior changes.

  • Step 3: Run type-check for picker type migration

Run:

pnpm type-check

Expected: PASS or only failures from later not-yet-updated page imports if Task 5 has not been applied.

  • Step 4: Commit if authorized

Do not commit unless the user explicitly authorizes commits in this session. If authorized, run:

git add src/components/navigation/RoutePointPicker.vue src/components/navigation/RoutePlannerPanel.vue
git commit -m @'
refactor: route picker uses guide view model type

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Expected: one commit containing the route picker type ownership change.


Task 3: Add Explain Hall Selector ViewModel Mapper

Files:

  • Modify: src/view-models/explainViewModels.ts

  • Modify: src/components/explain/ExplainHallSelect.vue

  • Step 1: Add ExplainHallSelectItem interface to explain view models

In src/view-models/explainViewModels.ts, after HallDetailViewModel, add:

export interface ExplainHallSelectItem {
  id: string
  name: string
  floorLabel?: string
  image?: string
  explainCount: number
  searchText: string
}

Expected: the hall selector item presentation contract is no longer owned by the component file.

  • Step 2: Add hall-selector mapping helpers

In src/view-models/explainViewModels.ts, after toExplainSearchResultViewModel, add:

const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()

export const toExplainHallSelectItems = (
  halls: MuseumHall[],
  exhibits: MuseumExhibit[]
): ExplainHallSelectItem[] => {
  const countsByHallId = new Map<string, number>()
  const countsByHallName = new Map<string, number>()
  const keywordsByHallId = new Map<string, Set<string>>()
  const keywordsByHallName = new Map<string, Set<string>>()

  exhibits.forEach((exhibit) => {
    const keywordParts = [
      exhibit.name,
      exhibit.hallName,
      exhibit.floorLabel,
      exhibit.description,
      exhibit.guideTitle,
      exhibit.guideText,
      ...(exhibit.tags || [])
    ].filter(Boolean) as string[]

    if (exhibit.hallId) {
      countsByHallId.set(exhibit.hallId, (countsByHallId.get(exhibit.hallId) || 0) + 1)
      const keywords = keywordsByHallId.get(exhibit.hallId) || new Set<string>()
      keywordParts.forEach((part) => keywords.add(part))
      keywordsByHallId.set(exhibit.hallId, keywords)
    }

    if (exhibit.hallName) {
      countsByHallName.set(exhibit.hallName, (countsByHallName.get(exhibit.hallName) || 0) + 1)
      const keywords = keywordsByHallName.get(exhibit.hallName) || new Set<string>()
      keywordParts.forEach((part) => keywords.add(part))
      keywordsByHallName.set(exhibit.hallName, keywords)
    }
  })

  return halls.map((hall) => {
    const explainCount = countsByHallId.get(hall.id)
      || countsByHallName.get(hall.name)
      || hall.exhibitCount
      || 0
    const keywordParts = [
      hall.name,
      hall.floorLabel,
      hall.description,
      hall.area,
      ...Array.from(keywordsByHallId.get(hall.id) || []),
      ...Array.from(keywordsByHallName.get(hall.name) || [])
    ].filter(Boolean) as string[]

    return {
      id: hall.id,
      name: hall.name,
      floorLabel: hall.floorLabel,
      image: hall.image,
      explainCount,
      searchText: normalizeExplainKeyword(keywordParts.join(' '))
    }
  })
}

Expected: the exact logic currently in src/pages/index/index.vue is moved without behavior changes.

  • Step 3: Update ExplainHallSelect component type ownership

In src/components/explain/ExplainHallSelect.vue, replace the local ExplainHallSelectItem interface with this import/export block after import { computed } from 'vue':

import type {
  ExplainHallSelectItem as ExplainHallSelectItemViewModel
} from '@/view-models/explainViewModels'

export type ExplainHallSelectItem = ExplainHallSelectItemViewModel

Remove this old interface from the same file:

export interface ExplainHallSelectItem {
  id: string
  name: string
  floorLabel?: string
  image?: string
  explainCount: number
  searchText: string
}

Expected: component props retain the same shape, but the presentation contract is defined in src/view-models/explainViewModels.ts.

  • Step 4: Run type-check for explain view-model migration

Run:

pnpm type-check

Expected: PASS or only failures from src/pages/index/index.vue until Task 5 is applied.

  • Step 5: Commit if authorized

Do not commit unless the user explicitly authorizes commits in this session. If authorized, run:

git add src/view-models/explainViewModels.ts src/components/explain/ExplainHallSelect.vue
git commit -m @'
refactor: move explain hall selector mapping to view models

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Expected: one commit containing explain hall selector view-model ownership.


Task 4: Add Data-Source Mode Diagnostics

Files:

  • Modify: src/config/dataSource.ts

  • Modify: src/components/navigation/GuideMapShell.vue

  • Step 1: Add diagnostics helper to dataSource config

At the end of src/config/dataSource.ts, after getSgsMapTargetOrigin, add:

export const getGuideDataSourceDiagnostics = () => {
  const usesSgsRepository = dataSourceConfig.mode === 'api' || dataSourceConfig.mode === 'sdk'
  const usesSgsRenderer = dataSourceConfig.mode === 'sdk'

  return {
    mode: dataSourceConfig.mode,
    guideRepository: usesSgsRepository ? 'SgsSdkGuideRepository' : 'StaticGuideRepository',
    mapRenderer: usesSgsRenderer ? 'SgsMapRenderer' : 'ThreeMap',
    apiBaseUrl: usesSgsRepository ? dataSourceConfig.sgsApiBaseUrl : '',
    mapId: usesSgsRepository ? dataSourceConfig.sgsMapId : '',
    sdkScriptUrl: usesSgsRenderer ? dataSourceConfig.sgsSdkScriptUrl : '',
    sdkEngineUrl: usesSgsRenderer ? dataSourceConfig.sgsMapEngineUrl : ''
  }
}

Expected: diagnostics are centralized with mode parsing and do not read raw env vars in page/component code.

  • Step 2: Import diagnostics helper in GuideMapShell

In src/components/navigation/GuideMapShell.vue, change the H5 config import from:

import {
  isSgsSdkMode
} from '@/config/dataSource'

to:

import {
  getGuideDataSourceDiagnostics,
  isSgsSdkMode
} from '@/config/dataSource'

Also change the Vue import from:

import { computed, ref } from 'vue'

to:

import { computed, onMounted, ref } from 'vue'

Expected: GuideMapShell can log diagnostics in H5 without changing mode-selection behavior.

  • Step 3: Log selected mode once in dev H5

In src/components/navigation/GuideMapShell.vue, after const useSgsMapRenderer = computed(() => isSgsSdkMode()), add:

onMounted(() => {
  if (!import.meta.env.DEV) return

  const diagnostics = getGuideDataSourceDiagnostics()
  console.info('[guide-data-source]', diagnostics)
})

Expected in dev console:

[guide-data-source] { mode: 'sdk', guideRepository: 'SgsSdkGuideRepository', mapRenderer: 'SgsMapRenderer', ... }

Expected in production build: no diagnostic log.

  • Step 4: Run type-check for diagnostics change

Run:

pnpm type-check

Expected: PASS or only failures from src/pages/index/index.vue until Task 5 is applied.

  • Step 5: Commit if authorized

Do not commit unless the user explicitly authorizes commits in this session. If authorized, run:

git add src/config/dataSource.ts src/components/navigation/GuideMapShell.vue
git commit -m @'
feat: expose guide data source diagnostics

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Expected: one commit containing diagnostics only.


Task 5: Replace Remaining Page-Level Mapping In Index Page

Files:

  • Modify: src/pages/index/index.vue

  • Step 1: Update imports

In src/pages/index/index.vue, remove this import block:

import type {
  RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
import ExplainHallSelect, {
  type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue'

Replace it with:

import type {
  RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
import ExplainHallSelect from '@/components/explain/ExplainHallSelect.vue'

Then replace the existing domain import:

import type {
  GuideRouteResult,
  GuideRouteTarget,
  MuseumExhibit,
  MuseumFloor
} from '@/domain/museum'

with:

import type {
  GuideRouteResult,
  MuseumFloor
} from '@/domain/museum'

Finally add this view-model import near the other app imports:

import {
  toExplainHallSelectItems,
  type ExplainHallSelectItem
} from '@/view-models/explainViewModels'
import {
  toRoutePointOptionViewModel,
  toSelectedGuidePoiCardViewModel
} from '@/view-models/guideViewModels'

Expected: page no longer imports GuideRouteTarget or ExplainHallSelectItem from presentation components.

  • Step 2: Replace selected POI subtitle computed with card view model

In src/pages/index/index.vue, replace this computed block:

const selectedGuidePoiSubtitle = computed(() => {
  const poi = selectedGuidePoi.value
  if (!poi) return ''
  const floor = guideFloors.value.find((item) => item.id === poi.floorId)
  const floorLabel = floor?.label || poi.floorId
  return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
})

with:

const selectedGuidePoiCard = computed(() => (
  selectedGuidePoi.value
    ? toSelectedGuidePoiCardViewModel(selectedGuidePoi.value, guideFloors.value)
    : null
))

Expected: page still owns selected state, but no longer formats selected POI display text inline.

  • Step 3: Update selected POI card template bindings

In the selected POI card template near the top of src/pages/index/index.vue, replace:

<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">位置预览 · 点按展开操作</text>

with:

<text class="poi-card-title">{{ selectedGuidePoiCard?.name }}</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiCard?.collapsedSubtitle }}</text>

Also replace:

<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiSubtitle }}</text>

with:

<text class="poi-card-title">{{ selectedGuidePoiCard?.name }}</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiCard?.subtitle }}</text>

Expected: user-facing text remains identical.

  • Step 4: Remove inline route point mapper

Delete this function from src/pages/index/index.vue:

const toRoutePointOption = (target: GuideRouteTarget): RoutePointOption => ({
  poiId: target.poiId,
  name: target.name,
  floorId: target.floorId,
  floorLabel: target.floorLabel,
  categoryLabel: target.categoryLabel
})

Expected: route-point option mapping comes from src/view-models/guideViewModels.ts.

  • Step 5: Use guide view-model route mapper

In src/pages/index/index.vue, replace:

const options = targets.map(toRoutePointOption)

with:

const options: RoutePointOption[] = targets.map(toRoutePointOptionViewModel)

Expected: route planner point list remains unchanged.

  • Step 6: Remove inline explain hall mapper helpers

Delete these functions from src/pages/index/index.vue:

const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()

const buildExplainHallItems = (
  halls: Awaited<ReturnType<typeof explainUseCase.listHalls>>,
  exhibits: MuseumExhibit[]
): ExplainHallSelectItem[] => {
  const countsByHallId = new Map<string, number>()
  const countsByHallName = new Map<string, number>()
  const keywordsByHallId = new Map<string, Set<string>>()
  const keywordsByHallName = new Map<string, Set<string>>()

  exhibits.forEach((exhibit) => {
    const keywordParts = [
      exhibit.name,
      exhibit.hallName,
      exhibit.floorLabel,
      exhibit.description,
      exhibit.guideTitle,
      exhibit.guideText,
      ...(exhibit.tags || [])
    ].filter(Boolean) as string[]

    if (exhibit.hallId) {
      countsByHallId.set(exhibit.hallId, (countsByHallId.get(exhibit.hallId) || 0) + 1)
      const keywords = keywordsByHallId.get(exhibit.hallId) || new Set<string>()
      keywordParts.forEach((part) => keywords.add(part))
      keywordsByHallId.set(exhibit.hallId, keywords)
    }

    if (exhibit.hallName) {
      countsByHallName.set(exhibit.hallName, (countsByHallName.get(exhibit.hallName) || 0) + 1)
      const keywords = keywordsByHallName.get(exhibit.hallName) || new Set<string>()
      keywordParts.forEach((part) => keywords.add(part))
      keywordsByHallName.set(exhibit.hallName, keywords)
    }
  })

  return halls.map((hall) => {
    const explainCount = countsByHallId.get(hall.id)
      || countsByHallName.get(hall.name)
      || hall.exhibitCount
      || 0
    const keywordParts = [
      hall.name,
      hall.floorLabel,
      hall.description,
      hall.area,
      ...Array.from(keywordsByHallId.get(hall.id) || []),
      ...Array.from(keywordsByHallName.get(hall.name) || [])
    ].filter(Boolean) as string[]

    return {
      id: hall.id,
      name: hall.name,
      floorLabel: hall.floorLabel,
      image: hall.image,
      explainCount,
      searchText: normalizeExplainKeyword(keywordParts.join(' '))
    }
  })
}

Expected: src/pages/index/index.vue no longer owns explain hall card mapping.

  • Step 7: Use explain view-model hall mapper

In loadExplainExhibits, replace:

explainHallItems.value = buildExplainHallItems(halls, exhibits)

with:

explainHallItems.value = toExplainHallSelectItems(halls, exhibits)

Expected: explain hall selector still shows the same halls and counts.

  • Step 8: Run type-check after index page migration

Run:

pnpm type-check

Expected: PASS.

  • Step 9: Commit if authorized

Do not commit unless the user explicitly authorizes commits in this session. If authorized, run:

git add src/pages/index/index.vue
git commit -m @'
refactor: use view models on guide index page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Expected: one commit containing only index page mapping removal.


Task 6: Verify Data-Source Switch Modes

Files:

  • No source edits expected.

  • Step 1: Verify no forbidden page imports exist

Run these searches:

Select-String -Path "src/pages/**/*.vue" -Pattern "SGSMapSDK|SgsMapService|sgsSdkApiProvider|/app-api/gis/sdk" -CaseSensitive
Select-String -Path "src/components/**/*.vue" -Pattern "sgsSdkApiProvider|/app-api/gis/sdk" -CaseSensitive

Expected: no matches in pages. Component matches should not include generic UI components; SgsMapRenderer.vue may import SgsMapService, which is allowed because it is the SDK renderer boundary.

  • Step 2: Verify mode switch source code

Run:

Select-String -Path "src/repositories/createGuideRepository.ts","src/components/navigation/GuideMapShell.vue","src/config/dataSource.ts" -Pattern "dataSourceConfig.mode|isSgsSdkMode|SgsSdkGuideRepository|StaticGuideRepository|SgsMapRenderer|ThreeMap" -CaseSensitive

Expected evidence:

createGuideRepository.ts: api/sdk -> SgsSdkGuideRepository, static -> StaticGuideRepository
GuideMapShell.vue: sdk -> SgsMapRenderer, otherwise -> ThreeMap
config/dataSource.ts: mode supports static/api/sdk
  • Step 3: Run type-check

Run:

pnpm type-check

Expected: PASS.

  • Step 4: Run lint because shell/page/view-model files changed

Run:

pnpm lint

Expected: PASS. If warnings appear but exit code is 0, record them as debt in the final report.

  • Step 5: Run H5 build because shell/page UI files changed

This command writes dist.

Run:

pnpm build:h5

Expected: PASS and dist/build/h5 or the projects configured H5 output is updated.

  • Step 6: Commit verification-related source edits if authorized

If Task 6 made no source edits, do not commit. If a small verification fix was required and the user has authorized commits, run:

git add src
git commit -m @'
chore: verify guide data source switching

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Expected: commit only source fixes made during verification.


Acceptance Criteria

  • VITE_DATA_SOURCE_MODE=static continues to use StaticGuideRepository and ThreeMap.
  • VITE_DATA_SOURCE_MODE=api continues to use SgsSdkGuideRepository and ThreeMap.
  • VITE_DATA_SOURCE_MODE=sdk continues to use SgsSdkGuideRepository and SgsMapRenderer.
  • Pages and ordinary presentation components do not directly import SGSMapSDK, SgsMapService, sgsSdkApiProvider, or raw /app-api/gis/sdk/* URLs.
  • Remaining route picker, selected POI card, and explain hall selector mapping is owned by src/view-models/* instead of src/pages/index/index.vue.
  • Route readiness gate remains unchanged; no user-facing copy claims certified route navigation.
  • pnpm type-check passes.
  • pnpm lint passes.
  • pnpm build:h5 passes.

Self-Review

  • Spec coverage: plan covers data-source switching, renderer switching preservation, middle-layer/view-model convergence, forbidden direct imports, diagnostics, and quality gates.
  • Placeholder scan: no TBD/TODO/fill-in-later instructions remain; all code snippets are concrete.
  • Type consistency: RoutePointOptionViewModel is aliased as RoutePointOption, ExplainHallSelectItem is exported from the view-model layer and aliased by the component, and the page imports the mapper names defined in the tasks.