Browse Source

feat: 纯瘾大 uni-app 前端 v2.1 R3修复版

- 9页面: 落地页/菜单/确认/订单/聊天 + 员工登录/看板/详情/聊天
- 9组件: Banner/分类Tab/商品卡片/出餐铃/购物车弹层/订单卡片/聊天气泡/配方弹窗/预设标签
- 3 Pinia Store: card/cart/staff
- 3 Utils: request/constants/poller
- 深色酒吧主题,全局无价格显示
- 响应式双列布局
- 1997号码牌跳转员工登录
- 出餐铃动画 bell-press + bell-ring-wave
- 购物车点击遮罩关闭
dev
mac 1 day ago
commit
429d836a71
30 changed files with 1608 additions and 0 deletions
  1. +5
    -0
      .gitignore
  2. +10
    -0
      App.vue
  3. +29
    -0
      components/BannerSwiper.vue
  4. +25
    -0
      components/CartBar.vue
  5. +63
    -0
      components/CartPopup.vue
  6. +24
    -0
      components/CategoryTab.vue
  7. +54
    -0
      components/ChatBubble.vue
  8. +50
    -0
      components/OrderCard.vue
  9. +34
    -0
      components/PresetTags.vue
  10. +33
    -0
      components/ProductCard.vue
  11. +35
    -0
      components/RecipeModal.vue
  12. +9
    -0
      main.js
  13. +15
    -0
      manifest.json
  14. +19
    -0
      pages.json
  15. +81
    -0
      pages/chat/chat.vue
  16. +103
    -0
      pages/confirm/confirm.vue
  17. +164
    -0
      pages/index/index.vue
  18. +187
    -0
      pages/menu/menu.vue
  19. +83
    -0
      pages/orders/orders.vue
  20. +107
    -0
      pages/staff/board.vue
  21. +79
    -0
      pages/staff/chat.vue
  22. +103
    -0
      pages/staff/detail.vue
  23. +65
    -0
      pages/staff/login.vue
  24. +19
    -0
      stores/card.js
  25. +36
    -0
      stores/cart.js
  26. +31
    -0
      stores/staff.js
  27. +40
    -0
      uni.scss
  28. +36
    -0
      utils/constants.js
  29. +18
    -0
      utils/poller.js
  30. +51
    -0
      utils/request.js

+ 5
- 0
.gitignore View File

@ -0,0 +1,5 @@
node_modules/
dist/
.DS_Store
*.log
unpackage/

+ 10
- 0
App.vue View File

@ -0,0 +1,10 @@
<script>
export default {
onLaunch() {
console.log('纯瘾大 启动')
}
}
</script>
<style>
@import './uni.scss';
</style>

+ 29
- 0
components/BannerSwiper.vue View File

@ -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>

+ 25
- 0
components/CartBar.vue View File

@ -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>

+ 63
- 0
components/CartPopup.vue View File

@ -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>

+ 24
- 0
components/CategoryTab.vue View File

@ -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>

+ 54
- 0
components/ChatBubble.vue View File

@ -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>

+ 50
- 0
components/OrderCard.vue View File

@ -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>

+ 34
- 0
components/PresetTags.vue View File

@ -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>

+ 33
- 0
components/ProductCard.vue View File

@ -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>

+ 35
- 0
components/RecipeModal.vue View File

@ -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>

+ 9
- 0
main.js View File

@ -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 }
}

+ 15
- 0
manifest.json View File

@ -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
}
}

+ 19
- 0
pages.json View File

@ -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"
}
}

+ 81
- 0
pages/chat/chat.vue View File

@ -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>

+ 103
- 0
pages/confirm/confirm.vue View File

@ -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>

+ 164
- 0
pages/index/index.vue View File

@ -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>

+ 187
- 0
pages/menu/menu.vue View File

@ -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>

+ 83
- 0
pages/orders/orders.vue View File

@ -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>

+ 107
- 0
pages/staff/board.vue View File

@ -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>

+ 79
- 0
pages/staff/chat.vue View File

@ -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>

+ 103
- 0
pages/staff/detail.vue View File

@ -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>

+ 65
- 0
pages/staff/login.vue View File

@ -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>

+ 19
- 0
stores/card.js View File

@ -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 }
})

+ 36
- 0
stores/cart.js View File

@ -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 }
})

+ 31
- 0
stores/staff.js View File

@ -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 }
})

+ 40
- 0
uni.scss View File

@ -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}}

+ 36
- 0
utils/constants.js View File

@ -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: '⚡' },
]

+ 18
- 0
utils/poller.js View File

@ -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))
}

+ 51
- 0
utils/request.js View File

@ -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)
}

Loading…
Cancel
Save