Răsfoiți Sursa

feat: Enhance registration form with food preferences, i18n updates, and storage utility integration.

FanLide 1 săptămână în urmă
părinte
comite
3e23033767

+ 200 - 0
docs/FOOD_PREFERENCES.md

@@ -0,0 +1,200 @@
+# 食物偏好选项实现说明
+
+## 📋 概述
+
+注册页面的食物偏好选项现在支持以下功能:
+
+- ✅ **国际化支持** - 所有食物名称支持 4 种语言(日语、英语、简体中文、繁体中文)
+- ✅ **API 集成准备** - 预留了 API 接口,可轻松切换到后端数据
+- ✅ **Fallback 机制** - API 失败时自动使用本地国际化数据
+
+## 🔧 当前实现
+
+### 1. 数据结构
+
+```typescript
+interface FoodOption {
+  id: string; // 食物唯一标识
+  label: string; // 显示的本地化名称
+}
+```
+
+### 2. 本地化配置
+
+在 `src/locale/*.json` 中添加了 `register.foods` 部分:
+
+```json
+{
+  "register": {
+    "foods": {
+      "sushi": "寿司",
+      "ramen": "ラーメン"
+      // ...
+    }
+  }
+}
+```
+
+### 3. 当前行为
+
+- 页面加载时调用 `fetchFoodOptions()`
+- 目前使用本地国际化数据作为 fallback
+- 用户选择的是 `food.id`,存储在 `form.favoriteFoods` 数组中
+
+## 🚀 切换到 API 模式
+
+### 步骤 1: 更新 register.vue
+
+在 `src/views/login/register.vue` 中,找到 `fetchFoodOptions` 函数:
+
+```typescript
+// 当前代码(第 255-267 行)
+const fetchFoodOptions = async () => {
+  try {
+    // TODO: 取消注释以下代码以启用 API
+    // const response = await getFoodPreferences(locale.value)
+    // foodOptions.value = response.data
+
+    // 暂时使用本地 fallback
+    foodOptions.value = getFallbackFoodOptions();
+  } catch (error) {
+    console.error("Failed to fetch food options:", error);
+    foodOptions.value = getFallbackFoodOptions();
+  }
+};
+```
+
+**修改为**:
+
+```typescript
+import { getFoodPreferences } from "@/api/preferences";
+
+const fetchFoodOptions = async () => {
+  try {
+    // 从 API 获取
+    const response = await getFoodPreferences(locale.value);
+    foodOptions.value = response.data || response;
+  } catch (error) {
+    console.error("Failed to fetch food options:", error);
+    // API 失败时使用本地 fallback
+    foodOptions.value = getFallbackFoodOptions();
+  }
+};
+```
+
+### 步骤 2: 后端 API 实现
+
+后端需要实现以下接口:
+
+**GET** `/app-api/member/preferences/foods`
+
+**Query Parameters:**
+
+- `locale` (可选): 语言代码 (ja, en, zh-Hans, zh-Hant)
+
+**Response:**
+
+```json
+{
+  "code": 0,
+  "data": [
+    {
+      "id": "sushi",
+      "label": "寿司",
+      "category": "japanese",
+      "icon": "🍣"
+    },
+    {
+      "id": "ramen",
+      "label": "ラーメン",
+      "category": "japanese",
+      "icon": "🍜"
+    }
+  ]
+}
+```
+
+### 步骤 3: 保存用户偏好
+
+在 `handleRegister` 函数中,可以调用保存 API:
+
+```typescript
+import { saveFoodPreferences } from "@/api/preferences";
+
+const handleRegister = async () => {
+  if (!form.nickname || !form.contact) {
+    showToast(t("register.fillRequired"));
+    return;
+  }
+
+  loading.value = true;
+  try {
+    // 注册用户
+    // const userRes = await registerUser(form)
+
+    // 保存食物偏好
+    if (form.favoriteFoods.length > 0) {
+      await saveFoodPreferences(form.favoriteFoods);
+    }
+
+    showToast(t("register.success"));
+    router.push("/index");
+  } catch (error) {
+    console.error(error);
+    showToast(t("register.failed"));
+  } finally {
+    loading.value = false;
+  }
+};
+```
+
+## 🎯 优势
+
+### 使用 API 的好处:
+
+1. **动态管理** - 可以在后台添加/删除食物选项,无需发布新版本
+2. **个性化** - 可以根据地区、餐厅类型返回不同选项
+3. **数据分析** - 收集用户偏好数据用于推荐算法
+4. **一致性** - 与后端数据库保持同步
+
+### 保留 Fallback 的好处:
+
+1. **离线支持** - API 失败时仍能正常使用
+2. **快速加载** - 本地数据立即可用
+3. **降级方案** - 确保用户体验不受影响
+
+## 📝 国际化翻译
+
+已添加的翻译键:
+
+```
+register.foods.sushi
+register.foods.ramen
+register.foods.gyoza
+register.foods.tempura
+register.foods.udon
+register.foods.sashimi
+register.foods.yakitori
+```
+
+如需添加新的食物选项,在所有语言文件中添加对应的翻译即可。
+
+## 🔄 语言切换
+
+当用户切换语言时,食物选项会自动更新为对应语言的翻译:
+
+```typescript
+// 监听语言变化(可选)
+watch(locale, () => {
+  fetchFoodOptions();
+});
+```
+
+## 📚 相关文件
+
+- `src/views/login/register.vue` - 注册页面组件
+- `src/api/preferences.ts` - 偏好设置 API 接口
+- `src/locale/ja.json` - 日语翻译
+- `src/locale/en.json` - 英语翻译
+- `src/locale/zh-Hans.json` - 简体中文翻译
+- `src/locale/zh-Hant.json` - 繁体中文翻译

