クロスプラットフォーム開発のアプローチ
モバイルユーザー向けの政策情報アクセス手段として、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タグで強調表示