UniAppで構築するクロスプラットフォーム政策検索モバイルアプリケーション

クロスプラットフォーム開発のアプローチ

モバイルユーザー向けの政策情報アクセス手段として、UniAppを採用した開発を行いました。このフレームワークの利点は、単一のコードベースから複数プラットフォームへの出力が可能な点です。 | 出力先 | 特徴 | |---|---| | H5 | モバイルブラウザ対応 | | WeChatミニプログラム | WeChatエコシステム内での利用 | | Androidアプリ | ネイティブAPK生成 | | iOSアプリ | ネイティブIPA生成 | 開発言語はVue 3とTypeScriptを組み合わせ、script setup構文を活用しています。

プロジェクトのディレクトリ構成


policy-app/
├── src/
│   ├── views/                    # 画面コンポーネント
│   │   ├── Home.vue             # ホーム画面
│   │   ├── CategoryList.vue     # カテゴリ一覧
│   │   ├── PolicyList.vue       # 政策一覧
│   │   ├── SearchView.vue       # 検索画面
│   │   ├── PolicyDetail.vue     # 政策詳細
│   │   └── BookmarkView.vue     # ブックマーク
│   ├── services/                 # サービス層
│   │   ├── httpClient.ts        # HTTP通信ラッパー
│   │   ├── categoryService.ts   # カテゴリAPI
│   │   └── policyService.ts     # 政策API
│   ├── assets/                   # 静的リソース
│   │   └── logo.svg
│   ├── manifest.json            # アプリ設定
│   ├── pages.json               # ルーティング定義
│   ├── App.vue
│   ├── main.ts
│   └── styles.scss
├── package.json
└── vite.config.ts

ルーティング定義

UniAppではルート設定をpages.jsonで宣言的に管理します。