+ 46 - 0
src/api/preferences.ts

@@ -0,0 +1,46 @@
+/**
+ * 用户偏好设置相关 API
+ * User Preferences API
+ */
+
+import request from './request'
+
+/**
+ * 食物选项接口
+ * Food Option Interface
+ */
+export interface FoodOption {
+  id: string
+  label: string
+  category?: string
+  icon?: string
+}
+
+/**
+ * 获取食物偏好选项
+ * Get food preference options
+ * 
+ * @param locale - 语言代码 (ja, en, zh-Hans, zh-Hant)
+ * @returns Promise<FoodOption[]>
+ */
+export function getFoodPreferences(locale?: string) {
+  return request<FoodOption[]>({
+    url: '/app-api/member/preferences/foods',
+    method: 'get',
+    params: { locale }
+  })
+}
+
+/**
+ * 保存用户食物偏好
+ * Save user food preferences
+ * 
+ * @param foodIds - 选中的食物ID数组
+ */
+export function saveFoodPreferences(foodIds: string[]) {
+  return request({
+    url: '/app-api/member/preferences/foods',
+    method: 'post',
+    data: { foodIds }
+  })
+}

+ 16 - 3
src/locale/en.json

@@ -263,7 +263,8 @@
     "userAgreement": "User Agreement",
     "and": "and",
     "privacyPolicy": "Privacy Policy",
