---
url: /column-hexazen/08-ui-and-i18n.md
description: 倒角邊框、聲景混音器、還有不用 vue-i18n 的輕量國際化方案。੭ ˙ᗜ˙ )੭
---

![hexazen-cover](/hexazen-cover.webp){.cover}

# HexaZen - EP08：UI 設計與國際化

3D 場景和音效引擎都搞定了，最後要讓整個應用的 UI 既好看又好用。

HexaZen 跑在 VitePress 的 whyframe iframe 裡，所以 `vue-i18n` 無法正常運作。

沒關係，那就自己做一個。%ᕕ( ﾟ ∀。)ᕗ%

## 倒角邊框元件

HexaZen 的 UI 風格不用圓角，而是用「倒角（chamfer）」，像寶石一樣的多邊形切割。

`content\aquarium\hexazen\components\chamfer-border-card.vue`

```vue
<template>
  <div class="chamfer-4 p-1" :style="frameStyle">
    <div class="bg-white chamfer-3.5 p-3" :class="props.contentClass">
      <slot />
    </div>
  </div>
</template>

<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed } from 'vue'

interface Props {
  contentClass?: string;
  borderSize?: string;
  borderColor?: string;
}
const props = withDefaults(defineProps<Props>(), {
  contentClass: '',
  borderSize: '4px',
  borderColor: '#e5e7eb',
})

const frameStyle = computed<CSSProperties>(() => ({
  padding: props.borderSize,
  backgroundColor: props.borderColor,
}))
</script>
```

外層一個帶色的 chamfer 框，內層一個白色的 chamfer 框，用 padding 差異製造邊框效果。

### 倒角按鈕

按鈕也用 `clip-path` 做出八邊形倒角：

```typescript
const style = computed<CSSProperties>(() => {
  const size = height.value / 2
  const size2 = (size / 3) * 2

  return {
    paddingInline: `${size * 1.1}px`,
    clipPath: `polygon(
      ${size2}px 0,
      calc(100% - ${size2}px) 0,
      100% ${size}px,
      100% calc(100% - ${size}px),
      calc(100% - ${size2}px) 100%,
      ${size2}px 100%,
      0 calc(100% - ${size}px),
      0 ${size}px
    )`,
  }
})
```

用 `useElementSize` 動態取得按鈕高度，讓倒角比例自動適配不同大小的按鈕。%( •̀ ω •́ )✧%

## 聲景混音器

每個音效都可以獨立調整音量，透過 Modal 呈現：

```vue
<template>
  <div class="space-y-4 p-2">
    <div
      v-for="item in playerList"
      :key="item.id"
      class="flex items-center gap-3"
    >
      <u-icon
        :name="soundscapeIconMap[item.type] ?? 'i-mingcute:music-2-fill'"
        class="text-xl text-gray-500 shrink-0"
      />

      <span class="text-sm text-gray-600 flex-1 capitalize">
        {{ t(item.player.title as any) }}
      </span>

      <u-slider
        :model-value="volumeMap[item.id] ?? 1"
        :min="0"
        :max="2"
        :step="0.01"
        class="flex-1"
        @update:model-value="(value) => handleVolumeChange(item.id, value ?? 1)"
      />
    </div>
  </div>
</template>
```

每個音效類型都有專屬 icon：

```typescript
const soundscapeIconMap: Record<SoundscapeType, string> = {
  rustle: 'i-mingcute:leaf-fill',
  insect: 'i-mingcute:bug-fill',
  bird: 'i-material-symbols:raven',
  frog: 'i-fa7-solid:frog',
  beast: 'i-mingcute:cat-fill',
  river: 'i-material-symbols:water',
  building: 'i-solar:buildings-2-bold',
  ocean: 'i-lucide-lab:waves-birds',
  alpine: 'i-mynaui:mountain-snow-solid',
  rain: 'i-material-symbols:rainy',
  campfire: 'i-mingcute:campfire-fill',
}
```

音量範圍 0～2，也就是最大可以放大到原始音量的 2 倍。

只顯示目前正在播放（未被靜音）的音效，避免混亂。%ԅ(´∀\` ԅ)%

## 公告欄 Modal

首次開啟會顯示歡迎頁面，包含 Intro 和 Quick Start 兩個分頁。

Quick Start 使用輪播展示操作步驟，搭配影片和圖片：

```typescript
const quickStartItems = computed(() => {
  const descriptionList = t('start')
  const imgList = t('startImg')

  return descriptionList.map((text, index) => ({
    img: imgList[index] || '',
    description: text,
  }))
})
```

有趣的是文字內容和圖片路徑都放在 i18n 翻譯檔中，可以根據語言顯示不同的教學內容。

## 輕量 i18n：不用 vue-i18n 的方案

因為 whyframe 環境的限制，只好自己做了輕量版 i18n composable。

`content\aquarium\hexazen\composables\use-simple-i18n.ts`

```typescript
export function useSimpleI18n<
  Messages extends Record<DefaultLang, MessageSchema>,
  DefaultLang extends 'zh-hant' | 'en',
  Data = Messages[DefaultLang],
>(
  messages: Messages & StrictCheck<Messages>,
  options?: {
    defaultLocale?: DefaultLang;
  },
) {
  const currentLocale = ref(
    options?.defaultLocale ?? Object.keys(messages)[0],
  ) as Ref<DefaultLang>

  const languages = usePreferredLanguages()

  watch(
    languages,
    ([lang]) => {
      currentLocale.value = (
        lang?.includes('zh') ? 'zh-hant' : 'en'
      ) as DefaultLang
    },
    { immediate: true },
  )

  function t<Key extends keyof Data>(
    key: Key,
    params?: Record<string, string | number>,
  ): Data[Key] extends string[] ? string[] : string {
    const value = getMessage(key) as any

    if (Array.isArray(value))
      return value

    if (typeof value === 'string' && params) {
      let text = value
      Object.entries(params).forEach(([k, v]) => {
        text = text.replace(new RegExp(`{${k}}`, 'g'), String(v))
      })
      return text
    }

    return value
  }

  return { locale: currentLocale, setLocale, t }
}
```

### 型別安全的嚴格檢查

最精華的部分是 TypeScript 型別層面的檢查：

```typescript
type StrictCheck<Messages> = {
  [L in keyof Messages]: {
    [K in KeyUniverse<Messages>]: CommonType<Messages, K>;
  };
}
```

`StrictCheck` 確保**所有語言都必須有完全相同的 key 集合**。

如果中文多了一個 key 但英文沒有，TypeScript 編譯時就會報錯。不用等到運行時才發現遺漏翻譯。%╰(*´︶\`*)╯%

### 特色

* 自動偵測瀏覽器語言（`usePreferredLanguages`）
* 支援字串插值（`{name}` 參數替換）
* 支援字串陣列（用於教學步驟等清單）
* 編譯時期型別檢查，缺漏翻譯直接報錯
* 不到 100 行程式碼，零外部依賴

## 總結 🐟

以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen)

* 倒角邊框風格統一整個應用的視覺語言
* 聲景混音器提供精細的個別音量控制
* 公告欄兼具歡迎頁和操作教學
* 自製輕量 i18n，編譯時期保證翻譯完整性
* 自動偵測瀏覽器語言，零設定

下一章是最後一篇，讓我們來聊聊 WebGPU 與效能優化。%♪( ◜ω◝و(و%

::: tip
因為篇幅問題，某些部分簡單帶過。

若想看到更詳細的解釋，請不吝留言或寫信給我喔！%(\*´∀\`)~♥%
:::