{
  "pages": [
    {
      "path": "views/Home",
      "style": {
        "navigationBarTitleText": "政策検索",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "views/CategoryList",
      "style": { "navigationBarTitleText": "カテゴリ選択" }
    },
    {
      "path": "views/PolicyList",
      "style": { "navigationBarTitleText": "政策一覧" }
    },
    {
      "path": "views/SearchView",
      "style": {
        "navigationBarTitleText": "検索",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "views/PolicyDetail",
      "style": { "navigationBarTitleText": "詳細情報" }
    },
    {
      "path": "views/BookmarkView",
      "style": { "navigationBarTitleText": "保存済み" }
    }
  ],
  "globalStyle": {
    "navigationBarTextStyle": "white",
    "navigationBarBackgroundColor": "#1a365d",
    "backgroundColor": "#f7fafc"
  }
}
navigationStyle: "custom"を指定することで、ナビゲーションバーを完全にカスタマイズ可能になります。

HTTP通信の実装

UniApp標準のuni.requestをPromiseベースのインターフェースでラップします。

const API_ENDPOINT = '/api'

interface RequestConfig {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  payload?: Record<string, unknown>
  headers?: Record<string, string>
}

interface ApiResponse<T> {
  code: number
  message: string
  data: T
  total?: number
}

export function fetchApi<T>(endpoint: string, config: RequestConfig = {}): Promise<ApiResponse<T>> {
  return new Promise((resolve, reject) => {
    uni.showLoading({ title: '読み込み中', mask: true })

    uni.request({
      url: API_ENDPOINT + endpoint,
      method: config.method || 'GET',
      data: config.payload,
      header: {
        'Content-Type': 'application/json',
        ...config.headers
      },
      success: (response) => {
        uni.hideLoading()
        const result = response.data as ApiResponse<T>
        if (response.statusCode === 200 && result.code === 200) {
          resolve(result)
        } else {
          uni.showToast({ title: result.message || 'エラー', icon: 'none' })
          reject(new Error(result.message))
        }
      },
      fail: (error) => {
        uni.hideLoading()
        uni.showToast({ title: '通信エラー', icon: 'none' })
        reject(error)
      }
    })
  })
}

export const httpGet = <T>(url: string, params?: Record<string, unknown>) =>
  fetchApi<T>(url, { method: 'GET', payload: params })

export const httpPost = <T>(url: string, body?: Record<string, unknown>) =>
  fetchApi<T>(url, { method: 'POST', payload: body })

サービス層の定義

カテゴリサービス


import { httpGet } from './httpClient'

export interface Category {
  typeId: string
  typeName: string
  policyCount: number
  children?: Category[]
}

export const categoryService = {
  fetchTree: () => httpGet<Category[]>('/category/tree'),
  fetchCount: () => httpGet<Record<string, number>>('/category/count')
}

政策サービス


import { httpGet, httpPost } from './httpClient'

export interface Policy {
  id: string
  name: string
  organ: string
  pubdata: string
  typeName: string
  document?: string
  state?: string
  theme?: string
  keyword?: string
  text?: string
  pdf?: string
}

export interface SearchQuery {
  keyword: string
  typeId?: string
  page: number
  size: number
}

export const policyService = {
  fetchRecent: (page = 1, size = 10) =>
    httpGet<Policy[]>('/policy/all', { page, size }),
  
  fetchByType: (typeId: string, page = 1, size = 10) =>
    httpGet<Policy[]>('/policy/list', { typeId, page, size }),
  
  fetchByTypeName: (typeName: string, page = 1, size = 10) =>
    httpGet<Policy[]>('/policy/listByTypeName', { typeName, page, size }),
  
  search: (query: SearchQuery) =>
    httpPost<Policy[]>('/policy/search', query),
  
  fetchDetail: (id: string) =>
    httpGet<Policy>(`/policy/detail/${id}`)
}

ホーム画面の実装

ホーム画面では、カテゴリグリッドと最新政策リストを表示します。

<template>
  <view class="home-container">
    <!-- カスタムヘッダー -->
    <view class="header-bar">
      <text class="header-title">政策検索システム</text>
      <view class="header-action" @click="navigateToSearch">
        <text class="search-icon">🔍</text>
      </view>
    </view>

    <!-- ブランドエリア -->
    <view class="brand-section">
      <image class="brand-logo" src="/assets/logo.svg" mode="aspectFit" />
      <text class="brand-text">地域政策データベース</text>
    </view>

    <!-- 検索入力欄 -->
    <view class="search-box" @click="navigateToSearch">
      <text class="search-hint">キーワードを入力...</text>
    </view>

    <!-- カテゴリセクション -->
    <view class="category-block">
      <text class="block-title">政策カテゴリ</text>
      <view class="category-grid">
        <view
          v-for="cat in displayCategories"
          :key="cat.typeId"
          class="category-tile"
          @click="openCategoryList(cat)"
        >
          <text class="tile-icon">{{ getIconForCategory(cat.typeName) }}</text>
          <text class="tile-name">{{ cat.typeName }}</text>
          <text class="tile-count">{{ cat.policyCount }}件</text>
        </view>
      </view>
    </view>

    <!-- 最新政策 -->
    <view class="policy-block">
      <view class="block-header">
        <text class="block-title">最新政策</text>
        <text class="block-more" @click="openCategoryPage">もっと見る ></text>
      </view>
      <view
        v-for="item in recentPolicies"
        :key="item.id"
        class="policy-row"
        @click="openDetail(item.id)"
      >
        <text class="row-title">{{ item.name }}</text>
        <view class="row-meta">
          <text class="meta-org">{{ item.organ }}</text>
          <text class="meta-date">{{ item.pubdata }}</text>
          <text class="meta-fav" @click.stop="handleBookmark(item)">
            {{ checkBookmarked(item.id) ? '❤️' : '🤍' }}
          </text>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { categoryService, type Category } from '@/services/categoryService'
import { policyService, type Policy } from '@/services/policyService'

const displayCategories = ref<Category[]>([])
const recentPolicies = ref<Policy[]>([])

onMounted(async () => {
  try {
    const [categories, policies] = await Promise.all([
      categoryService.fetchTree(),
      policyService.fetchRecent(1, 5)
    ])
    // 親カテゴリのみ抽出
    displayCategories.value = categories.data
      .filter(c => c.typeId.length === 4)
      .slice(0, 8)
    recentPolicies.value = policies.data || []
  } catch (err) {
    console.error('データ読み込みエラー:', err)
  }
})

function navigateToSearch() {
  uni.navigateTo({ url: '/views/SearchView' })
}

function openCategoryList(cat: Category) {
  uni.navigateTo({
    url: `/views/PolicyList?category=${encodeURIComponent(cat.typeName)}`
  })
}

function openDetail(id: string) {
  uni.navigateTo({ url: `/views/PolicyDetail?id=${id}` })
}

function openCategoryPage() {
  uni.switchTab({ url: '/views/CategoryList' })
}

function getIconForCategory(name: string): string {
  const iconMap: Record<string, string> = {
    '総合': '📋', '研究機関': '🏛️', '計画管理': '📊',
    '予算財務': '💰', '基礎研究': '🔬', '人材': '👥',
    '企業技術': '🏭', '農業科学': '🌾', '金融税制': '🏦'
  }
  return iconMap[name] || '📄'
}

function checkBookmarked(id: string): boolean {
  const stored = uni.getStorageSync('bookmarks') || '[]'
  return JSON.parse(stored).some((b: Policy) => b.id === id)
}

function handleBookmark(item: Policy) {
  let bookmarks: Policy[] = JSON.parse(uni.getStorageSync('bookmarks') || '[]')
  const existingIndex = bookmarks.findIndex(b => b.id === item.id)
  
  if (existingIndex > -1) {
    bookmarks.splice(existingIndex, 1)
    uni.showToast({ title: '削除しました', icon: 'none' })
  } else {
    bookmarks.unshift(item)
    uni.showToast({ title: '保存しました', icon: 'none' })
  }
  uni.setStorageSync('bookmarks', JSON.stringify(bookmarks))
}
</script>

検索画面の実装

検索履歴管理とリアルタイム検索結果表示を実装します。

<template>
  <view class="search-container">
    <!-- 検索ヘッダー -->
    <view class="search-header">
      <view class="input-wrapper">
        <text class="input-icon">🔍</text>
        <input
          v-model="searchTerm"
          class="search-input"
          placeholder="検索キーワード"
          confirm-type="search"
          @confirm="executeSearch"
          @input="debouncedSearch"
          focus
        />
        <text v-if="searchTerm" class="clear-icon" @click="clearInput">✕</text>
      </view>
      <text class="cancel-text" @click="goBack">キャンセル</text>
    </view>

    <!-- 初期状態:人気検索 + 履歴 -->
    <view v-if="!searchTerm && !results.length" class="initial-view">
      <view class="popular-section">
        <text class="section-label">🔥 人気検索</text>
        <view class="tag-cloud">
          <text
            v-for="(tag, i) in popularTags"
            :key="i"
            class="popular-tag"
            @click="searchWithTag(tag)"
          >{{ tag }}</text>
        </view>
      </view>

      <view v-if="historyList.length" class="history-section">
        <view class="section-header">
          <text class="section-label">🕒 検索履歴</text>
          <text class="clear-action" @click="clearAllHistory">クリア</text>
        </view>
        <view class="tag-cloud">
          <text
            v-for="(item, i) in historyList"
            :key="i"
            class="history-tag"
            @click="searchWithTag(item)"
          >{{ item }}</text>
        </view>
      </view>
    </view>

    <!-- 検索結果 -->
    <scroll-view
      v-if="searchTerm"
      class="result-scroll"
      scroll-y
      @scrolltolower="loadNextPage"
    >
      <view
        v-for="item in results"
        :key="item.id"
        class="result-row"
        @click="openDetail(item.id)"
      >
        <view class="result-title" v-html="highlightMatch(item.name)"></view>
        <view class="result-info">
          <text>{{ item.organ }}</text>
          <text>{{ item.pubdata }}</text>
          <text>{{ item.typeName }}</text>
        </view>
      </view>
      <view v-if="isLoading" class="loading-text">読み込み中...</view>
      <view v-if="!isLoading && endReached" class="end-text">— 終わり —</view>
    </scroll-view>
  </view>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { policyService, type Policy } from '@/services/policyService'

const searchTerm = ref('')
const results = ref<Policy[]>([])
const isLoading = ref(false)
const endReached = ref(false)
const currentPage = ref(1)
const historyList = ref<string[]>([])

const popularTags = [
  'ハイテク企業', '中小企業', '技術革新', '成果転化',
  '科学人材', '研究開発費', '科技金融', '特許'
]

onMounted(() => {
  historyList.value = JSON.parse(uni.getStorageSync('search_history') || '[]')
})

// デバウンス処理
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSearch() {
  if (debounceTimer) clearTimeout(debounceTimer)
  if (!searchTerm.value.trim()) {
    results.value = []
    endReached.value = false
    return
  }
  debounceTimer = setTimeout(() => executeSearch(), 300)
}

async function executeSearch() {
  if (!searchTerm.value.trim()) return
  isLoading.value = true
  endReached.value = false
  currentPage.value = 1

  // 履歴保存
  const filtered = historyList.value.filter(h => h !== searchTerm.value)
  filtered.unshift(searchTerm.value)
  historyList.value = filtered.slice(0, 10)
  uni.setStorageSync('search_history', JSON.stringify(historyList.value))

  try {
    const response = await policyService.search({
      keyword: searchTerm.value,
      page: 1,
      size: 20
    })
    results.value = response.data || []
    endReached.value = results.value.length >= (response.total || 0)
  } finally {
    isLoading.value = false
  }
}

async function loadNextPage() {
  if (isLoading.value || endReached.value) return
  isLoading.value = true
  currentPage.value++
  
  try {
    const response = await policyService.search({
      keyword: searchTerm.value,
      page: currentPage.value,
      size: 20
    })
    results.value = [...results.value, ...(response.data || [])]
    endReached.value = results.value.length >= (response.total || 0)
  } finally {
    isLoading.value = false
  }
}

function searchWithTag(tag: string) {
  searchTerm.value = tag
  executeSearch()
}

function clearInput() {
  searchTerm.value = ''
  results.value = []
}

function clearAllHistory() {
  historyList.value = []
  uni.removeStorageSync('search_history')
}

function highlightMatch(text: string): string {
  if (!searchTerm.value || !text) return text
  const regex = new RegExp(`(${searchTerm.value})`, 'gi')
  return text.replace(regex, '<span class="highlight">$1</span>')
}

function openDetail(id: string) {
  uni.navigateTo({ url: `/views/PolicyDetail?id=${id}` })
}
function goBack() { uni.navigateBack() }
</script>

詳細画面の実装


<template>
  <view class="detail-container" v-if="policyData.name">
    <!-- 基本情報カード -->
    <view class="info-card">
      <text class="policy-name">{{ policyData.name }}</text>
      <view class="meta-grid">
        <view class="meta-row">
          <text class="meta-key">文書番号</text>
          <text class="meta-val">{{ policyData.document || '-' }}</text>
        </view>
        <view class="meta-row">
          <text class="meta-key">発行機関</text>
          <text class="meta-val">{{ policyData.organ || '-' }}</text>
        </view>
        <view class="meta-row">
          <text class="meta-key">公開日</text>
          <text class="meta-val">{{ policyData.pubdata || '-' }}</text>
        </view>
        <view class="meta-row">
          <text class="meta-key">カテゴリ</text>
          <text class="meta-val">{{ policyData.typeName || '-' }}</text>
        </view>
        <view class="meta-row">
          <text class="meta-key">状態</text>
          <text class="meta-val" :class="{ 'active': policyData.state === '有効' }">
            {{ policyData.state || '-' }}
          </text>
        </view>
      </view>
    </view>

    <!-- テーマタグ -->
    <view v-if="policyData.theme" class="tag-block">
      <text class="block-label">テーマ</text>
      <view class="tag-list">
        <text v-for="(t, i) in policyData.theme.split(',')" :key="i" class="theme-chip">
          {{ t.trim() }}
        </text>
      </view>
    </view>

    <!-- キーワード -->
    <view v-if="policyData.keyword" class="tag-block">
      <text class="block-label">キーワード</text>
      <view class="tag-list">
        <text v-for="(k, i) in policyData.keyword.split(',')" :key="i" class="keyword-chip">
          {{ k.trim() }}
        </text>
      </view>
    </view>

    <!-- 本文 -->
    <view class="content-block">
      <text class="block-label">本文</text>
      <rich-text class="policy-text" :nodes="policyData.text"></rich-text>
    </view>

    <!-- アクションバー -->
    <view class="action-bar">
      <view class="action-item" @click="handleBookmarkToggle">
        <text>{{ isBookmarked ? '❤️ 保存済み' : '🤍 保存' }}</text>
      </view>
      <view v-if="policyData.pdf" class="action-item primary" @click="openPdf">
        <text>📥 PDFを開く</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { policyService, type Policy } from '@/services/policyService'

const currentPageInstance = getCurrentPages().pop()
const policyData = ref<Policy>({} as Policy)

onMounted(() => {
  const policyId = currentPageInstance?.options?.id
  if (policyId) loadPolicyData(policyId)
})

async function loadPolicyData(id: string) {
  try {
    const response = await policyService.fetchDetail(id)
    policyData.value = response.data || {} as Policy
  } catch (err) {
    console.error('詳細読み込みエラー:', err)
  }
}

const isBookmarked = computed(() => {
  const stored = uni.getStorageSync('bookmarks') || '[]'
  return JSON.parse(stored).some((b: Policy) => b.id === policyData.value.id)
})

function handleBookmarkToggle() {
  let bookmarks: Policy[] = JSON.parse(uni.getStorageSync('bookmarks') || '[]')
  const existingIdx = bookmarks.findIndex(b => b.id === policyData.value.id)
  
  if (existingIdx > -1) {
    bookmarks.splice(existingIdx, 1)
    uni.showToast({ title: '削除しました', icon: 'none' })
  } else {
    bookmarks.unshift({
      id: policyData.value.id,
      name: policyData.value.name,
      organ: policyData.value.organ,
      pubdata: policyData.value.pubdata,
      typeName: policyData.value.typeName
    })
    uni.showToast({ title: '保存しました', icon: 'none' })
  }
  uni.setStorageSync('bookmarks', JSON.stringify(bookmarks))
}

function openPdf() {
  if (!policyData.value.pdf) return
  uni.showLoading({ title: '開いています...' })
  uni.downloadFile({
    url: policyData.value.pdf,
    success: (res) => {
      uni.hideLoading()
      uni.openDocument({ filePath: res.tempFilePath })
    },
    fail: () => {
      uni.hideLoading()
      uni.showToast({ title: '失敗しました', icon: 'none' })
    }
  })
}
</script>

ローカルストレージによるブックマーク管理


const STORAGE_KEY = 'bookmarks'

interface BookmarkManager {
  add(policy: Policy): void
  remove(id: string): void
  exists(id: string): boolean
  getAll(): Policy[]
}

export const bookmarkManager: BookmarkManager = {
  add(policy) {
    const bookmarks = this.getAll()
    if (!bookmarks.some(b => b.id === policy.id)) {
      bookmarks.unshift({ ...policy, savedAt: Date.now() })
      uni.setStorageSync(STORAGE_KEY, JSON.stringify(bookmarks))
      uni.showToast({ title: '保存完了', icon: 'success' })
    } else {
      uni.showToast({ title: '既に保存済み', icon: 'none' })
    }
  },

  remove(id) {
    const bookmarks = this.getAll().filter(b => b.id !== id)
    uni.setStorageSync(STORAGE_KEY, JSON.stringify(bookmarks))
  },

  exists(id) {
    return this.getAll().some(b => b.id === id)
  },

  getAll() {
    return JSON.parse(uni.getStorageSync(STORAGE_KEY) || '[]')
  }
}
ローカルストレージは、デバイス内での完結する機能に適しています。クロスデバイス同期が必要な場合は、バックエンドでのユーザー認証とデータ同期の実装が必要です。

H5開発環境のプロキシ設定


{
  "h5": {
    "devServer": {
      "port": 8082,
      "disableHostCheck": true,
      "proxy": {
        "/api": {
          "target": "http://localhost:8080",
          "changeOrigin": true,
          "secure": false
        }
      }
    }
  }
}
WeChatミニプログラムの場合はmanifest.jsonでドメイン設定を行います。

{
  "mp-weixin": {
    "appid": "wx0000000000000000",
    "setting": {
      "urlCheck": false
    }
  }
}

実装のポイント

  • HTTP通信ラッパー: uni.requestをPromise化し、async/awaitパターンで記述可能に
  • ローカルストレージ活用: uni.getStorageSync/setStorageSyncで検索履歴とブックマークを管理
  • デバウンス処理: 検索入力時の過剰なAPI呼び出しを抑制
  • 無限スクロール: scrolltolowerイベントでページネーション実装
  • カスタムナビゲーション: navigationStyle: customでUIの完全制御
  • キーワードハイライト: 正規表現でマッチ部分をstrongタグで強調表示

タグ: UniApp vue.js TypeScript クロスプラットフォーム開発 ミニプログラム

9月2日 17:12 投稿