-    "success": "Login Success"
+    "success": "Login Success",
+    "newToApp": "New to LINE Order?"
   },
   "register": {
     "title": "Customer Registration",
@@ -273,7 +274,7 @@
     "phoneticName": "Phonetic Name",
     "enterPhonetic": "Katakana or Hiragana",
     "mobileOrEmail": "Mobile Number or Email",
-    "enterContact": "e.g. 09012345678 or email@example.com",
+    "enterContact": "e.g. 09012345678 or email{'@'}example.com",
     "birthday": "Birthday",
     "selectBirthday": "mm/dd/yyyy",
     "referral": "Referral Code",
@@ -281,7 +282,19 @@
     "favoriteFood": "Favorite Food",
     "submit": "Register",
     "orRegisterWith": "Or register with",
-    "hasAccount": "Already have an account?"
+    "hasAccount": "Already have an account?",
+    "fillRequired": "Please fill in required fields",
+    "selectBirthdayRequired": "Please select your birthday",
+    "success": "Registration Successful",
+    "foods": {
+      "sushi": "Sushi",
+      "ramen": "Ramen",
+      "gyoza": "Gyoza",
+      "tempura": "Tempura",
+      "udon": "Udon",
+      "sashimi": "Sashimi",
+      "yakitori": "Yakitori"
+    }
   },
   "merchant": {
     "tabbar": {

+ 16 - 3
src/locale/ja.json

@@ -263,7 +263,8 @@
     "userAgreement": "利用規約",
     "and": "および",
     "privacyPolicy": "プライバシーポリシー",
-    "success": "ログイン成功"
+    "success": "ログイン成功",
+    "newToApp": "LINE Orderが初めてですか?"
   },
   "register": {
     "title": "会員登録",
@@ -273,7 +274,7 @@
     "phoneticName": "フリガナ",
     "enterPhonetic": "カタカナまたはひらがな",
     "mobileOrEmail": "電話番号またはメール",
-    "enterContact": "例: 09012345678 または email@example.com",
+    "enterContact": "例: 09012345678 または email{'@'}example.com",
     "birthday": "誕生日",
     "selectBirthday": "mm/dd/yyyy",
     "referral": "紹介コード",
@@ -281,7 +282,19 @@
     "favoriteFood": "好きな料理",
     "submit": "登録する",
     "orRegisterWith": "または以下で登録",
-    "hasAccount": "アカウントをお持ちですか?"
+    "hasAccount": "アカウントをお持ちですか?",
+    "fillRequired": "必須項目を入力してください",
+    "selectBirthdayRequired": "誕生日を選択してください",
+    "success": "登録が完了しました",
+    "foods": {
+      "sushi": "寿司",
+      "ramen": "ラーメン",
+      "gyoza": "餅子",
+      "tempura": "天ぷら",
+      "udon": "うどん",
+      "sashimi": "刺身",
+      "yakitori": "焼き鳥"
+    }
   },
   "merchant": {
     "tabbar": {

+ 16 - 3
src/locale/zh-Hans.json

@@ -263,7 +263,8 @@
     "userAgreement": "用户协议",
     "and": "与",
     "privacyPolicy": "隐私政策",
-    "success": "登录成功"
+    "success": "登录成功",
+    "newToApp": "还没有账号?"
   },
   "register": {
     "title": "会员注册",
@@ -273,7 +274,7 @@
     "phoneticName": "拼音名称",
     "enterPhonetic": "片假名或平假名",
     "mobileOrEmail": "手机号或邮箱",
-    "enterContact": "例如:09012345678 或 email@example.com",
+    "enterContact": "例如:09012345678 或 email{'@'}example.com",
     "birthday": "生日",
     "selectBirthday": "mm/dd/yyyy",
     "referral": "推荐码",
@@ -281,7 +282,19 @@
     "favoriteFood": "喜欢的食物",
     "submit": "注册",
     "orRegisterWith": "或使用以下方式注册",
-    "hasAccount": "已有账号?"
+    "hasAccount": "已有账号?",
+    "fillRequired": "请填写必填项",
+    "selectBirthdayRequired": "请选择生日",
+    "success": "注册成功",
+    "foods": {
+      "sushi": "寿司",
+      "ramen": "拉面",
+      "gyoza": "饺子",
+      "tempura": "天妇罗",
+      "udon": "乌冬面",
+      "sashimi": "生鱼片",
+      "yakitori": "烤鸡串"
+    }
   },
   "merchant": {
     "tabbar": {

+ 16 - 3
src/locale/zh-Hant.json

@@ -263,7 +263,8 @@
     "userAgreement": "用戶協議",
     "and": "與",
     "privacyPolicy": "隱私政策",
-    "success": "登錄成功"
+    "success": "登錄成功",
+    "newToApp": "還沒有賬號?"
   },
   "register": {
     "title": "會員註冊",
@@ -273,7 +274,7 @@
     "phoneticName": "拼音名稱",
     "enterPhonetic": "片假名或平假名",
     "mobileOrEmail": "手機號或郵箱",
-    "enterContact": "例如:09012345678 或 email@example.com",
+    "enterContact": "例如:09012345678 或 email{'@'}example.com",
     "birthday": "生日",
     "selectBirthday": "mm/dd/yyyy",
     "referral": "推薦碼",
@@ -281,7 +282,19 @@
     "favoriteFood": "喜歡的食物",
     "submit": "註冊",
     "orRegisterWith": "或使用以下方式註冊",
-    "hasAccount": "已有賬號?"
+    "hasAccount": "已有賬號?",
+    "fillRequired": "請填寫必填項",
+    "selectBirthdayRequired": "請選擇生日",
+    "success": "註冊成功",
+    "foods": {
+      "sushi": "壽司",
+      "ramen": "拉麵",
+      "gyoza": "餃子",
+      "tempura": "天婦羅",
+      "udon": "烏冬麵",
+      "sashimi": "生魚片",
+      "yakitori": "燒鳥串"
+    }
   },
   "merchant": {
     "tabbar": {

+ 4 - 3
src/views/login/login.vue

@@ -191,6 +191,7 @@ import { showToast } from 'vant'
 import { useUserStore } from '@/store/modules/user'
 import { login as apiLogin, sendSmsCode, lineLogin as apiLineLogin } from '@/api/auth'
 import { useLiff } from '@/composables/useLiff'
+import { storage } from '@/utils/storage'
 
 // TypeScript Interfaces
 interface LangAction {
@@ -198,7 +199,7 @@ interface LangAction {
   value: string
 }
 
-interface LoginResponse {
+interface LoginResponseData {
   userInfo: any
   accessToken: string
 }
@@ -235,7 +236,7 @@ const currentLangText = computed(() => {
 const onSelectLang = (item: LangAction) => {
   locale.value = item.value
   showLangAction.value = false
-  localStorage.setItem('locale', item.value)
+  storage.set('language', item.value)
 }
 
 const handleEmployeeLogin = () => {
@@ -325,7 +326,7 @@ const handleLineLogin = async () => {
   }
 }
 
-const handleLoginSuccess = (data: LoginResponse) => {
+const handleLoginSuccess = (data: any) => {
   userStore.setMember(data.userInfo)
   userStore.setToken(data.accessToken)
   showToast(t('login.success'))

+ 60 - 14
src/views/login/register.vue

@@ -15,8 +15,7 @@
         <div class="pattern-bg"></div>
         <div class="header-icon-wrapper">
           <div class="header-icon">
-            <!-- Replacing shop-o with user-plus or similar for registration -->
-            <van-icon name="user-plus" size="40" color="#f2930d" />
+            <van-icon name="user-o" size="40" color="#f2930d" />
           </div>
         </div>
       </div>
@@ -45,8 +44,8 @@
             </div>
           </div>
 
-          <!-- Phonetic Name -->
-          <div class="form-group mt-4">
+          <!-- Phonetic Name (Japanese only) -->
+          <div v-if="locale === 'ja'" class="form-group mt-4">
             <label class="form-label">
                 {{ $t('register.phoneticName') || 'Phonetic Name' }} <span class="optional">(Optional)</span>
             </label>
@@ -78,7 +77,7 @@
           <!-- Birthday & Referral Code (2 cols) -->
           <div class="form-row mt-4">
             <div class="form-group half">
-                 <label class="form-label">{{ $t('register.birthday') || 'Birthday' }}</label>
+                 <label class="form-label">{{ $t('register.birthday') || 'Birthday' }} <span class="required">*</span></label>
                  <div class="input-pill clickable" @click="showDatePicker = true">
                     <van-field
                         v-model="form.birthday"
@@ -109,12 +108,12 @@
             <div class="tags-container">
                 <div 
                     v-for="food in foodOptions" 
-                    :key="food" 
+                    :key="food.id" 
                     class="food-tag"
-                    :class="{ active: form.favoriteFoods.includes(food) }"
-                    @click="toggleFood(food)"
+                    :class="{ active: form.favoriteFoods.includes(food.id) }"
+                    @click="toggleFood(food.id)"
                 >
-                    {{ food }}
+                    {{ food.label }}
                 </div>
             </div>
           </div>
@@ -189,10 +188,11 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { showToast } from 'vant'
 import { useI18n } from 'vue-i18n'
+import { storage } from '@/utils/storage'
 
 const { t, locale } = useI18n()
 const router = useRouter()
@@ -220,7 +220,7 @@ const currentLangText = computed(() => {
 const onSelectLang = (item: any) => {
   locale.value = item.value
   showLangAction.value = false
-  localStorage.setItem('locale', item.value)
+  storage.set('language', item.value)
 }
 
 const form = reactive({
@@ -232,7 +232,47 @@ const form = reactive({
     favoriteFoods: [] as string[]
 })
 
-const foodOptions = ['Sushi', 'Ramen', 'Gyoza', 'Tempura', 'Udon', 'Sashimi', 'Yakitori']
+// Food Options - Fetch from API with i18n fallback
+interface FoodOption {
+  id: string
+  label: string
+}
+
+const foodOptions = ref<FoodOption[]>([])
+
+// Fallback food options with i18n
+const getFallbackFoodOptions = (): FoodOption[] => {
+  return [
+    { id: 'sushi', label: t('register.foods.sushi') || 'Sushi' },
+    { id: 'ramen', label: t('register.foods.ramen') || 'Ramen' },
+    { id: 'gyoza', label: t('register.foods.gyoza') || 'Gyoza' },
+    { id: 'tempura', label: t('register.foods.tempura') || 'Tempura' },
+    { id: 'udon', label: t('register.foods.udon') || 'Udon' },
+    { id: 'sashimi', label: t('register.foods.sashimi') || 'Sashimi' },
+    { id: 'yakitori', label: t('register.foods.yakitori') || 'Yakitori' }
+  ]
+}
+
+// Fetch food options from API
+const fetchFoodOptions = async () => {
+  try {
+    // TODO: Replace with actual API call
+    // const response = await getFoodPreferences()
+    // foodOptions.value = response.data
+    
+    // For now, use fallback with i18n
+    foodOptions.value = getFallbackFoodOptions()
+  } catch (error) {
+    console.error('Failed to fetch food options:', error)
+    // Use fallback on error
+    foodOptions.value = getFallbackFoodOptions()
+  }
+}
+
+// Load food options on mount
+onMounted(() => {
+  fetchFoodOptions()
+})
 
 const onConfirmDate = ({ selectedValues }: { selectedValues: string[] }) => {
     form.birthday = selectedValues.join('/')
@@ -249,15 +289,21 @@ const toggleFood = (food: string) => {
 }
 
 const handleRegister = async () => {
+    // Validate required fields
     if (!form.nickname || !form.contact) {
-        showToast('Please fill in required fields')
+        showToast(t('register.fillRequired') || 'Please fill in required fields')
+        return
+    }
+    
+    if (!form.birthday) {
+        showToast(t('register.selectBirthdayRequired') || 'Please select your birthday')
         return
     }
     
     loading.value = true
     setTimeout(() => {
         loading.value = false
-        showToast('Registration Successful (Mock)')
+        showToast(t('register.success') || 'Registration Successful (Mock)')
         router.push('/index')
     }, 1500)
 }