- 9页面: 落地页/菜单/确认/订单/聊天 + 员工登录/看板/详情/聊天 - 9组件: Banner/分类Tab/商品卡片/出餐铃/购物车弹层/订单卡片/聊天气泡/配方弹窗/预设标签 - 3 Pinia Store: card/cart/staff - 3 Utils: request/constants/poller - 深色酒吧主题,全局无价格显示 - 响应式双列布局 - 1997号码牌跳转员工登录 - 出餐铃动画 bell-press + bell-ring-wave - 购物车点击遮罩关闭dev
| @ -0,0 +1,5 @@ | |||||
| node_modules/ | |||||
| dist/ | |||||
| .DS_Store | |||||
| *.log | |||||
| unpackage/ | |||||
| @ -0,0 +1,10 @@ | |||||
| <script> | |||||
| export default { | |||||
| onLaunch() { | |||||
| console.log('纯瘾大 启动') | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style> | |||||
| @import './uni.scss'; | |||||
| </style> | |||||
| @ -0,0 +1,29 @@ | |||||
| <template> | |||||
| <swiper class="banner-swiper" :autoplay="true" :interval="10000" :circular="true" indicator-dots indicator-color="rgba(255,255,255,.3)" indicator-active-color="#F5A623"> | |||||
| <swiper-item v-for="(b, i) in banners" :key="i"> | |||||
| <view class="banner-slide" :style="{ background: b.bg }"> | |||||
| <text>{{ b.text }}</text> | |||||
| </view> | |||||
| </swiper-item> | |||||
| </swiper> | |||||
| </template> | |||||
| <script> | |||||
| export default { | |||||
| name: 'BannerSwiper', | |||||
| setup() { | |||||
| const banners = [ | |||||
| { text: '🍸 新品特调上线', bg: 'linear-gradient(135deg,#2d1b69,#e74c3c)' }, | |||||
| { text: '🕐 调酒师已起床营业', bg: 'linear-gradient(135deg,#1a3a2a,#2ecc71)' }, | |||||
| { text: '🍺 精酿啤酒冰凉供应', bg: 'linear-gradient(135deg,#5c2a0a,#f39c12)' }, | |||||
| ] | |||||
| return { banners } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .banner-swiper{width:100%;height:260rpx;margin:12rpx 0} | |||||
| .banner-slide{width:100%;height:100%;display:flex;align-items:center;justify-content:center} | |||||
| .banner-slide text{font-size:36rpx;color:#fff;font-weight:800;letter-spacing:2rpx} | |||||
| </style> | |||||
| @ -0,0 +1,25 @@ | |||||
| <template> | |||||
| <view class="cart-bar" @tap="$emit('open')"> | |||||
| <view class="cart-icon-wrap"> | |||||
| <text class="cart-icon">🍷</text> | |||||
| <text v-if="count > 0" class="cart-badge">{{ count }}</text> | |||||
| </view> | |||||
| <text class="cart-text">{{ count > 0 ? count + '件 · 去呼唤调酒师' : '购物车空空' }}</text> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| export default { | |||||
| name: 'CartBar', | |||||
| props: { count: { type: Number, default: 0 } }, | |||||
| emits: ['open'] | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .cart-bar{position:sticky;bottom:0;height:100rpx;background:var(--bg-card);border-top:1px solid var(--border);display:flex;align-items:center;padding:0 24rpx;z-index:100;gap:16rpx} | |||||
| .cart-icon-wrap{position:relative} | |||||
| .cart-icon{font-size:48rpx} | |||||
| .cart-badge{position:absolute;top:-8rpx;right:-8rpx;background:var(--red);color:#fff;font-size:20rpx;width:36rpx;height:36rpx;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700} | |||||
| .cart-text{flex:1;font-size:28rpx;color:var(--text-dim)} | |||||
| </style> | |||||
| @ -0,0 +1,63 @@ | |||||
| <template> | |||||
| <view class="cart-popup" :class="{ show: visible }"> | |||||
| <view class="popup-header"> | |||||
| <text class="popup-title">购物车 ({{ items.length }})</text> | |||||
| <text class="popup-clear" @tap="onClear">清空</text> | |||||
| </view> | |||||
| <scroll-view class="popup-body" scroll-y> | |||||
| <view v-for="item in items" :key="item.product.id" class="cart-item"> | |||||
| <text class="cart-item-emoji">{{ item.product.emoji }}</text> | |||||
| <view class="cart-item-info"> | |||||
| <text class="cart-item-name">{{ item.product.name }}</text> | |||||
| <text class="cart-item-alc" v-if="item.product.alc > 0">{{ item.product.alc }}%</text> | |||||
| </view> | |||||
| <view class="cart-item-qty"> | |||||
| <text class="qty-btn" @tap="onQty(item.product.id, -1)">−</text> | |||||
| <text class="qty-num">{{ item.qty }}</text> | |||||
| <text class="qty-btn" @tap="onQty(item.product.id, 1)">+</text> | |||||
| </view> | |||||
| </view> | |||||
| </scroll-view> | |||||
| <view class="popup-footer"> | |||||
| <text class="popup-total">共 {{ cart.totalCount }} 件</text> | |||||
| <button class="popup-submit" @tap="onSubmit" :disabled="items.length===0">去呼唤调酒师</button> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { useCartStore } from '@/stores/cart' | |||||
| export default { | |||||
| name: 'CartPopup', | |||||
| props: { visible: { type: Boolean, default: false }, items: { type: Array, default: () => [] } }, | |||||
| emits: ['close', 'submit'], | |||||
| setup(props, { emit }) { | |||||
| const cart = useCartStore() | |||||
| function onQty(id, d) { cart.updateQty(id, (cart.items.find(i=>i.product.id===id)||{}).qty + d) } | |||||
| function onClear() { cart.clearCart() } | |||||
| function onSubmit() { emit('submit') } | |||||
| return { cart, onQty, onClear, onSubmit } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .cart-popup{position:fixed;bottom:0;left:0;right:0;background:var(--bg-card);border-radius:32rpx 32rpx 0 0;z-index:200;transform:translateY(100%);transition:transform .3s;max-height:60vh;display:flex;flex-direction:column} | |||||
| .cart-popup.show{transform:translateY(0)} | |||||
| .popup-header{display:flex;justify-content:space-between;padding:24rpx 28rpx;border-bottom:1px solid var(--border)} | |||||
| .popup-title{font-size:30rpx;font-weight:700;color:var(--text)} | |||||
| .popup-clear{font-size:24rpx;color:var(--red)} | |||||
| .popup-body{flex:1;padding:0 28rpx;max-height:400rpx} | |||||
| .cart-item{display:flex;align-items:center;padding:20rpx 0;border-bottom:1px solid var(--border);gap:16rpx} | |||||
| .cart-item-emoji{font-size:44rpx} | |||||
| .cart-item-info{flex:1} | |||||
| .cart-item-name{font-size:28rpx;font-weight:600;color:var(--text);display:block} | |||||
| .cart-item-alc{font-size:22rpx;color:var(--gold-light)} | |||||
| .cart-item-qty{display:flex;align-items:center;gap:20rpx} | |||||
| .qty-btn{width:48rpx;height:48rpx;border-radius:50%;background:var(--border);color:var(--text);display:flex;align-items:center;justify-content:center;font-size:32rpx;font-weight:700} | |||||
| .qty-num{font-size:28rpx;color:var(--text);min-width:40rpx;text-align:center} | |||||
| .popup-footer{display:flex;align-items:center;padding:20rpx 28rpx;padding-bottom:calc(20rpx + var(--safe-b));border-top:1px solid var(--border);gap:20rpx} | |||||
| .popup-total{font-size:28rpx;color:var(--text-dim)} | |||||
| .popup-submit{flex:1;height:80rpx;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--orange),var(--red));color:#fff;border:none;font-size:28rpx;font-weight:700} | |||||
| </style> | |||||
| @ -0,0 +1,24 @@ | |||||
| <template> | |||||
| <scroll-view class="cat-scroll" scroll-x :show-scrollbar="false"> | |||||
| <view class="cat-tabs"> | |||||
| <view v-for="c in categories" :key="c" class="cat-tab" :class="{ active: active === c }" @tap="$emit('change', c)"> | |||||
| {{ c === 'all' ? '🔥 全部' : c }} | |||||
| </view> | |||||
| </view> | |||||
| </scroll-view> | |||||
| </template> | |||||
| <script> | |||||
| export default { | |||||
| name: 'CategoryTab', | |||||
| props: { categories: { type: Array, default: () => [] }, active: { type: String, default: 'all' } }, | |||||
| emits: ['change'] | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .cat-scroll{white-space:nowrap;padding:12rpx 0} | |||||
| .cat-tabs{display:inline-flex;gap:12rpx;padding:0 20rpx} | |||||
| .cat-tab{display:inline-block;padding:14rpx 32rpx;border-radius:40rpx;border:1px solid var(--border);background:var(--bg-card);color:var(--text-dim);font-size:26rpx;font-weight:600;white-space:nowrap;flex-shrink:0} | |||||
| .cat-tab.active{background:linear-gradient(135deg,var(--orange),var(--gold));color:#fff;border-color:transparent;font-weight:700;box-shadow:0 4rpx 24rpx rgba(245,166,35,.3)} | |||||
| </style> | |||||
| @ -0,0 +1,54 @@ | |||||
| <template> | |||||
| <view class="chat-row" :class="{ 'chat-left': showLeft, 'chat-right': showRight }"> | |||||
| <view class="bubble" :class="bubbleClass"> | |||||
| <text class="bubble-content">{{ msg.content }}</text> | |||||
| <text class="bubble-time">{{ msg.time }}</text> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { computed } from 'vue' | |||||
| export default { | |||||
| name: 'ChatBubble', | |||||
| props: { | |||||
| msg: { type: Object, required: true }, | |||||
| viewer: { type: String, default: 'customer' } // 'customer' or 'staff' | |||||
| }, | |||||
| setup(props) { | |||||
| const showLeft = computed(() => { | |||||
| if (props.msg.senderType === 'system') return false | |||||
| // customer viewer: staff messages on left, customer on right | |||||
| // staff viewer: customer messages on left, staff on right | |||||
| if (props.viewer === 'staff') return props.msg.senderType === 'customer' | |||||
| return props.msg.senderType === 'staff' | |||||
| }) | |||||
| const showRight = computed(() => { | |||||
| if (props.msg.senderType === 'system') return false | |||||
| if (props.viewer === 'staff') return props.msg.senderType === 'staff' | |||||
| return props.msg.senderType === 'customer' | |||||
| }) | |||||
| const bubbleClass = computed(() => ({ | |||||
| 'bubble-left': showLeft.value, | |||||
| 'bubble-right': showRight.value, | |||||
| 'bubble-system': props.msg.senderType === 'system', | |||||
| })) | |||||
| return { showLeft, showRight, bubbleClass } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .chat-row{display:flex;padding:12rpx 20rpx} | |||||
| .chat-left{justify-content:flex-start} | |||||
| .chat-right{justify-content:flex-end} | |||||
| .bubble{max-width:70%;padding:16rpx 24rpx;border-radius:20rpx;font-size:28rpx} | |||||
| .bubble-left{background:var(--bg-card);border:1px solid var(--border)} | |||||
| .bubble-left .bubble-content{color:var(--text)} | |||||
| .bubble-right{background:linear-gradient(135deg,var(--orange),#FF8C5A)} | |||||
| .bubble-right .bubble-content{color:#fff} | |||||
| .bubble-system{background:rgba(74,144,217,.15);border:1px solid rgba(74,144,217,.3);max-width:90%;margin:0 auto} | |||||
| .bubble-system .bubble-content{color:var(--blue);font-size:24rpx} | |||||
| .bubble-time{font-size:20rpx;color:var(--text-muted);display:block;text-align:right;margin-top:8rpx} | |||||
| </style> | |||||
| @ -0,0 +1,50 @@ | |||||
| <template> | |||||
| <view class="order-card" :class="{ pulse: order.status === 0 }"> | |||||
| <view class="card-header"> | |||||
| <view> | |||||
| <text class="card-no">🎫 {{ order.cardNo }}</text> | |||||
| <text v-if="order.remindCount > 0" class="remind-badge">🔔催单×{{ order.remindCount }}</text> | |||||
| </view> | |||||
| <text class="status-badge" :class="statusClass">{{ statusLabel }}</text> | |||||
| </view> | |||||
| <view class="card-body"> | |||||
| <text class="card-items">{{ order.items.map(i=>i.name+'×'+i.qty).join('、') }}</text> | |||||
| <text v-if="order.note" class="card-note">💬 {{ order.note }}</text> | |||||
| <text class="card-time">{{ order.submittedAt }}</text> | |||||
| <slot name="actions"></slot> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { computed } from 'vue' | |||||
| import { ORDER_STATUS } from '@/utils/constants' | |||||
| export default { | |||||
| name: 'OrderCard', | |||||
| props: { order: { type: Object, required: true } }, | |||||
| setup(props) { | |||||
| const statusLabel = computed(() => ORDER_STATUS[props.order.status]?.label || '') | |||||
| const statusClass = computed(() => ORDER_STATUS[props.order.status]?.class || '') | |||||
| return { statusLabel, statusClass } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .order-card{background:var(--bg-card);border-radius:var(--radius);padding:24rpx;margin-bottom:20rpx;border:1px solid var(--border)} | |||||
| .order-card.pulse{animation:pulse-glow 2s ease-in-out infinite} | |||||
| .card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12rpx} | |||||
| .card-no{font-weight:700;color:var(--gold);font-size:28rpx} | |||||
| .remind-badge{font-size:20rpx;color:var(--red);font-weight:700;margin-left:8rpx} | |||||
| .status-badge{font-size:22rpx;font-weight:700;padding:4rpx 16rpx;border-radius:20rpx} | |||||
| .status-new{background:rgba(255,59,59,.15);color:#FF3B3B} | |||||
| .status-confirmed{background:rgba(74,144,217,.15);color:#4A90D9} | |||||
| .status-done{background:rgba(46,213,115,.15);color:#2ED573} | |||||
| .status-cancelled{background:rgba(116,125,140,.15);color:#747D8C} | |||||
| .card-body{padding-top:8rpx} | |||||
| .card-items{font-size:26rpx;color:var(--text);display:block} | |||||
| .card-note{font-size:24rpx;color:var(--orange);display:block;padding:8rpx 0} | |||||
| .card-time{font-size:22rpx;color:var(--text-muted);display:block;padding:4rpx 0} | |||||
| @keyframes pulse-glow{0%,100%{box-shadow:0 0 20rpx rgba(245,166,35,.1)}50%{box-shadow:0 0 40rpx rgba(245,166,35,.2)}} | |||||
| </style> | |||||
| @ -0,0 +1,34 @@ | |||||
| <template> | |||||
| <view class="preset-tags"> | |||||
| <view v-for="p in presets" :key="p.label" class="preset-tag" :class="{ active: selected.includes(p.label) }" @tap="onToggle(p.label)"> | |||||
| <text>{{ p.icon }} {{ p.label }}</text> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref } from 'vue' | |||||
| import { CONFIRM_PRESETS } from '@/utils/constants' | |||||
| export default { | |||||
| name: 'PresetTags', | |||||
| emits: ['update:modelValue'], | |||||
| props: { modelValue: { type: Array, default: () => [] } }, | |||||
| setup(props, { emit }) { | |||||
| const selected = ref([...props.modelValue]) | |||||
| function onToggle(label) { | |||||
| const i = selected.value.indexOf(label) | |||||
| if (i > -1) selected.value.splice(i, 1) | |||||
| else selected.value.push(label) | |||||
| emit('update:modelValue', selected.value) | |||||
| } | |||||
| return { presets: CONFIRM_PRESETS, selected, onToggle } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .preset-tags{display:flex;flex-wrap:wrap;gap:16rpx;padding:16rpx 0} | |||||
| .preset-tag{padding:12rpx 28rpx;border-radius:40rpx;background:var(--bg);border:1px solid var(--border);color:var(--text-dim);font-size:24rpx;transition:.2s} | |||||
| .preset-tag.active{border-color:var(--gold);color:var(--gold);background:rgba(245,166,35,.1)} | |||||
| </style> | |||||
| @ -0,0 +1,33 @@ | |||||
| <template> | |||||
| <view class="product-card" @tap="$emit('tap', product)"> | |||||
| <view class="card-img"> | |||||
| <text>{{ product.emoji }}</text> | |||||
| <text v-if="product.alc > 0" class="alc-tag">{{ product.alc }}%</text> | |||||
| </view> | |||||
| <view class="card-body"> | |||||
| <text class="card-name">{{ product.name }}</text> | |||||
| <text class="card-en">{{ product.en }}</text> | |||||
| <text class="card-desc">{{ product.desc }}</text> | |||||
| <view class="card-add" @tap.stop="$emit('add', product)">+</view> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| export default { | |||||
| name: 'ProductCard', | |||||
| props: { product: { type: Object, required: true } }, | |||||
| emits: ['tap', 'add'] | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .product-card{width:calc(50% - 12rpx);background:var(--bg-card);border-radius:var(--radius);overflow:hidden;margin-bottom:20rpx;border:1px solid var(--border);position:relative} | |||||
| .card-img{width:100%;height:220rpx;display:flex;align-items:center;justify-content:center;font-size:88rpx;position:relative;background:rgba(245,166,35,.04)} | |||||
| .alc-tag{position:absolute;top:16rpx;right:16rpx;background:rgba(0,0,0,.6);color:var(--gold-light);font-size:20rpx;padding:6rpx 16rpx;border-radius:20rpx;font-weight:700} | |||||
| .card-body{padding:16rpx 20rpx 24rpx;position:relative} | |||||
| .card-name{font-size:28rpx;font-weight:700;color:var(--text);display:block;margin-bottom:4rpx} | |||||
| .card-en{font-size:20rpx;color:var(--text-muted);display:block;margin-bottom:6rpx} | |||||
| .card-desc{font-size:22rpx;color:var(--text-dim);line-height:1.3;display:block;margin-bottom:4rpx} | |||||
| .card-add{position:absolute;bottom:16rpx;right:16rpx;width:56rpx;height:56rpx;border-radius:50%;background:var(--orange);color:#fff;font-size:32rpx;display:flex;align-items:center;justify-content:center;box-shadow:0 4rpx 20rpx rgba(255,107,53,.4)} | |||||
| </style> | |||||
| @ -0,0 +1,35 @@ | |||||
| <template> | |||||
| <view class="recipe-overlay" :class="{ show: visible }" @tap="onClose"> | |||||
| <view class="recipe-modal" @tap.stop> | |||||
| <text class="recipe-title">📖 全部配方 — 🎫 {{ cardNo }}</text> | |||||
| <view v-for="item in items" :key="item.name" class="recipe-item"> | |||||
| <text class="recipe-name">{{ item.emoji }} {{ item.name }} ×{{ item.qty }}</text> | |||||
| <text class="recipe-text">{{ item.recipe || '暂无配方信息' }}</text> | |||||
| </view> | |||||
| <button class="recipe-close" @tap="onClose">关闭</button> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| export default { | |||||
| name: 'RecipeModal', | |||||
| props: { visible: Boolean, cardNo: String, items: Array }, | |||||
| emits: ['close'], | |||||
| setup(props, { emit }) { | |||||
| function onClose() { emit('close') } | |||||
| return { onClose } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .recipe-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:400;align-items:center;justify-content:center;backdrop-filter:blur(4px)} | |||||
| .recipe-overlay.show{display:flex} | |||||
| .recipe-modal{width:640rpx;max-height:70vh;background:var(--bg-card);border-radius:var(--radius);border:2px solid var(--border);overflow-y:auto;padding:36rpx} | |||||
| .recipe-title{font-size:32rpx;font-weight:700;color:var(--gold);display:block;margin-bottom:24rpx} | |||||
| .recipe-item{background:rgba(245,166,35,.05);border:1px solid rgba(245,166,35,.15);border-radius:20rpx;padding:24rpx;margin-bottom:20rpx} | |||||
| .recipe-name{font-size:28rpx;font-weight:700;color:var(--text);display:block;margin-bottom:12rpx} | |||||
| .recipe-text{font-size:24rpx;color:var(--text-dim);line-height:1.7;white-space:pre-line} | |||||
| .recipe-close{width:100%;height:80rpx;border-radius:var(--radius-sm);margin-top:16rpx;background:var(--gold);color:#1A1A1A;border:none;font-size:28rpx;font-weight:700} | |||||
| </style> | |||||
| @ -0,0 +1,9 @@ | |||||
| import App from './App' | |||||
| import { createSSRApp } from 'vue' | |||||
| import { createPinia } from 'pinia' | |||||
| export function createApp() { | |||||
| const app = createSSRApp(App) | |||||
| app.use(createPinia()) | |||||
| return { app } | |||||
| } | |||||
| @ -0,0 +1,15 @@ | |||||
| { | |||||
| "name": "纯瘾大", | |||||
| "appid": "__UNI__BARORDER", | |||||
| "description": "纯瘾大酒吧点单小程序", | |||||
| "versionName": "1.0.0", | |||||
| "versionCode": "100", | |||||
| "transformPx": false, | |||||
| "app-plus": {}, | |||||
| "quickapp": {}, | |||||
| "mp-weixin": { | |||||
| "appid": "", | |||||
| "setting": { "urlCheck": true }, | |||||
| "usingComponents": true | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,19 @@ | |||||
| { | |||||
| "pages": [ | |||||
| { "path": "pages/index/index", "style": { "navigationBarTitleText": "纯瘾大" } }, | |||||
| { "path": "pages/menu/menu", "style": { "navigationBarTitleText": "酒水单" } }, | |||||
| { "path": "pages/confirm/confirm", "style": { "navigationBarTitleText": "确认下单" } }, | |||||
| { "path": "pages/orders/orders", "style": { "navigationBarTitleText": "我的订单" } }, | |||||
| { "path": "pages/chat/chat", "style": { "navigationBarTitleText": "对话" } }, | |||||
| { "path": "pages/staff/login", "style": { "navigationBarTitleText": "调酒师登录" } }, | |||||
| { "path": "pages/staff/board", "style": { "navigationBarTitleText": "订单看板" } }, | |||||
| { "path": "pages/staff/detail", "style": { "navigationBarTitleText": "订单详情" } }, | |||||
| { "path": "pages/staff/chat", "style": { "navigationBarTitleText": "客人对话" } } | |||||
| ], | |||||
| "globalStyle": { | |||||
| "navigationBarTextStyle": "white", | |||||
| "navigationBarTitleText": "纯瘾大", | |||||
| "navigationBarBackgroundColor": "#0D0D0D", | |||||
| "backgroundColor": "#0D0D0D" | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,81 @@ | |||||
| <template> | |||||
| <view class="page-chat"> | |||||
| <view class="nav-bar"> | |||||
| <text class="nav-back" @tap="goBack">←</text> | |||||
| <text class="nav-title">💬 调酒师</text> | |||||
| <text></text> | |||||
| </view> | |||||
| <scroll-view class="chat-list" scroll-y :scroll-into-view="scrollToId"> | |||||
| <ChatBubble v-for="m in messages" :key="m.id" :msg="m" /> | |||||
| <view id="chat-bottom"></view> | |||||
| </scroll-view> | |||||
| <view class="chat-input-bar"> | |||||
| <input class="chat-input" v-model="inputText" placeholder="说点什么..." @confirm="sendMsg" /> | |||||
| <button class="chat-send" @tap="sendMsg">发送</button> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref, onMounted, onUnmounted, nextTick } from 'vue' | |||||
| import { useCardStore } from '@/stores/card' | |||||
| import { get, post } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import { startPoll, stopPoll } from '@/utils/poller' | |||||
| import ChatBubble from '@/components/ChatBubble.vue' | |||||
| export default { | |||||
| components: { ChatBubble }, | |||||
| setup() { | |||||
| const card = useCardStore() | |||||
| const messages = ref([]) | |||||
| const inputText = ref('') | |||||
| const scrollToId = ref('') | |||||
| let lastId = 0 | |||||
| async function loadMessages() { | |||||
| try { | |||||
| const res = await get(API.MESSAGE_LIST, { card_no: card.cardNo, since: lastId }) | |||||
| if (Array.isArray(res) && res.length > 0) { | |||||
| messages.value = [...messages.value, ...res] | |||||
| lastId = res[res.length - 1].id | |||||
| scrollToId.value = 'chat-bottom' | |||||
| } | |||||
| } catch (e) {} | |||||
| } | |||||
| async function sendMsg() { | |||||
| const content = inputText.value.trim() | |||||
| if (!content) return | |||||
| inputText.value = '' | |||||
| try { | |||||
| await post(API.MESSAGE_SEND, { cardNo: card.cardNo, senderType: 'customer', content }) | |||||
| await loadMessages() | |||||
| } catch (e) {} | |||||
| } | |||||
| function goBack() { uni.navigateBack() } | |||||
| onMounted(() => { | |||||
| loadMessages() | |||||
| startPoll('chat', loadMessages, 5000) | |||||
| }) | |||||
| onUnmounted(() => stopPoll('chat')) | |||||
| return { messages, inputText, scrollToId, sendMsg, goBack } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-chat{min-height:100vh;display:flex;flex-direction:column;background:var(--bg)} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border)} | |||||
| .nav-back{font-size:36rpx;color:var(--text-dim)} | |||||
| .nav-title{font-size:32rpx;font-weight:800;color:var(--gold)} | |||||
| .chat-list{flex:1;padding:16rpx 0} | |||||
| .chat-input-bar{display:flex;align-items:center;padding:16rpx 24rpx;padding-bottom:calc(16rpx + var(--safe-b));background:var(--bg-card);border-top:1px solid var(--border);gap:16rpx} | |||||
| .chat-input{flex:1;height:76rpx;background:var(--bg);border-radius:16rpx;padding:0 28rpx;font-size:28rpx;color:var(--text)} | |||||
| .chat-send{width:104rpx;height:72rpx;border-radius:16rpx;background:var(--blue);color:#fff;border:none;font-size:26rpx;font-weight:700} | |||||
| </style> | |||||
| @ -0,0 +1,103 @@ | |||||
| <template> | |||||
| <view class="page-confirm"> | |||||
| <view class="nav-bar"> | |||||
| <text class="nav-back" @tap="goBack">←</text> | |||||
| <text class="nav-title">确认下单</text> | |||||
| <text></text> | |||||
| </view> | |||||
| <scroll-view class="confirm-body" scroll-y> | |||||
| <view class="section"> | |||||
| <text class="section-title">🍷 订单清单</text> | |||||
| <view v-for="item in cart.items" :key="item.product.id" class="confirm-item"> | |||||
| <text class="ci-emoji">{{ item.product.emoji }}</text> | |||||
| <view class="ci-info"> | |||||
| <text class="ci-name">{{ item.product.name }}</text> | |||||
| <text class="ci-desc">{{ item.product.desc }}</text> | |||||
| </view> | |||||
| <text class="ci-qty">×{{ item.qty }}</text> | |||||
| </view> | |||||
| </view> | |||||
| <view class="section"> | |||||
| <text class="section-title">🏷 快捷备注</text> | |||||
| <PresetTags v-model="presets" /> | |||||
| </view> | |||||
| <view class="section"> | |||||
| <text class="section-title">✏️ 补充备注</text> | |||||
| <input class="note-input" v-model="note" placeholder="如:多冰、少甜..." maxlength="100" /> | |||||
| </view> | |||||
| <view class="total-line"> | |||||
| <text>共 {{ cart.totalCount }} 件</text> | |||||
| </view> | |||||
| </scroll-view> | |||||
| <view class="confirm-footer"> | |||||
| <button class="bell-btn" @tap="onSubmit" :loading="submitting">🔔 摇铃下单</button> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref } from 'vue' | |||||
| import { useCartStore } from '@/stores/cart' | |||||
| import { useCardStore } from '@/stores/card' | |||||
| import { post } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import PresetTags from '@/components/PresetTags.vue' | |||||
| export default { | |||||
| components: { PresetTags }, | |||||
| setup() { | |||||
| const cart = useCartStore() | |||||
| const card = useCardStore() | |||||
| const presets = ref([]) | |||||
| const note = ref('') | |||||
| const submitting = ref(false) | |||||
| function goBack() { uni.navigateBack() } | |||||
| async function onSubmit() { | |||||
| if (cart.items.length === 0) { | |||||
| uni.showToast({ title: '购物车为空', icon: 'none' }) | |||||
| return | |||||
| } | |||||
| submitting.value = true | |||||
| try { | |||||
| const fullNote = [...presets.value, note.value].filter(Boolean).join(' ') | |||||
| const items = cart.items.map(i => ({ productId: i.product.id, qty: i.qty })) | |||||
| await post(API.ORDER_SUBMIT, { cardNo: card.cardNo, items, note: fullNote }) | |||||
| cart.clearCart() | |||||
| uni.showToast({ title: '下单成功 🎉', icon: 'success' }) | |||||
| setTimeout(() => { uni.redirectTo({ url: '/pages/orders/orders' }) }, 1500) | |||||
| } catch (e) {} | |||||
| submitting.value = false | |||||
| } | |||||
| return { cart, presets, note, submitting, goBack, onSubmit } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-confirm{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border);flex-shrink:0} | |||||
| .nav-back{font-size:36rpx;color:var(--text-dim)} | |||||
| .nav-title{font-size:32rpx;font-weight:800;color:var(--gold)} | |||||
| .confirm-body{flex:1;padding:0 28rpx} | |||||
| .section{padding:20rpx 0;border-bottom:1px solid var(--border)} | |||||
| .section-title{font-size:28rpx;font-weight:700;color:var(--gold);display:block;margin-bottom:16rpx} | |||||
| .confirm-item{display:flex;align-items:center;padding:16rpx 0;gap:16rpx} | |||||
| .ci-emoji{font-size:48rpx} | |||||
| .ci-info{flex:1} | |||||
| .ci-name{font-size:28rpx;font-weight:600;color:var(--text);display:block} | |||||
| .ci-desc{font-size:22rpx;color:var(--text-dim)} | |||||
| .ci-qty{font-size:28rpx;font-weight:700;color:var(--gold)} | |||||
| .note-input{width:100%;height:80rpx;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0 24rpx;font-size:28rpx;color:var(--text);margin-top:8rpx} | |||||
| .total-line{padding:24rpx 0;font-size:28rpx;color:var(--text-dim)} | |||||
| .confirm-footer{padding:20rpx 28rpx;padding-bottom:calc(20rpx + var(--safe-b));flex-shrink:0} | |||||
| .bell-btn{width:100%;height:96rpx;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--gold-dark),var(--gold));color:#1A1A1A;border:none;font-size:32rpx;font-weight:800;display:flex;align-items:center;justify-content:center;animation:pulse-glow 2s ease-in-out infinite} | |||||
| @keyframes pulse-glow{0%,100%{box-shadow:0 0 20rpx rgba(245,166,35,.3)}50%{box-shadow:0 0 40rpx rgba(245,166,35,.6)}} | |||||
| </style> | |||||
| @ -0,0 +1,164 @@ | |||||
| <template> | |||||
| <view class="page-index"> | |||||
| <view class="nav-bar"> | |||||
| <view class="nav-left"> | |||||
| <text class="nav-logo">🍸</text> | |||||
| <text class="nav-brand">纯瘾大</text> | |||||
| </view> | |||||
| <view class="nav-right"> | |||||
| <text class="my-btn" @tap="goMyOrders">我的</text> | |||||
| </view> | |||||
| </view> | |||||
| <view class="landing-bg"> | |||||
| <view class="landing-decor" style="top:10%;left:8%">🍺</view> | |||||
| <view class="landing-decor" style="top:8%;right:10%">🍸</view> | |||||
| <view class="landing-decor" style="top:55%;left:5%;font-size:56rpx">🍋</view> | |||||
| <view class="landing-decor" style="top:48%;right:8%">🥃</view> | |||||
| <view class="landing-decor" style="bottom:25%;left:14%;font-size:48rpx">🧊</view> | |||||
| <text class="landing-logo">🍸</text> | |||||
| <text class="landing-title">纯瘾大</text> | |||||
| <text class="landing-sub">唤醒你的专属调酒师</text> | |||||
| <button class="btn-primary" @tap="showModal = true">🎫 领取号码牌</button> | |||||
| </view> | |||||
| <!-- 员工入口 --> | |||||
| <view class="staff-link"> | |||||
| <text @tap="goStaffLogin">🔐 员工入口</text> | |||||
| </view> | |||||
| <!-- 号码牌弹窗 (点击按钮才显示) --> | |||||
| <view class="modal-mask" :class="{ show: showModal }" @tap="onMaskTap"> | |||||
| <view class="card-modal" @tap.stop> | |||||
| <!-- 初始状态 --> | |||||
| <view v-if="!generated" class="modal-header"> | |||||
| <text class="modal-icon">🎫</text> | |||||
| <text class="modal-title">领取你的号码牌</text> | |||||
| <text class="modal-desc">一个号码开启今晚的微醺之旅</text> | |||||
| </view> | |||||
| <!-- 已生成状态 --> | |||||
| <view v-else class="card-generated"> | |||||
| <text class="modal-title">🎉 这是你的专属号码</text> | |||||
| <view class="card-number-big"> | |||||
| <text>{{ cardNo }}</text> | |||||
| </view> | |||||
| <text class="card-remember">⚠️ 请记住!点任意位置跳过</text> | |||||
| </view> | |||||
| <!-- 输入区域 --> | |||||
| <view v-if="!generated" class="modal-body"> | |||||
| <view class="card-input-group"> | |||||
| <input class="card-input" v-model="inputNo" placeholder="输入号码" maxlength="4" /> | |||||
| <button class="card-confirm-btn" @tap="onCheck">确认</button> | |||||
| </view> | |||||
| <view class="card-divider"> | |||||
| <text>或</text> | |||||
| </view> | |||||
| </view> | |||||
| <view v-if="!generated" class="modal-footer"> | |||||
| <button class="btn-primary" @tap="onGenerate">🎫 领取新号码牌</button> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref } from 'vue' | |||||
| import { post, get } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import { useCardStore } from '@/stores/card' | |||||
| export default { | |||||
| setup() { | |||||
| const card = useCardStore() | |||||
| const showModal = ref(false) | |||||
| const generated = ref(false) | |||||
| const inputNo = ref('') | |||||
| const cardNo = ref('') | |||||
| async function onGenerate() { | |||||
| try { | |||||
| const res = await post(API.CARD_GENERATE) | |||||
| cardNo.value = res.cardNo | |||||
| card.setCardNo(res.cardNo) | |||||
| generated.value = true | |||||
| setTimeout(() => { | |||||
| showModal.value = false | |||||
| generated.value = false | |||||
| uni.navigateTo({ url: '/pages/menu/menu' }) | |||||
| }, 3000) | |||||
| } catch (e) {} | |||||
| } | |||||
| async function onCheck() { | |||||
| const no = inputNo.value.trim().toUpperCase() | |||||
| if (!no) return | |||||
| // 1997 → 员工登录 | |||||
| if (no === '1997') { | |||||
| showModal.value = false | |||||
| uni.navigateTo({ url: '/pages/staff/login' }) | |||||
| return | |||||
| } | |||||
| try { | |||||
| await get(API.CARD_CHECK, { no }) | |||||
| card.setCardNo(no) | |||||
| showModal.value = false | |||||
| uni.navigateTo({ url: '/pages/menu/menu' }) | |||||
| } catch (e) {} | |||||
| } | |||||
| function onMaskTap() { | |||||
| if (generated.value) { | |||||
| showModal.value = false | |||||
| generated.value = false | |||||
| uni.navigateTo({ url: '/pages/menu/menu' }) | |||||
| } | |||||
| } | |||||
| function goMyOrders() { uni.navigateTo({ url: '/pages/orders/orders' }) } | |||||
| function goStaffLogin() { uni.navigateTo({ url: '/pages/staff/login' }) } | |||||
| return { showModal, generated, inputNo, cardNo, onGenerate, onCheck, onMaskTap, goMyOrders, goStaffLogin } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-index{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border);flex-shrink:0} | |||||
| .nav-left{display:flex;align-items:center;gap:16rpx} | |||||
| .nav-logo{font-size:44rpx} | |||||
| .nav-brand{font-size:40rpx;font-weight:900;color:var(--gold);letter-spacing:4rpx} | |||||
| .my-btn{font-size:26rpx;color:var(--gold);font-weight:600} | |||||
| .landing-bg{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden;background:radial-gradient(ellipse at center,#1A1A1A 0%,var(--bg) 70%);padding:40rpx} | |||||
| .landing-decor{position:absolute;font-size:80rpx;opacity:.08;animation:float 6s ease-in-out infinite;pointer-events:none} | |||||
| .landing-logo{font-size:160rpx;animation:float 3s ease-in-out infinite} | |||||
| .landing-title{font-size:56rpx;font-weight:900;color:var(--gold);margin-top:24rpx;letter-spacing:8rpx} | |||||
| .landing-sub{font-size:28rpx;color:var(--text-dim);margin:12rpx 0 80rpx;letter-spacing:2rpx} | |||||
| .btn-primary{width:440rpx;height:96rpx;border-radius:24rpx;background:linear-gradient(135deg,var(--gold-dark),var(--gold),var(--gold-light));color:#1A1A1A;border:none;font-size:32rpx;font-weight:800;display:flex;align-items:center;justify-content:center;box-shadow:0 8rpx 40rpx rgba(245,166,35,.35)} | |||||
| /* 员工入口 */ | |||||
| .staff-link{padding:20rpx;text-align:center;flex-shrink:0} | |||||
| .staff-link text{color:var(--text-muted);font-size:22rpx;letter-spacing:2rpx} | |||||
| /* 号码牌弹窗 (严格对齐UI CSS) */ | |||||
| .modal-mask{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.75);z-index:300;align-items:center;justify-content:center} | |||||
| .modal-mask.show{display:flex} | |||||
| .card-modal{width:580rpx;background:var(--bg-card);border-radius:40rpx;border:2px solid var(--border);overflow:hidden;animation:bounce-in .5s ease} | |||||
| .modal-header{padding:56rpx 48rpx 32rpx;text-align:center} | |||||
| .modal-icon{font-size:104rpx;animation:float 2s ease-in-out infinite} | |||||
| .modal-title{font-size:36rpx;font-weight:800;color:var(--gold);margin-top:16rpx} | |||||
| .modal-desc{font-size:26rpx;color:var(--text-dim);margin-top:8rpx;line-height:1.6} | |||||
| .card-generated{text-align:center;padding:24rpx 48rpx 40rpx} | |||||
| .card-number-big{padding:32rpx;background:linear-gradient(135deg,rgba(245,166,35,.1),rgba(245,166,35,.05));border-radius:28rpx;border:2rpx dashed rgba(245,166,35,.3)} | |||||
| .card-number-big text{font-size:84rpx;font-weight:900;letter-spacing:20rpx;color:var(--gold)} | |||||
| .card-remember{font-size:26rpx;color:var(--red);margin-top:20rpx;font-weight:600;display:block} | |||||
| .modal-body{padding:0 48rpx 40rpx} | |||||
| .card-input-group{display:flex;align-items:stretch;margin-bottom:24rpx;border:4rpx solid var(--border);border-radius:20rpx;overflow:hidden;background:var(--bg)} | |||||
| .card-input{flex:1;min-width:0;height:92rpx;background:transparent;border:none;outline:none;padding:0 28rpx;font-size:36rpx;font-weight:700;text-align:center;letter-spacing:8rpx;color:var(--gold)} | |||||
| .card-confirm-btn{flex-shrink:0;width:128rpx;border:none;background:linear-gradient(135deg,var(--gold-dark),var(--gold));color:#1A1A1A;font-size:28rpx;font-weight:700;display:flex;align-items:center;justify-content:center} | |||||
| .card-divider{text-align:center;color:var(--text-muted);font-size:24rpx;margin:20rpx 0;position:relative} | |||||
| .modal-footer{padding:0 48rpx 48rpx} | |||||
| </style> | |||||
| @ -0,0 +1,187 @@ | |||||
| <template> | |||||
| <view class="page-menu"> | |||||
| <view class="nav-bar"> | |||||
| <view class="nav-left"> | |||||
| <text class="nav-logo">🍸</text> | |||||
| <text class="nav-brand">纯瘾大</text> | |||||
| <text v-if="card.cardNo" class="card-badge">🎫 {{ card.cardNo }}</text> | |||||
| </view> | |||||
| <view class="nav-right"> | |||||
| <text class="my-btn" @tap="goMyOrders">我的</text> | |||||
| </view> | |||||
| </view> | |||||
| <scroll-view class="menu-scroll" scroll-y> | |||||
| <BannerSwiper /> | |||||
| <CategoryTab :categories="categories" :active="activeCate" @change="onCateChange" /> | |||||
| <view class="product-grid" v-if="products.length > 0"> | |||||
| <ProductCard v-for="p in products" :key="p.id" :product="p" @add="onAddToCart" /> | |||||
| </view> | |||||
| <view v-else class="empty-state"> | |||||
| <text class="empty-icon">🍹</text> | |||||
| <text class="empty-text">暂无商品</text> | |||||
| </view> | |||||
| <view style="height:200rpx"></view> | |||||
| </scroll-view> | |||||
| <!-- 底部栏: 红酒杯(左) + 出餐铃(中) --> | |||||
| <view class="bottom-bar"> | |||||
| <view class="wine-glass" @tap="showCart = true"> | |||||
| <text class="glass-icon">🍷</text> | |||||
| <text v-if="cart.totalCount > 0" class="glass-count">{{ cart.totalCount }}</text> | |||||
| </view> | |||||
| <view class="press-bell-wrapper" @tap="onBellPress"> | |||||
| <view class="press-bell-ring" :class="{ waving: bellWaving }"></view> | |||||
| <view class="press-bell-dome" :class="{ pressed: bellPressed }"></view> | |||||
| <view class="press-bell-base"></view> | |||||
| <text class="bell-label">出餐铃</text> | |||||
| </view> | |||||
| </view> | |||||
| <!-- 购物车弹层 (点击遮罩关闭) --> | |||||
| <view class="cart-overlay" :class="{ show: showCart }" @tap="showCart = false"> | |||||
| <view class="cart-popup" @tap.stop> | |||||
| <view class="cart-header"> | |||||
| <text class="cart-title">购物车 ({{ cart.items.length }})</text> | |||||
| <text class="clear-btn" @tap="cart.clearCart()">清空</text> | |||||
| </view> | |||||
| <scroll-view class="cart-items" scroll-y> | |||||
| <view v-for="item in cart.items" :key="item.product.id" class="cart-item"> | |||||
| <view class="item-img"><text>{{ item.product.emoji }}</text></view> | |||||
| <view class="item-info"> | |||||
| <text class="item-name">{{ item.product.name }}</text> | |||||
| </view> | |||||
| <view class="qty-control"> | |||||
| <text class="qty-btn" @tap="cart.updateQty(item.product.id, item.qty - 1)">−</text> | |||||
| <text class="qty-num">{{ item.qty }}</text> | |||||
| <text class="qty-btn" @tap="cart.updateQty(item.product.id, item.qty + 1)">+</text> | |||||
| </view> | |||||
| </view> | |||||
| </scroll-view> | |||||
| <view class="cart-footer"> | |||||
| <button class="next-btn" @tap="onGoConfirm" :disabled="cart.items.length===0">去呼唤调酒师</button> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref, onMounted } from 'vue' | |||||
| import { useCardStore } from '@/stores/card' | |||||
| import { useCartStore } from '@/stores/cart' | |||||
| import { get } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import BannerSwiper from '@/components/BannerSwiper.vue' | |||||
| import CategoryTab from '@/components/CategoryTab.vue' | |||||
| import ProductCard from '@/components/ProductCard.vue' | |||||
| export default { | |||||
| components: { BannerSwiper, CategoryTab, ProductCard }, | |||||
| setup() { | |||||
| const card = useCardStore() | |||||
| const cart = useCartStore() | |||||
| const categories = ref(['all']) | |||||
| const activeCate = ref('all') | |||||
| const products = ref([]) | |||||
| const showCart = ref(false) | |||||
| const bellPressed = ref(false) | |||||
| const bellWaving = ref(false) | |||||
| async function loadCategories() { | |||||
| try { | |||||
| const res = await get(API.MENU_CATEGORIES) | |||||
| if (Array.isArray(res)) categories.value = ['all', ...res] | |||||
| } catch (e) {} | |||||
| } | |||||
| async function loadProducts(cate) { | |||||
| try { | |||||
| const res = await get(API.MENU_PRODUCTS, { cate: cate === 'all' ? '' : cate }) | |||||
| if (Array.isArray(res)) products.value = res | |||||
| } catch (e) {} | |||||
| } | |||||
| function onCateChange(cate) { activeCate.value = cate; loadProducts(cate) } | |||||
| function onAddToCart(product) { | |||||
| cart.addItem(product) | |||||
| uni.showToast({ title: '已加入 🍷', icon: 'none', duration: 800 }) | |||||
| } | |||||
| function onBellPress() { | |||||
| if (cart.items.length === 0) { | |||||
| uni.showToast({ title: '购物车空空', icon: 'none' }) | |||||
| return | |||||
| } | |||||
| bellPressed.value = true | |||||
| bellWaving.value = true | |||||
| setTimeout(() => { bellPressed.value = false; bellWaving.value = false }, 600) | |||||
| setTimeout(() => { uni.navigateTo({ url: '/pages/confirm/confirm' }) }, 400) | |||||
| } | |||||
| function onGoConfirm() { | |||||
| showCart.value = false | |||||
| uni.navigateTo({ url: '/pages/confirm/confirm' }) | |||||
| } | |||||
| function goMyOrders() { uni.navigateTo({ url: '/pages/orders/orders' }) } | |||||
| onMounted(async () => { | |||||
| await loadCategories() | |||||
| await loadProducts('all') | |||||
| }) | |||||
| return { card, cart, categories, activeCate, products, showCart, bellPressed, bellWaving, onCateChange, onAddToCart, onBellPress, onGoConfirm, goMyOrders } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-menu{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border);flex-shrink:0;position:sticky;top:0;z-index:100;background:var(--bg)} | |||||
| .nav-left{display:flex;align-items:center;gap:16rpx} | |||||
| .nav-logo{font-size:44rpx} | |||||
| .nav-brand{font-size:40rpx;font-weight:900;color:var(--gold);letter-spacing:4rpx} | |||||
| .card-badge{background:linear-gradient(135deg,var(--gold-dark),var(--gold));color:#1A1A1A;padding:10rpx 24rpx;border-radius:28rpx;font-weight:800;font-size:26rpx;letter-spacing:6rpx;animation:card-appear-top .5s ease} | |||||
| .my-btn{font-size:26rpx;color:var(--gold);font-weight:600} | |||||
| .menu-scroll{flex:1} | |||||
| .product-grid{display:flex;flex-wrap:wrap;justify-content:space-between;padding:16rpx 20rpx} | |||||
| .empty-state{display:flex;flex-direction:column;align-items:center;padding:120rpx 0} | |||||
| .empty-icon{font-size:112rpx;opacity:.3} | |||||
| .empty-text{font-size:28rpx;color:var(--text-muted);margin-top:16rpx} | |||||
| /* 底部栏: 出餐铃 */ | |||||
| .bottom-bar{position:sticky;bottom:0;background:var(--bg-card);border-top:1px solid var(--border);padding:24rpx 32rpx;padding-bottom:calc(24rpx + var(--safe-b));display:flex;align-items:center;justify-content:center;z-index:100;min-height:190rpx;flex-shrink:0} | |||||
| .wine-glass{position:absolute;left:40rpx;bottom:calc(40rpx + var(--safe-b));display:flex;flex-direction:column;align-items:center} | |||||
| .glass-icon{font-size:64rpx;transition:.3s} | |||||
| .glass-count{position:absolute;top:-8rpx;right:-16rpx;min-width:36rpx;height:36rpx;border-radius:18rpx;background:var(--red);color:#fff;font-size:20rpx;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 8rpx} | |||||
| .press-bell-wrapper{display:flex;flex-direction:column;align-items:center;position:relative} | |||||
| .press-bell-base{width:140rpx;height:28rpx;border-radius:14rpx;background:linear-gradient(180deg,#8B7355,#5C4033);box-shadow:0 4rpx 12rpx rgba(0,0,0,.5)} | |||||
| .press-bell-dome{width:120rpx;height:96rpx;border-radius:60rpx 60rpx 12rpx 12rpx;background:linear-gradient(180deg,#E8C84A,#C8962A,#A07020);margin-bottom:-4rpx;position:relative;box-shadow:0 8rpx 24rpx rgba(200,150,42,.5),inset 0 4rpx 0 rgba(255,255,255,.3);transition:transform .15s ease;transform-origin:bottom center} | |||||
| .press-bell-dome::after{content:'';position:absolute;top:16rpx;left:50%;transform:translateX(-50%);width:32rpx;height:36rpx;border-radius:50%;background:linear-gradient(180deg,#F5DEB3,#D4A030);box-shadow:0 2rpx 6rpx rgba(0,0,0,.3)} | |||||
| .press-bell-dome.pressed{animation:bell-press .4s ease} | |||||
| .press-bell-ring{position:absolute;top:8rpx;left:50%;transform:translateX(-50%);width:100rpx;height:100rpx;border-radius:50%;pointer-events:none;border:4rpx solid rgba(245,166,35,.4)} | |||||
| .press-bell-ring.waving{animation:bell-ring-wave .6s ease-out forwards} | |||||
| .bell-label{font-size:22rpx;font-weight:700;color:var(--gold);letter-spacing:2rpx;margin-top:12rpx} | |||||
| /* 购物车弹层 */ | |||||
| .cart-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:200} | |||||
| .cart-overlay.show{display:block} | |||||
| .cart-popup{position:fixed;bottom:0;left:0;right:0;background:var(--bg-card);border-radius:40rpx 40rpx 0 0;max-height:55vh;display:flex;flex-direction:column;transform:translateY(100%);transition:transform .35s cubic-bezier(.4,0,.2,1);border-top:4rpx solid var(--gold)} | |||||
| .cart-overlay.show .cart-popup{transform:translateY(0)} | |||||
| .cart-header{display:flex;align-items:center;justify-content:space-between;padding:32rpx 40rpx 24rpx;flex-shrink:0} | |||||
| .cart-title{font-size:32rpx;font-weight:700;color:var(--gold)} | |||||
| .clear-btn{font-size:26rpx;color:var(--text-dim)} | |||||
| .cart-items{flex:1;overflow-y:auto;padding:0 40rpx;max-height:400rpx} | |||||
| .cart-item{display:flex;align-items:center;padding:24rpx 0;border-bottom:1px solid var(--border);gap:20rpx} | |||||
| .item-img{width:80rpx;height:80rpx;border-radius:16rpx;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:40rpx;flex-shrink:0} | |||||
| .item-info{flex:1;min-width:0} | |||||
| .item-name{font-size:28rpx;font-weight:600;color:var(--text)} | |||||
| .qty-control{display:flex;align-items:center;gap:0} | |||||
| .qty-btn{width:52rpx;height:52rpx;border-radius:50%;border:1px solid var(--border);background:var(--bg);font-size:30rpx;display:flex;align-items:center;justify-content:center;color:var(--text-dim)} | |||||
| .qty-num{width:56rpx;text-align:center;font-size:28rpx;font-weight:700;color:var(--gold)} | |||||
| .cart-footer{padding:24rpx 40rpx 40rpx;flex-shrink:0} | |||||
| .next-btn{width:100%;height:96rpx;border-radius:28rpx;background:linear-gradient(135deg,var(--orange),var(--red));color:#fff;border:none;font-size:32rpx;font-weight:800;box-shadow:0 8rpx 40rpx rgba(255,107,53,.35)} | |||||
| </style> | |||||
| @ -0,0 +1,83 @@ | |||||
| <template> | |||||
| <view class="page-orders"> | |||||
| <view class="nav-bar"> | |||||
| <text class="nav-back" @tap="goBack">←</text> | |||||
| <text class="nav-title">我的订单</text> | |||||
| <text></text> | |||||
| </view> | |||||
| <scroll-view class="orders-body" scroll-y v-if="orders.length > 0"> | |||||
| <OrderCard v-for="o in orders" :key="o.id" :order="o"> | |||||
| <template #actions> | |||||
| <view class="card-actions"> | |||||
| <button v-if="o.status===0" class="action-btn action-remind" @tap="onRemind(o.id)">🔔 催单</button> | |||||
| <button class="action-btn action-chat" @tap="onChat">💬 联系调酒师</button> | |||||
| </view> | |||||
| </template> | |||||
| </OrderCard> | |||||
| </scroll-view> | |||||
| <view v-else class="empty-state"> | |||||
| <text class="empty-icon">📋</text> | |||||
| <text class="empty-text">暂无订单</text> | |||||
| <button class="btn-back-menu" @tap="goMenu">去点酒 🍸</button> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref, onMounted, onUnmounted } from 'vue' | |||||
| import { useCardStore } from '@/stores/card' | |||||
| import { get, post } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import { startPoll, stopPoll } from '@/utils/poller' | |||||
| import OrderCard from '@/components/OrderCard.vue' | |||||
| export default { | |||||
| components: { OrderCard }, | |||||
| setup() { | |||||
| const card = useCardStore() | |||||
| const orders = ref([]) | |||||
| async function loadOrders() { | |||||
| try { | |||||
| const res = await get(API.ORDER_LIST, { card_no: card.cardNo }) | |||||
| if (Array.isArray(res)) orders.value = res | |||||
| } catch (e) {} | |||||
| } | |||||
| async function onRemind(id) { | |||||
| try { | |||||
| await post(API.ORDER_REMIND, { id, card_no: card.cardNo }) | |||||
| uni.showToast({ title: '已催单 🔔', icon: 'none' }) | |||||
| loadOrders() | |||||
| } catch (e) {} | |||||
| } | |||||
| function onChat() { uni.navigateTo({ url: '/pages/chat/chat' }) } | |||||
| function goBack() { uni.navigateBack() } | |||||
| function goMenu() { uni.navigateBack() } | |||||
| onMounted(() => { loadOrders(); startPoll('orders', loadOrders, 15000) }) | |||||
| onUnmounted(() => stopPoll('orders')) | |||||
| return { orders, onRemind, onChat, goBack, goMenu } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-orders{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border);flex-shrink:0} | |||||
| .nav-back{font-size:36rpx;color:var(--text-dim)} | |||||
| .nav-title{font-size:32rpx;font-weight:800;color:var(--gold)} | |||||
| .orders-body{flex:1;padding:20rpx} | |||||
| .card-actions{display:flex;gap:16rpx;padding-top:16rpx} | |||||
| .action-btn{padding:12rpx 28rpx;border-radius:24rpx;font-size:24rpx;font-weight:600;border:none} | |||||
| .action-remind{background:rgba(255,59,59,.15);color:var(--red)} | |||||
| .action-chat{background:rgba(74,144,217,.15);color:var(--blue)} | |||||
| .empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center} | |||||
| .empty-icon{font-size:112rpx;opacity:.3} | |||||
| .empty-text{font-size:28rpx;color:var(--text-muted);margin:16rpx 0} | |||||
| .btn-back-menu{margin-top:40rpx;padding:16rpx 48rpx;border-radius:40rpx;background:var(--gold);color:#1A1A1A;font-size:28rpx;font-weight:700;border:none} | |||||
| </style> | |||||
| @ -0,0 +1,107 @@ | |||||
| <template> | |||||
| <view class="page-staff-board"> | |||||
| <view class="nav-bar"> | |||||
| <view class="nav-left"> | |||||
| <text class="nav-logo">🍸</text> | |||||
| <text class="nav-brand">纯瘾大</text> | |||||
| </view> | |||||
| <view class="nav-right"> | |||||
| <text class="logout-btn" @tap="onLogout">退出</text> | |||||
| </view> | |||||
| </view> | |||||
| <view class="staff-info">🧑🍳 {{ staff.nickname }} — 别摸鱼了!</view> | |||||
| <view class="board-tabs"> | |||||
| <view v-for="t in tabs" :key="t.status" class="board-tab" :class="{ active: activeTab === t.status }" @tap="switchTab(t.status)"> | |||||
| {{ t.label }} | |||||
| <text v-if="t.status !== 2" class="tab-count">{{ counts[t.status] }}</text> | |||||
| </view> | |||||
| </view> | |||||
| <scroll-view class="board-body" scroll-y v-if="orders.length > 0"> | |||||
| <OrderCard v-for="o in orders" :key="o.id" :order="o"> | |||||
| <template #actions> | |||||
| <view class="board-actions"> | |||||
| <button class="ba-btn ba-gold" @tap="onDetail(o)">📖 配方</button> | |||||
| <button class="ba-btn ba-blue" @tap="onChat(o)">💬 聊天</button> | |||||
| <button v-if="o.status===0" class="ba-btn ba-gold" @tap="onConfirm(o.id)">✅ 接单</button> | |||||
| <button v-if="o.status===1" class="ba-btn ba-gold" @tap="onDone(o.id)">✔ 结单</button> | |||||
| </view> | |||||
| </template> | |||||
| </OrderCard> | |||||
| </scroll-view> | |||||
| <view v-else class="empty-state"> | |||||
| <text class="empty-icon">📋</text> | |||||
| <text class="empty-text">暂无订单</text> | |||||
| </view> | |||||
| <RecipeModal :visible="recipeVisible" :cardNo="recipeCardNo" :items="recipeItems" @close="recipeVisible=false" /> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref, onMounted, onUnmounted } from 'vue' | |||||
| import { useStaffStore } from '@/stores/staff' | |||||
| import { get, post } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import { startPoll, stopPoll } from '@/utils/poller' | |||||
| import OrderCard from '@/components/OrderCard.vue' | |||||
| import RecipeModal from '@/components/RecipeModal.vue' | |||||
| export default { | |||||
| components: { OrderCard, RecipeModal }, | |||||
| setup() { | |||||
| const staff = useStaffStore() | |||||
| const tabs = [{ status: 0, label: '🔔 新订单' },{ status: 1, label: '⚡ 进行中' },{ status: 2, label: '✅ 已完成' }] | |||||
| const activeTab = ref(0) | |||||
| const orders = ref([]) | |||||
| const counts = ref({ 0: 0, 1: 0 }) | |||||
| const recipeVisible = ref(false) | |||||
| const recipeCardNo = ref('') | |||||
| const recipeItems = ref([]) | |||||
| async function loadOrders() { | |||||
| try { const res = await get(API.STAFF_ORDERS, { status: activeTab.value }); if (Array.isArray(res)) orders.value = res } catch (e) {} | |||||
| } | |||||
| async function loadCounts() { | |||||
| try { const r0 = await get(API.STAFF_ORDERS, { status: 0 }); const r1 = await get(API.STAFF_ORDERS, { status: 1 }); counts.value = { 0: Array.isArray(r0) ? r0.length : 0, 1: Array.isArray(r1) ? r1.length : 0 } } catch (e) {} | |||||
| } | |||||
| function switchTab(s) { activeTab.value = s; loadOrders() } | |||||
| async function onConfirm(id) { try { await post(API.STAFF_CONFIRM, { id }); uni.showToast({ title: '已接单 ✅', icon: 'none' }); loadOrders(); loadCounts() } catch (e) {} } | |||||
| async function onDone(id) { try { const res = await post(API.STAFF_DONE, { id }); uni.showToast({ title: res && res.released ? '已结单 ✔ 号码已释放' : '已结单 ✔', icon: 'none' }); loadOrders(); loadCounts() } catch (e) {} } | |||||
| function onDetail(order) { uni.navigateTo({ url: '/pages/staff/detail?id=' + order.id }) } | |||||
| function onRecipe(order) { recipeCardNo.value = order.cardNo; recipeItems.value = order.items; recipeVisible.value = true } | |||||
| function onChat(order) { uni.setStorageSync('chatCardNo', order.cardNo); uni.navigateTo({ url: '/pages/staff/chat' }) } | |||||
| function onLogout() { staff.logout(); uni.redirectTo({ url: '/pages/staff/login' }) } | |||||
| onMounted(() => { loadOrders(); loadCounts(); startPoll('board', () => { loadOrders(); loadCounts() }, 10000) }) | |||||
| onUnmounted(() => stopPoll('board')) | |||||
| return { staff, tabs, activeTab, orders, counts, recipeVisible, recipeCardNo, recipeItems, switchTab, onConfirm, onDone, onDetail, onRecipe, onChat, onLogout } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-staff-board{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border);flex-shrink:0} | |||||
| .nav-left{display:flex;align-items:center;gap:16rpx} | |||||
| .nav-logo{font-size:44rpx} | |||||
| .nav-brand{font-size:32rpx;font-weight:900;color:var(--gold)} | |||||
| .logout-btn{font-size:26rpx;color:var(--red)} | |||||
| .staff-info{background:var(--bg-card);padding:16rpx 32rpx;font-size:24rpx;color:var(--text-dim);border-bottom:1px solid var(--border)} | |||||
| .board-tabs{display:flex;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:50;background:var(--bg);flex-shrink:0} | |||||
| .board-tab{flex:1;padding:24rpx;text-align:center;font-size:26rpx;font-weight:600;color:var(--text-dim);border-bottom:4rpx solid transparent} | |||||
| .board-tab.active{color:var(--gold);border-bottom-color:var(--gold)} | |||||
| .tab-count{margin-left:8rpx;color:var(--red);font-size:22rpx} | |||||
| .board-body{flex:1;padding:20rpx} | |||||
| .board-actions{display:flex;gap:16rpx;flex-wrap:wrap;padding-top:16rpx} | |||||
| .ba-btn{padding:12rpx 24rpx;border-radius:24rpx;font-size:24rpx;font-weight:600;border:none} | |||||
| .ba-gold{background:rgba(245,166,35,.15);color:var(--gold)} | |||||
| .ba-blue{background:rgba(74,144,217,.15);color:var(--blue)} | |||||
| .empty-state{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column} | |||||
| .empty-icon{font-size:112rpx;opacity:.3} | |||||
| .empty-text{font-size:28rpx;color:var(--text-muted);margin-top:16rpx} | |||||
| </style> | |||||
| @ -0,0 +1,79 @@ | |||||
| <template> | |||||
| <view class="page-staff-chat"> | |||||
| <view class="nav-bar"> | |||||
| <text class="nav-back" @tap="goBack">←</text> | |||||
| <text class="nav-title">💬 客人对话</text> | |||||
| <text></text> | |||||
| </view> | |||||
| <scroll-view class="chat-list" scroll-y :scroll-into-view="scrollToId"> | |||||
| <ChatBubble v-for="m in messages" :key="m.id" :msg="m" viewer="staff" /> | |||||
| <view id="chat-bottom"></view> | |||||
| </scroll-view> | |||||
| <view class="chat-input-bar"> | |||||
| <input class="chat-input" v-model="inputText" placeholder="回复客人..." @confirm="sendMsg" /> | |||||
| <button class="chat-send" @tap="sendMsg">发送</button> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref, onMounted, onUnmounted } from 'vue' | |||||
| import { useStaffStore } from '@/stores/staff' | |||||
| import { get, post } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import { startPoll, stopPoll } from '@/utils/poller' | |||||
| import ChatBubble from '@/components/ChatBubble.vue' | |||||
| export default { | |||||
| components: { ChatBubble }, | |||||
| setup() { | |||||
| const staff = useStaffStore() | |||||
| const cardNo = ref(uni.getStorageSync('chatCardNo') || '') | |||||
| const messages = ref([]) | |||||
| const inputText = ref('') | |||||
| const scrollToId = ref('') | |||||
| let lastId = 0 | |||||
| async function loadMessages() { | |||||
| try { | |||||
| const res = await get(API.MESSAGE_LIST, { card_no: cardNo.value, since: lastId }) | |||||
| if (Array.isArray(res) && res.length > 0) { | |||||
| messages.value = [...messages.value, ...res] | |||||
| lastId = res[res.length - 1].id | |||||
| scrollToId.value = 'chat-bottom' | |||||
| } | |||||
| } catch (e) {} | |||||
| } | |||||
| async function sendMsg() { | |||||
| const content = inputText.value.trim() | |||||
| if (!content) return | |||||
| inputText.value = '' | |||||
| try { | |||||
| await post(API.MESSAGE_SEND, { cardNo: cardNo.value, senderType: 'staff', content, staffId: staff.staffId }) | |||||
| await loadMessages() | |||||
| } catch (e) {} | |||||
| } | |||||
| function goBack() { uni.navigateBack() } | |||||
| onMounted(() => { loadMessages(); startPoll('staffChat', loadMessages, 5000) }) | |||||
| onUnmounted(() => stopPoll('staffChat')) | |||||
| return { cardNo, messages, inputText, scrollToId, sendMsg, goBack } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-staff-chat{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border);flex-shrink:0} | |||||
| .nav-back{font-size:36rpx;color:var(--text-dim)} | |||||
| .nav-title{font-size:32rpx;font-weight:800;color:var(--gold)} | |||||
| .chat-list{flex:1;padding:16rpx 0} | |||||
| .chat-input-bar{display:flex;align-items:center;padding:16rpx 24rpx;padding-bottom:calc(16rpx + var(--safe-b));background:var(--bg-card);border-top:1px solid var(--border);gap:16rpx} | |||||
| .chat-input{flex:1;height:76rpx;background:var(--bg);border-radius:16rpx;padding:0 28rpx;font-size:28rpx;color:var(--text)} | |||||
| .chat-send{width:104rpx;height:72rpx;border-radius:16rpx;background:var(--blue);color:#fff;border:none;font-size:26rpx;font-weight:700} | |||||
| </style> | |||||
| @ -0,0 +1,103 @@ | |||||
| <template> | |||||
| <view class="page-staff-detail"> | |||||
| <view class="nav-bar"> | |||||
| <text class="nav-back" @tap="goBack">←</text> | |||||
| <text class="nav-title">订单详情</text> | |||||
| <text></text> | |||||
| </view> | |||||
| <scroll-view class="detail-body" scroll-y v-if="order"> | |||||
| <view class="detail-card"> | |||||
| <view class="detail-row"><text>订单号</text><text>{{ order.orderNo }}</text></view> | |||||
| <view class="detail-row"><text>号码牌</text><text style="color:var(--gold);font-weight:700">🎫 {{ order.cardNo }}</text></view> | |||||
| <view class="detail-row"><text>状态</text><text :style="{color:statusColor}">{{ statusLabel }}</text></view> | |||||
| <view class="detail-row"><text>提交时间</text><text>{{ order.submittedAt }}</text></view> | |||||
| <view class="detail-row" v-if="order.note"><text>备注</text><text style="color:var(--orange)">💬 {{ order.note }}</text></view> | |||||
| </view> | |||||
| <view class="detail-card"> | |||||
| <text class="section-title">📋 商品清单</text> | |||||
| <view v-for="item in order.items" :key="item.name" class="detail-item"> | |||||
| <text class="di-emoji">{{ item.emoji }}</text> | |||||
| <view class="di-info"> | |||||
| <text class="di-name">{{ item.name }}</text> | |||||
| <text class="di-alc" v-if="item.alc>0">{{ item.alc }}%</text> | |||||
| </view> | |||||
| <text class="di-qty">×{{ item.qty }}</text> | |||||
| </view> | |||||
| </view> | |||||
| <view class="detail-card" v-for="item in order.items" :key="'r'+item.name"> | |||||
| <text class="section-title">📖 {{ item.name }} 配方</text> | |||||
| <text class="recipe-text">{{ item.recipe || '暂无配方信息' }}</text> | |||||
| </view> | |||||
| </scroll-view> | |||||
| <view v-else class="empty-state"> | |||||
| <text class="empty-icon">📋</text> | |||||
| <text class="empty-text">加载中...</text> | |||||
| </view> | |||||
| <view v-if="order" class="detail-actions"> | |||||
| <button v-if="order.status===0" class="da-btn da-confirm" @tap="onConfirm">✅ 接单</button> | |||||
| <button v-if="order.status===1" class="da-btn da-done" @tap="onDone">✔ 结单</button> | |||||
| <button class="da-btn da-chat" @tap="onChat">💬 聊天</button> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref, computed } from 'vue' | |||||
| import { get, post } from '@/utils/request' | |||||
| import { API, ORDER_STATUS } from '@/utils/constants' | |||||
| export default { | |||||
| setup() { | |||||
| const order = ref(null) | |||||
| const orderId = ref(0) | |||||
| const statusLabel = computed(() => order.value ? ORDER_STATUS[order.value.status]?.label : '') | |||||
| const statusColor = computed(() => order.value ? ORDER_STATUS[order.value.status]?.color : '') | |||||
| async function loadDetail() { | |||||
| if (!orderId.value) return | |||||
| try { const res = await get(API.STAFF_ORDER, { id: orderId.value }); if (res) order.value = res } catch (e) {} | |||||
| } | |||||
| async function onConfirm() { try { await post(API.STAFF_CONFIRM, { id: orderId.value }); uni.showToast({ title: '已接单 ✅', icon: 'none' }); loadDetail() } catch (e) {} } | |||||
| async function onDone() { try { await post(API.STAFF_DONE, { id: orderId.value }); uni.showToast({ title: '已结单 ✔', icon: 'none' }); loadDetail() } catch (e) {} } | |||||
| function onChat() { uni.setStorageSync('chatCardNo', order.value.cardNo); uni.navigateTo({ url: '/pages/staff/chat' }) } | |||||
| function goBack() { uni.navigateBack() } | |||||
| return { order, statusLabel, statusColor, orderId, loadDetail, onConfirm, onDone, onChat, goBack } | |||||
| }, | |||||
| onLoad(options) { | |||||
| this.orderId = parseInt(options.id) || 0 | |||||
| this.loadDetail() | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-staff-detail{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .nav-bar{height:100rpx;display:flex;align-items:center;justify-content:space-between;padding:0 28rpx;border-bottom:1px solid var(--border);flex-shrink:0} | |||||
| .nav-back{font-size:36rpx;color:var(--text-dim)} | |||||
| .nav-title{font-size:32rpx;font-weight:800;color:var(--gold)} | |||||
| .detail-body{flex:1;padding:28rpx} | |||||
| .detail-card{background:var(--bg-card);border-radius:var(--radius);padding:32rpx;margin-bottom:24rpx;border:1px solid var(--border)} | |||||
| .detail-row{display:flex;justify-content:space-between;padding:10rpx 0;font-size:28rpx;color:var(--text)} | |||||
| .section-title{font-size:28rpx;font-weight:700;color:var(--gold);display:block;margin-bottom:20rpx} | |||||
| .detail-item{display:flex;align-items:center;padding:16rpx 0;border-bottom:1px solid var(--border);gap:16rpx} | |||||
| .di-emoji{font-size:40rpx} | |||||
| .di-info{flex:1} | |||||
| .di-name{font-size:28rpx;font-weight:600;color:var(--text);display:block} | |||||
| .di-alc{font-size:22rpx;color:var(--gold-light)} | |||||
| .di-qty{font-size:28rpx;font-weight:700;color:var(--text-dim)} | |||||
| .recipe-text{font-size:24rpx;color:var(--text-dim);line-height:1.8;white-space:pre-line} | |||||
| .detail-actions{display:flex;gap:20rpx;padding:20rpx 28rpx;padding-bottom:calc(20rpx + var(--safe-b));flex-shrink:0} | |||||
| .da-btn{flex:1;height:80rpx;border-radius:var(--radius-sm);font-size:28rpx;font-weight:700;border:none;display:flex;align-items:center;justify-content:center} | |||||
| .da-confirm{background:linear-gradient(135deg,var(--gold-dark),var(--gold));color:#1A1A1A} | |||||
| .da-done{background:rgba(46,213,115,.2);color:#2ED573} | |||||
| .da-chat{background:rgba(74,144,217,.15);color:var(--blue)} | |||||
| .empty-state{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column} | |||||
| .empty-icon{font-size:112rpx;opacity:.3} | |||||
| .empty-text{font-size:28rpx;color:var(--text-muted);margin-top:16rpx} | |||||
| </style> | |||||
| @ -0,0 +1,65 @@ | |||||
| <template> | |||||
| <view class="page-staff-login"> | |||||
| <view class="login-container"> | |||||
| <text class="login-logo">🧑🍳</text> | |||||
| <text class="login-title">调酒师登录</text> | |||||
| <text class="login-subtitle">别再摸鱼了,起来干活!</text> | |||||
| <view class="login-form"> | |||||
| <input class="form-input" v-model="username" placeholder="账号" /> | |||||
| <input class="form-input" v-model="password" placeholder="密码" password /> | |||||
| <button class="login-submit" @tap="onLogin" :loading="loading">🔓 登 录</button> | |||||
| </view> | |||||
| <view style="margin-top:20rpx;"> | |||||
| <text style="color:var(--text-muted);font-size:24rpx" @tap="goBack">← 返回顾客端</text> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script> | |||||
| import { ref } from 'vue' | |||||
| import { post } from '@/utils/request' | |||||
| import { API } from '@/utils/constants' | |||||
| import { useStaffStore } from '@/stores/staff' | |||||
| export default { | |||||
| setup() { | |||||
| const staff = useStaffStore() | |||||
| const username = ref('') | |||||
| const password = ref('') | |||||
| const loading = ref(false) | |||||
| async function onLogin() { | |||||
| if (!username.value || !password.value) { | |||||
| uni.showToast({ title: '请输入账号密码', icon: 'none' }) | |||||
| return | |||||
| } | |||||
| loading.value = true | |||||
| try { | |||||
| const res = await post(API.STAFF_LOGIN, { username: username.value, password: password.value }) | |||||
| staff.login(res.token, res.nickname, res.staffId) | |||||
| uni.redirectTo({ url: '/pages/staff/board' }) | |||||
| } catch (e) {} | |||||
| loading.value = false | |||||
| } | |||||
| function goBack() { uni.navigateBack() } | |||||
| return { username, password, loading, onLogin, goBack } | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .page-staff-login{min-height:100vh;display:flex;flex-direction:column;background:var(--bg);width:100%} | |||||
| .login-container{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80rpx 64rpx} | |||||
| .login-logo{font-size:120rpx;animation:float 3s ease-in-out infinite} | |||||
| .login-title{font-size:44rpx;font-weight:800;color:var(--gold);margin:24rpx 0 8rpx} | |||||
| .login-subtitle{font-size:26rpx;color:var(--text-dim);margin-bottom:64rpx} | |||||
| .login-form{width:100%} | |||||
| .form-input{width:100%;height:96rpx;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0 32rpx;font-size:30rpx;color:var(--text);margin-bottom:24rpx} | |||||
| .login-submit{width:100%;height:96rpx;border-radius:24rpx;background:linear-gradient(135deg,var(--gold-dark),var(--gold));color:#1A1A1A;border:none;font-size:32rpx;font-weight:800;margin-top:16rpx;display:flex;align-items:center;justify-content:center} | |||||
| @keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-16rpx)}} | |||||
| </style> | |||||
| @ -0,0 +1,19 @@ | |||||
| import { defineStore } from 'pinia' | |||||
| import { ref } from 'vue' | |||||
| export const useCardStore = defineStore('card', () => { | |||||
| const cardNo = ref(uni.getStorageSync('cardNo') || '') | |||||
| const dismissed = ref(false) | |||||
| function setCardNo(no) { | |||||
| cardNo.value = no | |||||
| uni.setStorageSync('cardNo', no) | |||||
| } | |||||
| function clearCard() { | |||||
| cardNo.value = '' | |||||
| uni.removeStorageSync('cardNo') | |||||
| } | |||||
| return { cardNo, dismissed, setCardNo, clearCard } | |||||
| }) | |||||
| @ -0,0 +1,36 @@ | |||||
| import { defineStore } from 'pinia' | |||||
| import { ref, computed } from 'vue' | |||||
| export const useCartStore = defineStore('cart', () => { | |||||
| const items = ref([]) | |||||
| const totalCount = computed(() => items.value.reduce((s, i) => s + i.qty, 0)) | |||||
| const totalAmount = computed(() => items.value.reduce((s, i) => s + i.product.price * i.qty, 0)) | |||||
| function addItem(product, qty = 1) { | |||||
| const exist = items.value.find(i => i.product.id === product.id) | |||||
| if (exist) { | |||||
| exist.qty += qty | |||||
| } else { | |||||
| items.value.push({ product: { ...product }, qty }) | |||||
| } | |||||
| } | |||||
| function removeItem(productId) { | |||||
| items.value = items.value.filter(i => i.product.id !== productId) | |||||
| } | |||||
| function updateQty(productId, qty) { | |||||
| const item = items.value.find(i => i.product.id === productId) | |||||
| if (item) { | |||||
| if (qty <= 0) removeItem(productId) | |||||
| else item.qty = qty | |||||
| } | |||||
| } | |||||
| function clearCart() { | |||||
| items.value = [] | |||||
| } | |||||
| return { items, totalCount, totalAmount, addItem, removeItem, updateQty, clearCart } | |||||
| }) | |||||
| @ -0,0 +1,31 @@ | |||||
| import { defineStore } from 'pinia' | |||||
| import { ref, computed } from 'vue' | |||||
| export const useStaffStore = defineStore('staff', () => { | |||||
| const token = ref(uni.getStorageSync('staffToken') || '') | |||||
| const nickname = ref(uni.getStorageSync('staffNickname') || '') | |||||
| const staffId = ref(uni.getStorageSync('staffId') || '') | |||||
| const unread = ref(0) | |||||
| const isLoggedIn = computed(() => !!token.value) | |||||
| function login(t, nick, id) { | |||||
| token.value = t | |||||
| nickname.value = nick | |||||
| staffId.value = id | |||||
| uni.setStorageSync('staffToken', t) | |||||
| uni.setStorageSync('staffNickname', nick) | |||||
| uni.setStorageSync('staffId', id) | |||||
| } | |||||
| function logout() { | |||||
| token.value = '' | |||||
| nickname.value = '' | |||||
| staffId.value = '' | |||||
| uni.removeStorageSync('staffToken') | |||||
| uni.removeStorageSync('staffNickname') | |||||
| uni.removeStorageSync('staffId') | |||||
| } | |||||
| return { token, nickname, staffId, unread, isLoggedIn, login, logout } | |||||
| }) | |||||
| @ -0,0 +1,40 @@ | |||||
| // 纯瘾大 全局样式 (严格对齐 UI/css/miniapp.css) | |||||
| page { | |||||
| --bg: #0D0D0D; | |||||
| --bg-card: #1A1A1A; | |||||
| --bg-card-hover: #222222; | |||||
| --gold: #F5A623; | |||||
| --gold-light: #FFD700; | |||||
| --gold-dark: #C8851A; | |||||
| --orange: #FF6B35; | |||||
| --orange-light: #FF8C5A; | |||||
| --red: #FF3B3B; | |||||
| --blue: #4A90D9; | |||||
| --blue-light: #6BB5FF; | |||||
| --text: #E8E4DD; | |||||
| --text-dim: #999590; | |||||
| --text-muted: #666260; | |||||
| --border: #2A2825; | |||||
| --radius: 32rpx; | |||||
| --radius-sm: 20rpx; | |||||
| --radius-xs: 12rpx; | |||||
| --nav-h: 100rpx; | |||||
| --safe-b: 68rpx; | |||||
| background-color: var(--bg); | |||||
| color: var(--text); | |||||
| font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; | |||||
| font-size: 28rpx; | |||||
| min-height: 100vh; | |||||
| width: 100%; | |||||
| -webkit-font-smoothing: antialiased; | |||||
| } | |||||
| // 动画 | |||||
| @keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-16rpx)}} | |||||
| @keyframes pulse-glow{0%,100%{box-shadow:0 0 20rpx rgba(245,166,35,.3)}50%{box-shadow:0 0 40rpx rgba(245,166,35,.6)}} | |||||
| @keyframes bell-press{0%,100%{transform:scaleY(1)}40%{transform:scaleY(.6) translateY(16rpx)}60%{transform:scaleY(.7) translateY(12rpx)}} | |||||
| @keyframes bell-ring-wave{0%{transform:scale(.5);opacity:1}100%{transform:scale(2.5);opacity:0}} | |||||
| @keyframes bounce-in{0%{transform:scale(0);opacity:0}60%{transform:scale(1.15);opacity:1}100%{transform:scale(1)}} | |||||
| @keyframes fade-in-up{from{opacity:0;transform:translateY(32rpx)}to{opacity:1;transform:translateY(0)}} | |||||
| @keyframes card-appear-top{0%{transform:translateY(-40rpx) scale(.5);opacity:0}100%{transform:translateY(0) scale(1);opacity:1}} | |||||
| @keyframes float-bubble{0%{transform:translateY(0) scale(1);opacity:1}100%{transform:translateY(-180rpx) scale(1.3);opacity:0}} | |||||
| @ -0,0 +1,36 @@ | |||||
| // 纯瘾大 · 全局常量 | |||||
| export const API = { | |||||
| BASE: 'http://127.0.0.1:8080', | |||||
| CARD_GENERATE: '/api/card/generate', | |||||
| CARD_CHECK: '/api/card/check', | |||||
| MENU_CATEGORIES: '/api/menu/categories', | |||||
| MENU_PRODUCTS: '/api/menu/products', | |||||
| MENU_PRODUCT: '/api/menu/product', | |||||
| ORDER_SUBMIT: '/api/order/submit', | |||||
| ORDER_LIST: '/api/order/list', | |||||
| ORDER_REMIND: '/api/order/remind', | |||||
| MESSAGE_LIST: '/api/message/list', | |||||
| MESSAGE_SEND: '/api/message/send', | |||||
| STAFF_LOGIN: '/api/staff/login', | |||||
| STAFF_ORDERS: '/api/staff/orders', | |||||
| STAFF_ORDER: '/api/staff/order', | |||||
| STAFF_CONFIRM: '/api/staff/order/confirm', | |||||
| STAFF_DONE: '/api/staff/order/done', | |||||
| STAFF_CANCEL: '/api/staff/order/cancel', | |||||
| } | |||||
| export const ORDER_STATUS = { | |||||
| 0: { label: '新订单', color: '#FF3B3B', class: 'status-new' }, | |||||
| 1: { label: '进行中', color: '#4A90D9', class: 'status-confirmed' }, | |||||
| 2: { label: '已完成', color: '#2ED573', class: 'status-done' }, | |||||
| 3: { label: '已取消', color: '#747D8C', class: 'status-cancelled' }, | |||||
| } | |||||
| export const CONFIRM_PRESETS = [ | |||||
| { label: '去冰', icon: '🧊' }, | |||||
| { label: '多酸', icon: '🍋' }, | |||||
| { label: '少甜', icon: '🍬' }, | |||||
| { label: '加烈', icon: '🥃' }, | |||||
| { label: '快做', icon: '⚡' }, | |||||
| ] | |||||
| @ -0,0 +1,18 @@ | |||||
| // 纯瘾大 · 轮询引擎 | |||||
| const timers = {} | |||||
| export function startPoll(name, fn, intervalMs = 5000) { | |||||
| stopPoll(name) | |||||
| timers[name] = setInterval(fn, intervalMs) | |||||
| } | |||||
| export function stopPoll(name) { | |||||
| if (timers[name]) { | |||||
| clearInterval(timers[name]) | |||||
| delete timers[name] | |||||
| } | |||||
| } | |||||
| export function stopAllPolls() { | |||||
| Object.keys(timers).forEach(k => stopPoll(k)) | |||||
| } | |||||
| @ -0,0 +1,51 @@ | |||||
| import { API } from './constants' | |||||
| function getBaseURL() { | |||||
| return API.BASE | |||||
| } | |||||
| function request(method, path, params = {}) { | |||||
| return new Promise((resolve, reject) => { | |||||
| const token = uni.getStorageSync('staffToken') || '' | |||||
| uni.request({ | |||||
| url: getBaseURL() + path, | |||||
| method: method, | |||||
| data: method === 'GET' ? undefined : params, | |||||
| dataType: 'json', | |||||
| header: { | |||||
| 'Content-Type': 'application/json', | |||||
| 'Authorization': token ? 'Bearer ' + token : '', | |||||
| }, | |||||
| timeout: 10000, | |||||
| success(res) { | |||||
| const data = res.data | |||||
| if (data.code === 0) { | |||||
| resolve(data.data) | |||||
| } else if (data.code === -1) { | |||||
| if (res.statusCode === 401) { | |||||
| uni.removeStorageSync('staffToken') | |||||
| uni.reLaunch({ url: '/pages/staff/login' }) | |||||
| } | |||||
| uni.showToast({ title: data.msg || '请求失败', icon: 'none' }) | |||||
| reject(new Error(data.msg)) | |||||
| } else { | |||||
| resolve(data) | |||||
| } | |||||
| }, | |||||
| fail(err) { | |||||
| uni.showToast({ title: '网络错误', icon: 'none' }) | |||||
| reject(err) | |||||
| } | |||||
| }) | |||||
| }) | |||||
| } | |||||
| export function get(path, params = {}) { | |||||
| const qs = Object.keys(params).map(k => k + '=' + encodeURIComponent(params[k])).join('&') | |||||
| return request('GET', qs ? path + '?' + qs : path) | |||||
| } | |||||
| export function post(path, data = {}) { | |||||
| return request('POST', path, data) | |||||
| } | |||||