Ver código fonte

feat: 更新游客模式

FanLide 2 semanas atrás
pai
commit
22dfe71a6d

+ 34 - 0
README.md

@@ -131,6 +131,40 @@ npm run type-check
 
 可以通过 `LanguageSwitcher` 组件或 `appStore` 动态切换语言。
 
+## 针对开发者的测试建议 (Testing & Demo)
+
+由于本项目支持多模式切换,你可以通过以下 URL 直接进入对应的业务模块进行测试:
+
+### 1. 平台/聚合模式 (Platform Mode)
+
+展示店铺列表和 LBS 定位。
+
+- **URL**: `/#/index`
+
+### 2. 顾客点餐模式 (Customer Mode)
+
+模拟用户进入特定店铺点餐。
+
+- **外带模式**: `/#/menu?shopId=shop_1`
+- **堂食/桌位模式**: `/#/menu?shopId=shop_1&tableCode=A01`
+- **扫码入口测试**: `/#/scan?shopId=shop_1&tableCode=A01`
+
+### 3. POS 员工模式 (POS Mode)
+
+模拟店员在平板端进行桌台和订单管理。
+
+- **入口**: `/#/pos/welcome`
+- **桌位管理**: `/#/pos/tables`
+
+### 4. 店长管理模式 (Owner Mode)
+
+模拟老板查看报表和管理店铺。
+
+- **总览**: `/#/owner/dashboard`
+- **报表**: `/#/owner/reports`
+
+> **提示**: 部分管理端页面 (`/pos`, `/owner`, `/admin`) 设有路由守卫,测试前需先在 `/#/login` 完成对应权限角色的登录。
+
 ## License
 
 MIT

+ 140 - 0
src/api/guest.ts

@@ -0,0 +1,140 @@
+/**
+ * 游客模式 API
+ * 用于堂食/桌位扫码点餐场景
+ */
+
+import request from './request'
+
+/**
+ * 游客登录参数
+ */
+export interface GuestLoginParams {
+  deviceId: string
+}
+
+/**
+ * 游客登录响应
+ */
+export interface GuestLoginResponse {
+  token?: string
+  guestId?: string
+}
+
+/**
+ * 游客登录
+ * 扫码后自动调用,生成游客会话
+ */
+export function guestLogin(data: GuestLoginParams): Promise<GuestLoginResponse> {
+  return request({
+    url: '/app-api/guest/auth/login',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 游客登出
+ */
+export function guestLogout() {
+  return request({
+    url: '/app-api/guest/auth/logout',
+    method: 'post'
+  })
+}
+
+/**
+ * 游客订单参数
+ */
+export interface GuestOrderCreateParams {
+  shopId: string
+  tableCode: string
+  goods: {
+    id: number | string
+    count: number
+    spec?: string
+  }[]
+  remark?: string
+}
+
+/**
+ * 游客创建订单
+ */
+export function guestCreateOrder(data: GuestOrderCreateParams) {
+  return request({
+    url: '/app-api/guest/order/create',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 游客订单列表参数
+ */
+export interface GuestOrderListParams {
+  pageNo?: number
+  pageSize?: number
+  status?: number
+}
+
+/**
+ * 游客获取订单列表
+ */
+export function guestGetOrderList(params?: GuestOrderListParams) {
+  return request({
+    url: '/app-api/guest/order/list',
+    method: 'get',
+    params
+  })
+}
+
+/**
+ * 游客获取订单详情
+ * @param key 订单key或ID
+ */
+export function guestGetOrderDetail(key: string | number) {
+  return request({
+    url: `/app-api/guest/order/detail/${key}`,
+    method: 'get'
+  })
+}
+
+/**
+ * 游客支付订单参数
+ */
+export interface GuestPayOrderParams {
+  orderId: string | number
+  paymentMethod?: string
+}
+
+/**
+ * 游客支付订单
+ */
+export function guestPayOrder(data: GuestPayOrderParams) {
+  return request({
+    url: '/app-api/guest/order/pay',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 游客取消订单
+ */
+export function guestCancelOrder(orderId: string | number) {
+  return request({
+    url: '/app-api/guest/order/cancel',
+    method: 'post',
+    data: { orderId }
+  })
+}
+
+/**
+ * 游客删除订单
+ */
+export function guestDeleteOrder(orderId: string | number) {
+  return request({
+    url: '/app-api/guest/order/del',
+    method: 'post',
+    data: { orderId }
+  })
+}

+ 11 - 0
src/api/request.ts

@@ -29,6 +29,17 @@ request.interceptors.request.use(
       config.headers.Authorization = `Bearer ${token}`
     }
 
+    // 游客模式: 添加 deviceId header
+    const guestDeviceId = storage.get('guestDeviceId')
+    const guestToken = storage.get('guestToken')
+    if (guestDeviceId) {
+      config.headers['X-Device-Id'] = guestDeviceId
+    }
+    // 游客Token优先(如果已登录游客)
+    if (guestToken && !token) {
+      config.headers.Authorization = `Bearer ${guestToken}`
+    }
+
     return config
   },
   (error: any) => {

+ 0 - 1
src/components.d.ts

@@ -45,7 +45,6 @@ declare module 'vue' {
     VanList: typeof import('vant/es')['List']
     VanLoading: typeof import('vant/es')['Loading']
     VanNavBar: typeof import('vant/es')['NavBar']
-    VanNoticeBar: typeof import('vant/es')['NoticeBar']
     VanPicker: typeof import('vant/es')['Picker']
     VanPopup: typeof import('vant/es')['Popup']
     VanPullRefresh: typeof import('vant/es')['PullRefresh']

+ 53 - 23
src/components/common/YTabBar.vue

@@ -14,8 +14,9 @@
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue'
+import { ref, watch, computed } from 'vue'
 import { useRoute } from 'vue-router'
+import { useAppStore } from '@/store/modules/app'
 
 const props = defineProps({
   fixed: {
@@ -33,37 +34,66 @@ const props = defineProps({
 })
 
 const route = useRoute()
+const appStore = useAppStore()
 const activeTab = ref(0)
 
 // Tab列表配置
-const tabList = [
-  {
-    path: '/index',
-    icon: 'home-o',
-    title: 'index.home'
-  },
-  {
-    path: '/menu',
-    icon: 'apps-o',
-    title: 'menu.title'
-  },
-  {
-    path: '/order',
-    icon: 'orders-o',
-    title: 'order.title'
-  },
-  {
-    path: '/mine',
-    icon: 'user-o',
-    title: 'mine.title'
+const tabList = computed(() => {
+  // 基础 Tab 配置
+  const tabs = [
+    {
+      path: '/index',
+      icon: 'home-o',
+      title: 'index.home'
+    },
+    {
+      path: '/menu',
+      icon: 'apps-o',
+      title: 'menu.title'
+    },
+    {
+      path: '/order',
+      icon: 'orders-o',
+      title: 'order.title'
+    },
+    {
+      path: '/mine',
+      icon: 'user-o',
+      title: 'mine.title'
+    }
+  ]
+
+  // 如果在店铺模式或桌台模式下,首页图标应指向该店铺的菜单页/首页
+  if (!appStore.isPlatformMode && appStore.currentShop?.id) {
+    // 构造带有查询参数的路径
+    let shopHomePath = `/menu?shopId=${appStore.currentShop.id}`
+    
+    // 如果有桌台信息,也带上
+    if (appStore.currentTable?.code) {
+      shopHomePath += `&tableCode=${appStore.currentTable.code}`
+    }
+
+    // 替换第一个 Tab (首页) 的路径
+    tabs[0].path = shopHomePath
+    
+    // 可选:如果希望"菜单" Tab 也指向同一个地方(或者隐藏它),
+    // 这里保持原状,因为 /menu 本身会读取 store 中的 shopId
   }
-]
+
+  return tabs
+})
 
 // 监听路由变化更新active
 watch(
   () => route.path,
   (newPath) => {
-    const index = tabList.findIndex((item) => item.path === newPath)
+    // 简单匹配 path,忽略 query
+    const index = tabList.value.findIndex((item) => {
+      // 处理带参数的 path 比较
+      const itemPathBase = item.path.split('?')[0]
+      return itemPathBase === newPath
+    })
+    
     if (index !== -1) {
       activeTab.value = index
     }

+ 73 - 12
src/router/guards.ts

@@ -1,12 +1,10 @@
-/**
- * 路由守卫
- */
-
 import type { Router } from 'vue-router'
 import { useUserStore, type UserRole } from '@/store/modules/user'
 import { useAppStore } from '@/store/modules/app'
 import { showToast } from 'vant'
 import i18n from '@/locale'
+import { guestLogin } from '@/api/guest'
+import { storage } from '@/utils/storage'
 
 const { t } = i18n.global
 
@@ -27,13 +25,55 @@ export function setupRouterGuards(router: Router) {
     } else if (to.path === '/menu') {
       // 进入菜单页,检查是否有店铺上下文
       const queryShopId = to.query.shopId as string
+      const queryTableCode = to.query.tableCode as string
       
       // 如果URL有shopId,优先使用(处理直接访问)
-      if (queryShopId && (!appStore.currentShop || appStore.currentShop.id !== queryShopId)) {
-        // TODO: 这里应该从API加载店铺信息
-        // 暂时只设置简单的上下文
-        appStore.enterShopMode({ id: queryShopId, name: '加载中...', status: 'operating', companyId: '', address: '' })
-      } 
+      if (queryShopId) {
+        // 1. 桌台模式 (带 tableCode)
+        if (queryTableCode) {
+           // 设置应用状态为桌台模式
+           const mockShop = { id: queryShopId, name: '加载中...', status: 'operating' as const, companyId: '', address: '' }
+           const mockTable = { id: queryTableCode, code: queryTableCode, name: `${queryTableCode}桌` } // 简单模拟
+           
+           // 只有当当前状态不一致时才更新,避免重复调用
+           if (!appStore.isTableMode || appStore.tableCode !== queryTableCode) {
+             appStore.enterTableMode(mockShop, mockTable)
+           }
+
+           // 如果未登录,初始化访客会话并调用游客登录API
+           if (!userStore.isLogin && !userStore.isGuest) {
+             // 获取或创建设备ID
+             const deviceId = userStore.getOrCreateDeviceId()
+             // 保存到localStorage供request拦截器使用
+             storage.set('guestDeviceId', deviceId)
+             
+             // 初始化本地访客状态
+             userStore.initGuestSession({
+               tableId: queryTableCode,
+               tableCode: queryTableCode,
+               tableName: `${queryTableCode}桌`,
+               shopId: queryShopId,
+               sessionId: `guest_${Date.now()}`,
+               createdAt: Date.now()
+             })
+
+             // 异步调用游客登录API(不阻塞路由)
+             guestLogin({ deviceId }).then((res) => {
+               if (res?.token) {
+                 userStore.setGuestToken(res.token)
+                 storage.set('guestToken', res.token)
+               }
+             }).catch((err) => {
+               console.warn('Guest login failed:', err)
+               // 即使登录失败,也允许访问菜单(离线模式)
+             })
+           }
+        } 
+        // 2. 普通店铺模式 (不带 tableCode)
+        else if (!appStore.currentShop || appStore.currentShop.id !== queryShopId) {
+          appStore.enterShopMode({ id: queryShopId, name: '加载中...', status: 'operating' as const, companyId: '', address: '' })
+        }
+      }
       // 如果没有context且没有query参数,重定向回首页
       else if (!appStore.currentShop) {
         showToast('请先选择店铺')
@@ -45,6 +85,28 @@ export function setupRouterGuards(router: Router) {
     // 1. 检查是否需要登录
     // guest 用户允许访问菜单和购物车,但不能访问需要登录的页面
     if (to.meta.requiresAuth) {
+      // 定义 guest 可访问的路径(订单相关)
+      const guestAllowedPaths = ['/cart', '/payment', '/order/detail', '/order']
+      const isGuestAllowedPath = guestAllowedPaths.some(p => to.path.startsWith(p))
+
+      // 如果是 Table Mode(appStore 有桌台信息),自动视为 Guest 允许访问特定路由
+      if (appStore.isTableMode && isGuestAllowedPath) {
+        // 如果仍然没有 guest session,补充初始化
+        if (!userStore.isLogin && !userStore.isGuest && appStore.currentTable) {
+          userStore.initGuestSession({
+            tableId: appStore.currentTable.code,
+            tableCode: appStore.currentTable.code,
+            tableName: appStore.currentTable.name,
+            shopId: appStore.currentShop?.id || '',
+            sessionId: `guest_${Date.now()}`,
+            createdAt: Date.now()
+          })
+        }
+        // Table Mode 下访问允许的路径,直接放行
+        next()
+        return
+      }
+
       // 检查是否登录或有有效会话
       if (!userStore.isLogin && !userStore.isGuest) {
         
@@ -70,9 +132,8 @@ export function setupRouterGuards(router: Router) {
 
       // guest 用户尝试访问需要登录的页面
       if (userStore.isGuest && to.meta.requiresAuth) {
-        // 允许 guest 访问部分页面(订单创建相关)
-        const guestAllowedPaths = ['/cart', '/payment', '/order/detail']
-        if (!guestAllowedPaths.some(p => to.path.startsWith(p))) {
+        // 允许 guest 访问部分页面(订单创建相关 + 订单列表)
+        if (!isGuestAllowedPath) {
           showToast(t('common.pleaseLogin'))
           next({
             path: '/login',

+ 2 - 1
src/router/routes.ts

@@ -75,7 +75,8 @@ export default [
     name: 'Mine',
     component: () => import('@/views/mine/mine.vue'),
     meta: {
-      title: 'mine.title'
+      title: 'mine.title',
+      requiresAuth: true
     }
   },
   {

+ 31 - 2
src/store/modules/user.ts

@@ -82,6 +82,8 @@ export const useUserStore = defineStore('user', {
     // 扫码访客会话
     isGuest: false,
     tableSession: null as TableSession | null,
+    guestDeviceId: '' as string,  // 游客设备ID,用于游客API认证
+    guestToken: '' as string,     // 游客登录后的token
 
     // 兼容性字段
     isMer: 0,
@@ -226,7 +228,32 @@ export const useUserStore = defineStore('user', {
     },
 
     /**
-     * 清除访客会话
+     * 获取或创建游客设备ID
+     * 用于游客API认证
+     */
+    getOrCreateDeviceId(): string {
+      if (this.guestDeviceId) {
+        return this.guestDeviceId
+      }
+      // 生成新的 UUID v4
+      const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+        const r = Math.random() * 16 | 0
+        const v = c === 'x' ? r : (r & 0x3 | 0x8)
+        return v.toString(16)
+      })
+      this.guestDeviceId = uuid
+      return uuid
+    },
+
+    /**
+     * 设置游客Token
+     */
+    setGuestToken(token: string) {
+      this.guestToken = token
+    },
+
+    /**
+     * 清除游客会话
      */
     clearGuestSession() {
       this.isGuest = false
@@ -368,7 +395,9 @@ export const useUserStore = defineStore('user', {
     paths: [
       'member', 'token', 'openid', 'isMer', 'merchartShop',
       'currentRole', 'availableRoles', 'roleData',
-      'currentShopId', 'currentShop', 'managedShops'
+      'currentShopId', 'currentShop', 'managedShops',
+      'isGuest', 'tableSession',  // 保持桌台扫码访客状态
+      'guestDeviceId', 'guestToken'  // 游客设备ID和Token
     ]
   }
 })

+ 19 - 0
src/views/login/login.vue

@@ -25,6 +25,12 @@
         </van-tab>
       </van-tabs>
 
+      <!-- 注册提示 -->
+      <div class="register-hint">
+        {{ $t('login.noAccount') }} 
+        <span class="link" @click="loginType = 'phone'">{{ $t('login.registerNow') }}</span>
+      </div>
+
       <!-- LINE登录 (仅在LINE环境中显示) -->
       <div v-if="shouldShowLineFeatures" class="third-party-login">
         <van-divider>{{ $t('login.orLoginWith') }}</van-divider>
@@ -188,6 +194,19 @@ const showAgreement = (type: string) => {
   }
 }
 
+.register-hint {
+  text-align: center;
+  font-size: 14px;
+  color: #666;
+  margin-bottom: 20px;
+  
+  .link {
+    color: #1989fa;
+    cursor: pointer;
+    font-weight: 500;
+  }
+}
+
 .third-party-login {
   margin-top: 30px;
   margin-bottom: 20px;