refactor: 拆分小程序 vue 结构
This commit is contained in:
44
src/app.config.ts
Normal file
44
src/app.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/home/index',
|
||||
'pages/menu/index',
|
||||
'pages/orders/index',
|
||||
'pages/profile/index',
|
||||
'pages/store/select/index',
|
||||
'pages/trade/checkout/index',
|
||||
'pages/order/detail/index',
|
||||
'pages/address/index',
|
||||
'pages/trade/success/index',
|
||||
'pages/dinein/confirm/index'
|
||||
],
|
||||
window: {
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundTextStyle: 'light',
|
||||
backgroundColor: '#F8FAFC'
|
||||
},
|
||||
tabBar: {
|
||||
color: '#64748b',
|
||||
selectedColor: '#16a34a',
|
||||
backgroundColor: '#ffffff',
|
||||
borderStyle: 'black',
|
||||
list: [
|
||||
{
|
||||
pagePath: 'pages/home/index',
|
||||
text: '首页'
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/menu/index',
|
||||
text: '点餐'
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/orders/index',
|
||||
text: '订单'
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/profile/index',
|
||||
text: '我的'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
108
src/app.scss
Normal file
108
src/app.scss
Normal file
@@ -0,0 +1,108 @@
|
||||
@import '@nutui/nutui-taro/dist/style.css';
|
||||
@import './styles/variables';
|
||||
|
||||
page {
|
||||
min-height: 100%;
|
||||
background: $bg;
|
||||
color: $text-1;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
scroll-view,
|
||||
image {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.row-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.inline-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.caption-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.value-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
padding: 28px 0 10px;
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
// Field input (used in checkout etc.)
|
||||
.field-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1.5px solid $border;
|
||||
border-radius: $r-md;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
color: $text-1;
|
||||
background: $bg;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-5;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
// Overlay
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 200;
|
||||
}
|
||||
32
src/app.ts
Normal file
32
src/app.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createApp } from 'vue'
|
||||
import './app.scss'
|
||||
import { setRequestHeaderProvider } from '@/services'
|
||||
import { pinia, useAppStore, useCustomerStore } from '@/stores'
|
||||
|
||||
const App = createApp({
|
||||
async onLaunch () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
customerStore.ensureDefaults()
|
||||
setRequestHeaderProvider(() => ({
|
||||
...appStore.requestHeaders,
|
||||
...customerStore.requestHeaders
|
||||
}))
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
},
|
||||
async onShow () {
|
||||
const appStore = useAppStore(pinia)
|
||||
if (appStore.bootstrapStatus === 'idle') {
|
||||
await appStore.initBootstrap()
|
||||
}
|
||||
|
||||
if (appStore.storesStatus === 'idle') {
|
||||
await appStore.initStores()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
App.use(pinia)
|
||||
|
||||
export default App
|
||||
116
src/components/cart-drawer/index.vue
Normal file
116
src/components/cart-drawer/index.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<view v-if="props.visible" class="overlay" @click="emit('close')" />
|
||||
<view v-if="props.visible" class="cart-drawer">
|
||||
<view class="cart-drawer__handle" />
|
||||
|
||||
<template v-if="props.lineList.length">
|
||||
<scroll-view scroll-y class="cart-drawer__scroll">
|
||||
<view class="cart-drawer__header">
|
||||
<view class="cart-drawer__header-left">
|
||||
<view class="cart-drawer__header-icon">
|
||||
<text class="cart-drawer__icon-text">🛒</text>
|
||||
</view>
|
||||
<text class="cart-drawer__title">购物车</text>
|
||||
<text class="cart-drawer__count">{{ props.itemCount }} 件</text>
|
||||
</view>
|
||||
<view class="cart-drawer__clear" @click="emit('clear')">
|
||||
<text>清空</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cart-drawer__list">
|
||||
<view
|
||||
v-for="line in props.lineList"
|
||||
:key="line.lineKey"
|
||||
class="cart-drawer__item"
|
||||
>
|
||||
<view class="cart-drawer__item-info">
|
||||
<text class="cart-drawer__item-name">{{ line.name }}</text>
|
||||
<text v-if="line.specText" class="cart-drawer__item-spec">
|
||||
{{ line.specText }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="cart-drawer__item-right">
|
||||
<text class="cart-drawer__item-price">
|
||||
<text class="cart-drawer__unit">¥</text>{{ line.lineAmountText }}
|
||||
</text>
|
||||
<view class="cart-drawer__stepper">
|
||||
<view
|
||||
class="cart-drawer__stepper-btn"
|
||||
@click="emit('changeQty', line.lineKey, -1)"
|
||||
>
|
||||
<text>-</text>
|
||||
</view>
|
||||
<text class="cart-drawer__stepper-val">{{ line.quantity }}</text>
|
||||
<view
|
||||
class="cart-drawer__stepper-btn"
|
||||
@click="emit('changeQty', line.lineKey, 1)"
|
||||
>
|
||||
<text>+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cart-drawer__amount">
|
||||
<view class="cart-drawer__amount-row">
|
||||
<text class="cart-drawer__amount-label">商品小计</text>
|
||||
<text class="cart-drawer__amount-value">¥{{ props.totalAmountText }}</text>
|
||||
</view>
|
||||
<view class="cart-drawer__amount-row cart-drawer__amount-row--total">
|
||||
<text class="cart-drawer__amount-label">合计</text>
|
||||
<text class="cart-drawer__amount-value cart-drawer__amount-value--total">
|
||||
<text class="cart-drawer__unit">¥</text>{{ props.totalAmountText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="cart-drawer__footer">
|
||||
<view class="cart-drawer__footer-left">
|
||||
<text class="cart-drawer__footer-summary">
|
||||
共 <text class="cart-drawer__footer-num">{{ props.itemCount }}</text> 件
|
||||
</text>
|
||||
<view class="cart-drawer__footer-total">
|
||||
<text class="cart-drawer__footer-unit">¥</text>
|
||||
<text class="cart-drawer__footer-value">{{ props.totalAmountText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="cart-drawer__footer-btn" @click="emit('checkout')">
|
||||
<text>去结算</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="cart-drawer__empty">
|
||||
<text class="cart-drawer__empty-title">购物车还是空的</text>
|
||||
<text class="cart-drawer__empty-desc">先去选几样喜欢的商品吧</text>
|
||||
<view class="cart-drawer__empty-btn" @click="emit('close')">
|
||||
<text>去选购</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CartDrawerLineItem } from './useCartDrawer'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
lineList: CartDrawerLineItem[]
|
||||
itemCount: number
|
||||
totalAmountText: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
clear: []
|
||||
checkout: []
|
||||
changeQty: [lineKey: string, delta: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
79
src/components/cart-drawer/styles/base.scss
Normal file
79
src/components/cart-drawer/styles/base.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 75%;
|
||||
background: $card;
|
||||
border-radius: 28px 28px 0 0;
|
||||
z-index: 210;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.10);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cart-drawer__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: $text-5;
|
||||
margin: 10px auto 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.cart-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 20px 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cart-drawer__header-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cart-drawer__icon-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.cart-drawer__count {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
font-weight: 500;
|
||||
background: $border;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.cart-drawer__clear {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
padding: 8px 0;
|
||||
}
|
||||
38
src/components/cart-drawer/styles/empty.scss
Normal file
38
src/components/cart-drawer/styles/empty.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px 60px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cart-drawer__empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.cart-drawer__empty-desc {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.cart-drawer__empty-btn {
|
||||
margin-top: 20px;
|
||||
height: 38px;
|
||||
padding: 0 24px;
|
||||
border-radius: 19px;
|
||||
border: 1.5px solid $primary;
|
||||
background: transparent;
|
||||
color: $primary;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
63
src/components/cart-drawer/styles/footer.scss
Normal file
63
src/components/cart-drawer/styles/footer.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px 28px;
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-summary {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-num {
|
||||
color: $primary;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-btn {
|
||||
height: 50px;
|
||||
min-width: 130px;
|
||||
padding: 0 32px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
4
src/components/cart-drawer/styles/index.scss
Normal file
4
src/components/cart-drawer/styles/index.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import './base.scss';
|
||||
@import './list.scss';
|
||||
@import './footer.scss';
|
||||
@import './empty.scss';
|
||||
124
src/components/cart-drawer/styles/list.scss
Normal file
124
src/components/cart-drawer/styles/list.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer__list {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.cart-drawer__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 0.5px solid $border;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.cart-drawer__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cart-drawer__item-spec {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cart-drawer__item-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__item-price {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.cart-drawer__unit {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cart-drawer__stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cart-drawer__stepper-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid $text-5;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__stepper-val {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.cart-drawer__amount {
|
||||
margin: 0 20px;
|
||||
padding: 14px 0 6px;
|
||||
border-top: 1px dashed $border;
|
||||
}
|
||||
|
||||
.cart-drawer__amount-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
|
||||
&--total {
|
||||
padding: 10px 0 4px;
|
||||
margin-top: 6px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-drawer__amount-label {
|
||||
font-size: 13px;
|
||||
color: $text-3;
|
||||
|
||||
.cart-drawer__amount-row--total & {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-drawer__amount-value {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
font-weight: 500;
|
||||
|
||||
&--total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
}
|
||||
}
|
||||
8
src/components/cart-drawer/useCartDrawer.ts
Normal file
8
src/components/cart-drawer/useCartDrawer.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { CartLine } from '@/stores/cart'
|
||||
|
||||
export interface CartDrawerLineItem extends CartLine {
|
||||
lineAmount: number
|
||||
lineAmountText: string
|
||||
unitPriceText: string
|
||||
specText: string
|
||||
}
|
||||
30
src/components/page-hero/index.vue
Normal file
30
src/components/page-hero/index.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<view class="page-hero">
|
||||
<view class="page-hero__head">
|
||||
<view class="page-hero__copy">
|
||||
<text class="page-hero__title">{{ props.title }}</text>
|
||||
<text class="page-hero__subtitle">{{ props.subtitle }}</text>
|
||||
</view>
|
||||
<view v-if="props.badge" class="page-hero__badge">{{ props.badge }}</view>
|
||||
</view>
|
||||
<view v-if="hasExtra" class="page-hero__extra">
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageHero } from './usePageHero'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
badge?: string
|
||||
}>()
|
||||
|
||||
const { hasExtra } = usePageHero()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles.scss';
|
||||
</style>
|
||||
46
src/components/page-hero/styles.scss
Normal file
46
src/components/page-hero/styles.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
.page-hero {
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(17, 24, 39, 0.96), rgba(22, 163, 74, 0.92));
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.page-hero__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-hero__copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-hero__title {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.page-hero__subtitle {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.page-hero__badge {
|
||||
flex: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.page-hero__extra {
|
||||
margin-top: 16px;
|
||||
}
|
||||
10
src/components/page-hero/usePageHero.ts
Normal file
10
src/components/page-hero/usePageHero.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
export function usePageHero () {
|
||||
const slots = useSlots()
|
||||
const hasExtra = computed(() => Boolean(slots.default))
|
||||
|
||||
return {
|
||||
hasExtra
|
||||
}
|
||||
}
|
||||
66
src/components/product-card/index.vue
Normal file
66
src/components/product-card/index.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<view class="product-card" @click="emit('select', props.product)">
|
||||
<view class="product-card__img-wrap">
|
||||
<image
|
||||
class="product-card__img"
|
||||
:src="props.product.coverImageUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text
|
||||
v-if="props.product.tagTexts?.length"
|
||||
class="product-card__img-tag"
|
||||
:class="tagClass"
|
||||
>
|
||||
{{ props.product.tagTexts[0] }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="product-card__body">
|
||||
<view>
|
||||
<text class="product-card__name">{{ props.product.name }}</text>
|
||||
<text class="product-card__desc">{{ props.product.description }}</text>
|
||||
<view class="product-card__meta">
|
||||
<text class="product-card__sales">{{ props.product.salesText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="product-card__bottom">
|
||||
<view class="product-card__price">
|
||||
<text class="product-card__price-unit">¥</text>
|
||||
<text class="product-card__price-value">{{ props.product.price }}</text>
|
||||
<text
|
||||
v-if="props.product.originalPriceText"
|
||||
class="product-card__price-origin"
|
||||
>
|
||||
¥{{ props.product.originalPriceText }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="product-card__btn"
|
||||
:class="{ 'product-card__btn--disabled': props.product.soldOut }"
|
||||
@click.stop="emit('action', props.product)"
|
||||
>
|
||||
<text>{{ props.product.hasOptions ? '选规格' : '加入购物车' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MiniProductCard } from '@/shared'
|
||||
import { useProductCard } from './useProductCard'
|
||||
|
||||
const props = defineProps<{
|
||||
product: MiniProductCard
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [product: MiniProductCard]
|
||||
action: [product: MiniProductCard]
|
||||
}>()
|
||||
|
||||
const { tagClass } = useProductCard(props)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles.scss';
|
||||
</style>
|
||||
132
src/components/product-card/styles.scss
Normal file
132
src/components/product-card/styles.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
@import '../../styles/variables';
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-card__img-wrap {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: $r-md;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.product-card__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #FED7AA, #FDBA74);
|
||||
}
|
||||
|
||||
.product-card__img-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
|
||||
&--hot { background: rgba(239, 68, 68, 0.85); }
|
||||
&--new { background: rgba(22, 163, 74, 0.85); }
|
||||
&--sale { background: rgba(245, 158, 11, 0.85); }
|
||||
}
|
||||
|
||||
.product-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-card__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.product-card__desc {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.product-card__sales {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.product-card__bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.product-card__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-card__price-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.product-card__price-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.product-card__price-origin {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
text-decoration: line-through;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.product-card__btn {
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
16
src/components/product-card/useProductCard.ts
Normal file
16
src/components/product-card/useProductCard.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { computed } from 'vue'
|
||||
import type { MiniProductCard } from '@/shared'
|
||||
|
||||
export function useProductCard (props: { product: MiniProductCard }) {
|
||||
const tagClass = computed(() => {
|
||||
const tag = props.product.tagTexts?.[0] || ''
|
||||
if (['招牌', '热卖', '热销'].includes(tag)) return 'product-card__img-tag--hot'
|
||||
if (['新品'].includes(tag)) return 'product-card__img-tag--new'
|
||||
if (['超值', '加购王'].includes(tag)) return 'product-card__img-tag--sale'
|
||||
return 'product-card__img-tag--hot'
|
||||
})
|
||||
|
||||
return {
|
||||
tagClass
|
||||
}
|
||||
}
|
||||
46
src/components/scene-switcher/index.vue
Normal file
46
src/components/scene-switcher/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<view class="scene-switcher">
|
||||
<view
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
class="scene-switcher__item"
|
||||
>
|
||||
<NutButton
|
||||
size="small"
|
||||
:type="option.value === props.modelValue ? 'primary' : 'default'"
|
||||
:plain="option.value !== props.modelValue"
|
||||
@click="handleSelect(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NutButton>
|
||||
<text class="scene-switcher__desc">{{ option.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import { useSceneSwitcher } from './useSceneSwitcher'
|
||||
|
||||
interface SceneOption {
|
||||
label: string
|
||||
value: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
options: SceneOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const { handleSelect } = useSceneSwitcher(emit)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles.scss';
|
||||
</style>
|
||||
17
src/components/scene-switcher/styles.scss
Normal file
17
src/components/scene-switcher/styles.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
.scene-switcher {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scene-switcher__item {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.scene-switcher__desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
15
src/components/scene-switcher/useSceneSwitcher.ts
Normal file
15
src/components/scene-switcher/useSceneSwitcher.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
interface SceneSwitcherEmits {
|
||||
(event: 'change', value: string): void
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
export function useSceneSwitcher (emit: SceneSwitcherEmits) {
|
||||
function handleSelect (value: string) {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
|
||||
return {
|
||||
handleSelect
|
||||
}
|
||||
}
|
||||
153
src/components/spec-popup/index.vue
Normal file
153
src/components/spec-popup/index.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<view v-if="props.visible" class="overlay" @click="emit('close')" />
|
||||
<view v-if="props.visible" class="spec-popup">
|
||||
<view class="spec-popup__handle" />
|
||||
|
||||
<scroll-view scroll-y class="spec-popup__scroll">
|
||||
<view class="spec-popup__header">
|
||||
<image
|
||||
class="spec-popup__img"
|
||||
:src="props.product.coverImageUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="spec-popup__info">
|
||||
<text class="spec-popup__name">{{ props.product.name }}</text>
|
||||
<text class="spec-popup__desc">
|
||||
{{ props.product.subtitle || props.product.description }}
|
||||
</text>
|
||||
<view class="spec-popup__tags">
|
||||
<text
|
||||
v-for="tag in props.product.tagTexts"
|
||||
:key="tag"
|
||||
class="spec-popup__tag"
|
||||
:class="tagClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="spec-popup__price-row">
|
||||
<text class="spec-popup__price-unit">¥</text>
|
||||
<text class="spec-popup__price-current">{{ props.displayUnitPrice }}</text>
|
||||
<text
|
||||
v-if="props.product.originalPriceText"
|
||||
class="spec-popup__price-origin"
|
||||
>
|
||||
¥{{ props.product.originalPriceText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="spec-popup__close" @click="emit('close')">
|
||||
<text>✕</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="group in props.product.optionGroups"
|
||||
:key="group.id"
|
||||
class="spec-popup__group"
|
||||
>
|
||||
<view class="spec-popup__group-header">
|
||||
<text class="spec-popup__group-title">{{ group.name }}</text>
|
||||
<text
|
||||
v-if="group.required"
|
||||
class="spec-popup__group-tag spec-popup__group-tag--required"
|
||||
>
|
||||
必选
|
||||
</text>
|
||||
<text
|
||||
v-else-if="group.selectionType === 'multiple'"
|
||||
class="spec-popup__group-tag spec-popup__group-tag--multi"
|
||||
>
|
||||
可多选
|
||||
</text>
|
||||
</view>
|
||||
<view class="spec-popup__pills">
|
||||
<view
|
||||
v-for="option in group.options"
|
||||
:key="option.id"
|
||||
class="spec-popup__pill"
|
||||
:class="{
|
||||
'spec-popup__pill--selected': props.isSelected(group.id, option.id),
|
||||
'spec-popup__pill--disabled': option.soldOut
|
||||
}"
|
||||
@click="emit('toggleOption', group, option)"
|
||||
>
|
||||
<text>{{ option.name }}</text>
|
||||
<text v-if="option.extraPrice" class="spec-popup__pill-extra">
|
||||
+¥{{ option.extraPriceText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="spec-popup__stepper-row">
|
||||
<text class="spec-popup__stepper-label">数量</text>
|
||||
<view class="spec-popup__stepper">
|
||||
<view
|
||||
class="spec-popup__stepper-btn"
|
||||
:class="{ 'spec-popup__stepper-btn--disabled': props.quantity <= 1 }"
|
||||
@click="emit('changeQty', -1)"
|
||||
>
|
||||
<text>-</text>
|
||||
</view>
|
||||
<text class="spec-popup__stepper-value">{{ props.quantity }}</text>
|
||||
<view class="spec-popup__stepper-btn" @click="emit('changeQty', 1)">
|
||||
<text>+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="spec-popup__footer">
|
||||
<view class="spec-popup__footer-price">
|
||||
<text class="spec-popup__footer-label">合计</text>
|
||||
<view class="spec-popup__footer-total">
|
||||
<text class="spec-popup__footer-unit">¥</text>
|
||||
<text class="spec-popup__footer-value">{{ props.totalPriceText }}</text>
|
||||
</view>
|
||||
<text class="spec-popup__footer-summary">{{ props.summaryText }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="spec-popup__footer-btn"
|
||||
:class="{ 'spec-popup__footer-btn--disabled': !props.canAdd }"
|
||||
@click="props.canAdd && emit('addToCart')"
|
||||
>
|
||||
<text>{{ props.canAdd ? '加入购物车' : props.disabledText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
MiniProductDetail,
|
||||
MiniProductOption,
|
||||
MiniProductOptionGroup
|
||||
} from '@/shared'
|
||||
import { useSpecPopup } from './useSpecPopup'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
product: MiniProductDetail
|
||||
quantity: number
|
||||
displayUnitPrice: number
|
||||
totalPriceText: string
|
||||
summaryText: string
|
||||
canAdd: boolean
|
||||
disabledText: string
|
||||
isSelected: (groupId: string, optionId: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
addToCart: []
|
||||
toggleOption: [group: MiniProductOptionGroup, option: MiniProductOption]
|
||||
changeQty: [delta: number]
|
||||
}>()
|
||||
|
||||
const { tagClass } = useSpecPopup()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
31
src/components/spec-popup/styles/base.scss
Normal file
31
src/components/spec-popup/styles/base.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 82%;
|
||||
background: $card;
|
||||
border-radius: 28px 28px 0 0;
|
||||
z-index: 210;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.10);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spec-popup__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: $text-5;
|
||||
margin: 10px auto 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spec-popup__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
74
src/components/spec-popup/styles/footer.scss
Normal file
74
src/components/spec-popup/styles/footer.scss
Normal file
@@ -0,0 +1,74 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__footer {
|
||||
padding: 14px 20px;
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
background: $card;
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spec-popup__footer-price {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spec-popup__footer-label {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.spec-popup__footer-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.spec-popup__footer-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.spec-popup__footer-value {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spec-popup__footer-summary {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.spec-popup__footer-btn {
|
||||
height: 48px;
|
||||
min-width: 140px;
|
||||
padding: 0 28px;
|
||||
border-radius: 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 4px 14px rgba(22, 163, 74, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&--disabled {
|
||||
background: $text-5;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
104
src/components/spec-popup/styles/header.scss
Normal file
104
src/components/spec-popup/styles/header.scss
Normal file
@@ -0,0 +1,104 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 6px 20px 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spec-popup__img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: $r-md;
|
||||
flex-shrink: 0;
|
||||
box-shadow: $shadow-sm;
|
||||
background: linear-gradient(135deg, #FED7AA, #FDBA74);
|
||||
}
|
||||
|
||||
.spec-popup__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spec-popup__name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.spec-popup__desc {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spec-popup__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spec-popup__tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.5;
|
||||
|
||||
&--hot { background: $red-light; color: #DC2626; }
|
||||
&--new { background: $primary-light; color: $primary; }
|
||||
&--sales { background: $border; color: $text-3; }
|
||||
}
|
||||
|
||||
.spec-popup__price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.spec-popup__price-unit {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.spec-popup__price-current {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spec-popup__price-origin {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.spec-popup__close {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: $border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-4;
|
||||
font-size: 14px;
|
||||
}
|
||||
5
src/components/spec-popup/styles/index.scss
Normal file
5
src/components/spec-popup/styles/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import './base.scss';
|
||||
@import './header.scss';
|
||||
@import './options.scss';
|
||||
@import './stepper.scss';
|
||||
@import './footer.scss';
|
||||
73
src/components/spec-popup/styles/options.scss
Normal file
73
src/components/spec-popup/styles/options.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__group {
|
||||
padding: 16px 20px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
|
||||
.spec-popup__group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.spec-popup__group-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.spec-popup__group-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.6;
|
||||
|
||||
&--required { background: $red-light; color: $red; }
|
||||
&--multi { background: $primary-light; color: $primary; }
|
||||
}
|
||||
|
||||
.spec-popup__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spec-popup__pill {
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-2;
|
||||
background: $bg;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 14px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--selected {
|
||||
background: $primary-lighter;
|
||||
border-color: $primary;
|
||||
color: $primary-darker;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spec-popup__pill-extra {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
font-weight: 400;
|
||||
|
||||
.spec-popup__pill--selected & {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
46
src/components/spec-popup/styles/stepper.scss
Normal file
46
src/components/spec-popup/styles/stepper.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__stepper-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
|
||||
.spec-popup__stepper-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.spec-popup__stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spec-popup__stepper-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid $text-5;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-2;
|
||||
font-size: 16px;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spec-popup__stepper-value {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
11
src/components/spec-popup/useSpecPopup.ts
Normal file
11
src/components/spec-popup/useSpecPopup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function useSpecPopup () {
|
||||
function tagClass (tag: string) {
|
||||
if (['招牌', '热卖', '热销'].includes(tag)) return 'spec-popup__tag--hot'
|
||||
if (['新品'].includes(tag)) return 'spec-popup__tag--new'
|
||||
return 'spec-popup__tag--sales'
|
||||
}
|
||||
|
||||
return {
|
||||
tagClass
|
||||
}
|
||||
}
|
||||
17
src/index.html
Normal file
17
src/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-touch-fullscreen" content="yes">
|
||||
<meta name="format-detection" content="telephone=no,address=no">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
||||
<title>TakeoutSaaS.C-Side-Mini-Program-Taro</title>
|
||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
70
src/pages/address/composables/useAddressPage.ts
Normal file
70
src/pages/address/composables/useAddressPage.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import { navigateBack, showToast } from '@tarojs/taro'
|
||||
import { pinia, useCustomerStore, useFulfillmentStore } from '@/stores'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
function readInputValue (event: InputLikeEvent) {
|
||||
return event.detail?.value || ''
|
||||
}
|
||||
|
||||
export function useAddressPage () {
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||
const name = ref(fulfillmentStore.address?.name || customerStore.name)
|
||||
const phone = ref(fulfillmentStore.address?.phone || customerStore.phone)
|
||||
const address = ref(fulfillmentStore.address?.address || '')
|
||||
const detail = ref(fulfillmentStore.address?.detail || '')
|
||||
|
||||
function handleNameInput (event: InputLikeEvent) {
|
||||
name.value = readInputValue(event)
|
||||
}
|
||||
|
||||
function handlePhoneInput (event: InputLikeEvent) {
|
||||
phone.value = readInputValue(event)
|
||||
}
|
||||
|
||||
function handleAddressInput (event: InputLikeEvent) {
|
||||
address.value = readInputValue(event)
|
||||
}
|
||||
|
||||
function handleDetailInput (event: InputLikeEvent) {
|
||||
detail.value = readInputValue(event)
|
||||
}
|
||||
|
||||
async function handleSave () {
|
||||
if (!name.value.trim() || !phone.value.trim() || !address.value.trim()) {
|
||||
await showToast({ title: '请完善地址信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
fulfillmentStore.setAddress({
|
||||
id: 'local-default',
|
||||
name: name.value.trim(),
|
||||
phone: phone.value.trim(),
|
||||
address: address.value.trim(),
|
||||
detail: detail.value.trim()
|
||||
})
|
||||
|
||||
await showToast({ title: '地址已保存', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
void navigateBack()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
detail,
|
||||
handleAddressInput,
|
||||
handleDetailInput,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
handleSave,
|
||||
name,
|
||||
phone
|
||||
}
|
||||
}
|
||||
3
src/pages/address/index.config.ts
Normal file
3
src/pages/address/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '配送地址'
|
||||
})
|
||||
42
src/pages/address/index.vue
Normal file
42
src/pages/address/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<view class="page-shell address-page">
|
||||
<PageHero title="配送地址" subtitle="填写收货人和配送地址,方便下单时直接使用" />
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">联系人</text>
|
||||
<input class="field-input" :value="name" placeholder="请输入收货人姓名" @input="handleNameInput" />
|
||||
<text class="section-title address-page__title-gap">联系电话</text>
|
||||
<input class="field-input" :value="phone" type="number" placeholder="请输入联系电话" @input="handlePhoneInput" />
|
||||
<text class="section-title address-page__title-gap">地址</text>
|
||||
<input class="field-input" :value="address" placeholder="请输入配送地址" @input="handleAddressInput" />
|
||||
<text class="section-title address-page__title-gap">门牌号 / 详细地址</text>
|
||||
<input class="field-input" :value="detail" placeholder="例如 3号楼 501" @input="handleDetailInput" />
|
||||
<view class="address-page__actions">
|
||||
<NutButton type="primary" block @click="handleSave">保存地址</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useAddressPage } from './composables/useAddressPage'
|
||||
|
||||
const {
|
||||
address,
|
||||
detail,
|
||||
handleAddressInput,
|
||||
handleDetailInput,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
handleSave,
|
||||
name,
|
||||
phone
|
||||
} = useAddressPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
|
||||
7
src/pages/address/styles/index.scss
Normal file
7
src/pages/address/styles/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.address-page__title-gap {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.address-page__actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
37
src/pages/dinein/confirm/composables/useDineInConfirmPage.ts
Normal file
37
src/pages/dinein/confirm/composables/useDineInConfirmPage.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue'
|
||||
import { navigateBack, showToast } from '@tarojs/taro'
|
||||
import { pinia, useFulfillmentStore } from '@/stores'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useDineInConfirmPage () {
|
||||
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||
const tableNo = ref(fulfillmentStore.tableNo)
|
||||
|
||||
function handleInput (event: InputLikeEvent) {
|
||||
tableNo.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
async function handleSave () {
|
||||
if (!tableNo.value.trim()) {
|
||||
await showToast({ title: '请先填写桌号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
fulfillmentStore.setTableNo(tableNo.value)
|
||||
await showToast({ title: '桌号已保存', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
void navigateBack()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
return {
|
||||
handleInput,
|
||||
handleSave,
|
||||
tableNo
|
||||
}
|
||||
}
|
||||
3
src/pages/dinein/confirm/index.config.ts
Normal file
3
src/pages/dinein/confirm/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '确认桌号'
|
||||
})
|
||||
26
src/pages/dinein/confirm/index.vue
Normal file
26
src/pages/dinein/confirm/index.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<view class="page-shell dinein-page">
|
||||
<PageHero title="确认桌号" subtitle="请先确认桌号,方便商家及时送餐" />
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">桌号</text>
|
||||
<input class="field-input" :value="tableNo" placeholder="请输入桌号,例如 A08" @input="handleInput" />
|
||||
<view class="dinein-page__actions">
|
||||
<NutButton type="primary" block @click="handleSave">保存桌号</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useDineInConfirmPage } from './composables/useDineInConfirmPage'
|
||||
|
||||
const { handleInput, handleSave, tableNo } = useDineInConfirmPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
|
||||
3
src/pages/dinein/confirm/styles/index.scss
Normal file
3
src/pages/dinein/confirm/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.dinein-page__actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
62
src/pages/home/composables/useHomePage.ts
Normal file
62
src/pages/home/composables/useHomePage.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useAppStore, useCartStore } from '@/stores'
|
||||
import {
|
||||
FulfillmentScenes,
|
||||
demoHotProducts,
|
||||
type MiniProductCard
|
||||
} from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
const categoryCards = [
|
||||
{ key: 'recommend', icon: '⭐', label: '推荐', toneClass: 'home-page__cat-icon--orange' },
|
||||
{ key: 'meal', icon: '🍚', label: '主食', toneClass: 'home-page__cat-icon--green-soft' },
|
||||
{ key: 'snack', icon: '🍜', label: '小吃', toneClass: 'home-page__cat-icon--yellow', badge: 'HOT' },
|
||||
{ key: 'drink', icon: '🧋', label: '饮品', toneClass: 'home-page__cat-icon--green-light' },
|
||||
{ key: 'set', icon: '📦', label: '套餐', toneClass: 'home-page__cat-icon--mint' },
|
||||
{ key: 'dessert', icon: '🍰', label: '甜品', toneClass: 'home-page__cat-icon--amber' }
|
||||
] as const
|
||||
|
||||
const trustItems = [
|
||||
{ key: 'fresh', icon: '⚡', label: '现炒现做' },
|
||||
{ key: 'fast', icon: '🕐', label: '30分钟送达' },
|
||||
{ key: 'pickup', icon: '🛍', label: '自提更快' },
|
||||
{ key: 'quality', icon: '🛡', label: '品质保证' }
|
||||
] as const
|
||||
|
||||
export function useHomePage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const cartStore = useCartStore(pinia)
|
||||
const currentStore = computed(() => appStore.currentStore)
|
||||
const cartCount = computed(() => cartStore.itemCount)
|
||||
const isDineIn = computed(() => appStore.scene === FulfillmentScenes.DineIn)
|
||||
const recommendedProducts = ref<MiniProductCard[]>(demoHotProducts)
|
||||
|
||||
async function refreshPage () {
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
}
|
||||
|
||||
function goStoreSelect () {
|
||||
void openRoute('/pages/store/select/index')
|
||||
}
|
||||
|
||||
function goMenu () {
|
||||
void openRoute('/pages/menu/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void refreshPage()
|
||||
})
|
||||
|
||||
return {
|
||||
cartCount,
|
||||
categoryCards,
|
||||
currentStore,
|
||||
goMenu,
|
||||
goStoreSelect,
|
||||
isDineIn,
|
||||
recommendedProducts,
|
||||
trustItems
|
||||
}
|
||||
}
|
||||
3
src/pages/home/index.config.ts
Normal file
3
src/pages/home/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
134
src/pages/home/index.vue
Normal file
134
src/pages/home/index.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<!-- Store Card -->
|
||||
<view class="home-page__store-card">
|
||||
<view class="home-page__store-icon-wrap">
|
||||
<text class="home-page__store-icon-emoji">📍</text>
|
||||
</view>
|
||||
<view class="home-page__store-text">
|
||||
<view class="home-page__store-name">
|
||||
<text class="home-page__store-name-text">{{ currentStore.name }}</text>
|
||||
<view class="home-page__store-status">
|
||||
<view class="home-page__store-status-dot" />
|
||||
<text>营业中</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="home-page__store-addr">{{ currentStore.address }}</text>
|
||||
</view>
|
||||
<view class="home-page__store-switch" @click="goStoreSelect">
|
||||
<text>切换</text>
|
||||
<text class="home-page__chevron">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Dine-in Scan Button -->
|
||||
<view v-if="isDineIn" class="home-page__scan-tab" @click="goMenu">
|
||||
<text>🔲</text>
|
||||
<text>堂食扫码点餐</text>
|
||||
</view>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<view class="home-page__search" @click="goMenu">
|
||||
<text class="home-page__search-icon">🔍</text>
|
||||
<text class="home-page__search-placeholder">搜索菜品、套餐、饮品</text>
|
||||
</view>
|
||||
|
||||
<!-- Banner -->
|
||||
<view class="home-page__banner" @click="goMenu">
|
||||
<view class="home-page__banner-content">
|
||||
<view class="home-page__banner-tag">
|
||||
<text>✓ 新客专享</text>
|
||||
</view>
|
||||
<text class="home-page__banner-title">首单立减</text>
|
||||
<view class="home-page__banner-amount">
|
||||
<text class="home-page__banner-unit">¥</text>
|
||||
<text>12</text>
|
||||
</view>
|
||||
<text class="home-page__banner-desc">全场满38再减8 · 限时三天</text>
|
||||
<view class="home-page__banner-cta">
|
||||
<text>立即领取 →</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="home-page__banner-ring-outer" />
|
||||
<view class="home-page__banner-ring" />
|
||||
<image
|
||||
class="home-page__banner-food-img"
|
||||
src="https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=240&h=240&fit=crop&auto=format&q=80"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Categories -->
|
||||
<view class="home-page__categories">
|
||||
<view
|
||||
v-for="category in categoryCards"
|
||||
:key="category.key"
|
||||
class="home-page__cat-item"
|
||||
@click="goMenu"
|
||||
>
|
||||
<view class="home-page__cat-icon" :class="category.toneClass">
|
||||
<text>{{ category.icon }}</text>
|
||||
<text v-if="category.badge" class="home-page__cat-badge">{{ category.badge }}</text>
|
||||
</view>
|
||||
<text class="home-page__cat-label">{{ category.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Section Header -->
|
||||
<view class="home-page__section-head">
|
||||
<text class="home-page__section-title">热门推荐</text>
|
||||
<view class="home-page__section-more" @click="goMenu">
|
||||
<text>查看全部 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Product List -->
|
||||
<view class="home-page__product-list">
|
||||
<ProductCard
|
||||
v-for="product in recommendedProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
@select="goMenu"
|
||||
@action="goMenu"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Trust Section -->
|
||||
<view class="home-page__trust">
|
||||
<view class="home-page__trust-grid">
|
||||
<view v-for="item in trustItems" :key="item.key" class="home-page__trust-item">
|
||||
<view class="home-page__trust-icon"><text>{{ item.icon }}</text></view>
|
||||
<text class="home-page__trust-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Floating Cart FAB -->
|
||||
<view v-if="cartCount > 0" class="home-page__fab" @click="goMenu">
|
||||
<text class="home-page__fab-icon">🛒</text>
|
||||
<view class="home-page__fab-badge">
|
||||
<text>{{ cartCount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProductCard from '@/components/product-card/index.vue'
|
||||
import { useHomePage } from './composables/useHomePage'
|
||||
|
||||
const {
|
||||
cartCount,
|
||||
categoryCards,
|
||||
currentStore,
|
||||
goMenu,
|
||||
goStoreSelect,
|
||||
isDineIn,
|
||||
recommendedProducts,
|
||||
trustItems
|
||||
} = useHomePage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
121
src/pages/home/styles/banner.scss
Normal file
121
src/pages/home/styles/banner.scss
Normal file
@@ -0,0 +1,121 @@
|
||||
.home-page__banner {
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 160px;
|
||||
background: linear-gradient(135deg, #14532D 0%, #166534 35%, #15803D 70%, #1A7A42 100%);
|
||||
padding: 22px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(22, 101, 52, 0.25), 0 8px 32px rgba(22, 101, 52, 0.12);
|
||||
}
|
||||
|
||||
.home-page__banner-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.home-page__banner-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.home-page__banner-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.home-page__banner-amount {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.1;
|
||||
margin: 4px 0 2px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.home-page__banner-unit {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-page__banner-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.home-page__banner-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 32px;
|
||||
padding: 0 18px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #14532D;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
width: fit-content;
|
||||
margin-top: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.home-page__banner-food-img {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.2));
|
||||
}
|
||||
|
||||
.home-page__banner-ring {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 144px;
|
||||
height: 144px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.home-page__banner-ring-outer {
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
z-index: 0;
|
||||
}
|
||||
161
src/pages/home/styles/base.scss
Normal file
161
src/pages/home/styles/base.scss
Normal file
@@ -0,0 +1,161 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.home-page__store-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 14px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home-page__store-icon-wrap {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-page__store-icon-emoji {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.home-page__store-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-page__store-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-page__store-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-page__store-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-page__store-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $primary;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-page__store-addr {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-page__store-switch {
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-page__chevron {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-page__scan-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 44px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(22, 163, 74, 0.35);
|
||||
}
|
||||
|
||||
.home-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #F1F5F9;
|
||||
border-radius: $r-md;
|
||||
padding: 0 14px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.home-page__search-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.home-page__search-placeholder {
|
||||
font-size: 14px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.home-page__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.home-page__section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.home-page__section-more {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.home-page__product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
54
src/pages/home/styles/category.scss
Normal file
54
src/pages/home/styles/category.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page__categories {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.home-page__cat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 2px 8px;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
|
||||
.home-page__cat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
font-size: 18px;
|
||||
|
||||
&--orange { background: #FFF4ED; }
|
||||
&--green-soft { background: #F0FDF4; }
|
||||
&--yellow { background: #FFFBEB; }
|
||||
&--green-light { background: #ECFDF5; }
|
||||
&--mint { background: #F0FDF4; }
|
||||
&--amber { background: #FFF7ED; }
|
||||
}
|
||||
|
||||
.home-page__cat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.home-page__cat-badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -6px;
|
||||
font-size: 9px;
|
||||
background: $red;
|
||||
color: #fff;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
37
src/pages/home/styles/fab.scss
Normal file
37
src/pages/home/styles/fab.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page__fab {
|
||||
position: fixed;
|
||||
bottom: 92px;
|
||||
right: 16px;
|
||||
z-index: 90;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.home-page__fab-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.home-page__fab-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: $red;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
5
src/pages/home/styles/index.scss
Normal file
5
src/pages/home/styles/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import './base.scss';
|
||||
@import './banner.scss';
|
||||
@import './category.scss';
|
||||
@import './trust.scss';
|
||||
@import './fab.scss';
|
||||
41
src/pages/home/styles/trust.scss
Normal file
41
src/pages/home/styles/trust.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page__trust {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 16px 12px;
|
||||
box-shadow: $shadow-xs;
|
||||
}
|
||||
|
||||
.home-page__trust-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.home-page__trust-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.home-page__trust-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 11px;
|
||||
background: $primary-lighter;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-page__trust-label {
|
||||
font-size: 11px;
|
||||
color: $text-2;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
52
src/pages/menu/composables/menu-page/data-actions.ts
Normal file
52
src/pages/menu/composables/menu-page/data-actions.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type {
|
||||
FulfillmentScene,
|
||||
MiniCategory,
|
||||
MiniMenuSection
|
||||
} from '@/shared'
|
||||
import { getCategories, getMenu } from '@/services'
|
||||
import type { useAppStore } from '@/stores'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
|
||||
export function createMenuDataActions (payload: {
|
||||
appStore: AppStoreInstance
|
||||
categories: Ref<MiniCategory[]>
|
||||
errorMessage: Ref<string>
|
||||
loading: Ref<boolean>
|
||||
sections: Ref<MiniMenuSection[]>
|
||||
}) {
|
||||
const { appStore, categories, errorMessage, loading, sections } = payload
|
||||
|
||||
async function loadMenu () {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
|
||||
const [nextCategories, nextSections] = await Promise.all([
|
||||
getCategories(appStore.currentStore.id, appStore.scene, appStore.channel),
|
||||
getMenu(appStore.currentStore.id, appStore.scene, appStore.channel)
|
||||
])
|
||||
|
||||
categories.value = nextCategories
|
||||
sections.value = nextSections
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '菜单加载失败,请检查接口是否可用'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSceneChange (value: string) {
|
||||
appStore.setScene(value as FulfillmentScene)
|
||||
await loadMenu()
|
||||
}
|
||||
|
||||
return {
|
||||
handleSceneChange,
|
||||
loadMenu
|
||||
}
|
||||
}
|
||||
183
src/pages/menu/composables/menu-page/detail-actions.ts
Normal file
183
src/pages/menu/composables/menu-page/detail-actions.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type {
|
||||
MiniProductCard,
|
||||
MiniProductDetail,
|
||||
MiniProductOption,
|
||||
MiniProductOptionGroup
|
||||
} from '@/shared'
|
||||
import { showToast } from '@tarojs/taro'
|
||||
import { getProductDetail } from '@/services'
|
||||
import type { useAppStore, useCartStore } from '@/stores'
|
||||
import {
|
||||
buildSelectedNames,
|
||||
resolveSelectionError,
|
||||
resolveSkuId
|
||||
} from './selection-helpers'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
type CartStoreInstance = ReturnType<typeof useCartStore>
|
||||
|
||||
export function createMenuDetailActions (payload: {
|
||||
activeDetail: Ref<MiniProductDetail | null>
|
||||
addonSelections: Ref<Record<string, string[]>>
|
||||
appStore: AppStoreInstance
|
||||
canAddCurrentDetail: ComputedRef<boolean>
|
||||
cartStore: CartStoreInstance
|
||||
closeDetail: () => void
|
||||
currentDetailPrice: ComputedRef<number>
|
||||
currentSkuId: Ref<string>
|
||||
detailQuantity: Ref<number>
|
||||
detailVisible: Ref<boolean>
|
||||
specSelections: Ref<Record<string, string[]>>
|
||||
}) {
|
||||
const {
|
||||
activeDetail,
|
||||
addonSelections,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartStore,
|
||||
closeDetail,
|
||||
currentDetailPrice,
|
||||
currentSkuId,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
specSelections
|
||||
} = payload
|
||||
|
||||
async function openProductDetail (productId: string) {
|
||||
try {
|
||||
const detail = await getProductDetail(productId, appStore.scene, appStore.channel)
|
||||
activeDetail.value = detail
|
||||
detailQuantity.value = 1
|
||||
|
||||
const nextSpecSelections: Record<string, string[]> = {}
|
||||
const nextAddonSelections: Record<string, string[]> = {}
|
||||
const defaultSku = detail.skus.find((sku) => sku.id === detail.defaultSkuId) || detail.skus[0]
|
||||
|
||||
detail.optionGroups.forEach((group) => {
|
||||
if (group.groupType === 'addon') {
|
||||
const defaults = group.required
|
||||
? group.options
|
||||
.filter((option) => !option.soldOut)
|
||||
.slice(0, Math.max(group.minSelect, 1))
|
||||
.map((option) => option.id)
|
||||
: []
|
||||
nextAddonSelections[group.id] = defaults
|
||||
return
|
||||
}
|
||||
|
||||
const matched = group.options.find((option) => defaultSku?.selectedOptionIds.includes(option.id))
|
||||
|| group.options.find((option) => !option.soldOut)
|
||||
nextSpecSelections[group.id] = matched ? [matched.id] : []
|
||||
})
|
||||
|
||||
specSelections.value = nextSpecSelections
|
||||
addonSelections.value = nextAddonSelections
|
||||
currentSkuId.value = detail.skus.length ? resolveSkuId(detail, nextSpecSelections) : ''
|
||||
detailVisible.value = true
|
||||
} catch (error: unknown) {
|
||||
await showToast({
|
||||
title: error instanceof Error ? error.message : '商品详情加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOption (group: MiniProductOptionGroup, option: MiniProductOption) {
|
||||
if (option.soldOut) return
|
||||
|
||||
if (group.groupType === 'addon') {
|
||||
const current = addonSelections.value[group.id] || []
|
||||
|
||||
if (group.selectionType === 'single') {
|
||||
addonSelections.value = { ...addonSelections.value, [group.id]: [option.id] }
|
||||
return
|
||||
}
|
||||
|
||||
const hasSelected = current.includes(option.id)
|
||||
const next = hasSelected
|
||||
? current.filter((id) => id !== option.id)
|
||||
: [...current, option.id]
|
||||
addonSelections.value = { ...addonSelections.value, [group.id]: next }
|
||||
return
|
||||
}
|
||||
|
||||
specSelections.value = { ...specSelections.value, [group.id]: [option.id] }
|
||||
if (activeDetail.value) {
|
||||
currentSkuId.value = resolveSkuId(activeDetail.value, specSelections.value)
|
||||
}
|
||||
}
|
||||
|
||||
function changeDetailQuantity (delta: number) {
|
||||
detailQuantity.value = Math.max(1, detailQuantity.value + delta)
|
||||
}
|
||||
|
||||
async function confirmAddCurrentDetail () {
|
||||
if (!activeDetail.value || !canAddCurrentDetail.value) {
|
||||
const message = resolveSelectionError({
|
||||
activeDetail: activeDetail.value,
|
||||
addonSelections: addonSelections.value,
|
||||
currentSkuId: currentSkuId.value,
|
||||
specSelections: specSelections.value
|
||||
})
|
||||
|
||||
if (message) {
|
||||
await showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const specNames = buildSelectedNames(
|
||||
activeDetail.value.optionGroups.filter((group) => group.groupType !== 'addon'),
|
||||
specSelections.value
|
||||
)
|
||||
const addonNames = buildSelectedNames(
|
||||
activeDetail.value.optionGroups.filter((group) => group.groupType === 'addon'),
|
||||
addonSelections.value
|
||||
)
|
||||
const addonIds = activeDetail.value.optionGroups
|
||||
.filter((group) => group.groupType === 'addon')
|
||||
.flatMap((group) => addonSelections.value[group.id] || [])
|
||||
|
||||
cartStore.addItem({
|
||||
productId: activeDetail.value.id,
|
||||
name: activeDetail.value.name,
|
||||
unitPrice: currentDetailPrice.value / detailQuantity.value,
|
||||
quantity: detailQuantity.value,
|
||||
skuId: currentSkuId.value || undefined,
|
||||
skuName: specNames.join('/'),
|
||||
addonItemIds: addonIds,
|
||||
addonNames,
|
||||
coverImageUrl: activeDetail.value.coverImageUrl
|
||||
})
|
||||
|
||||
await showToast({ title: '已加入购物车', icon: 'success' })
|
||||
closeDetail()
|
||||
}
|
||||
|
||||
async function handleProductAction (product: MiniProductCard) {
|
||||
if (product.hasOptions) {
|
||||
await openProductDetail(product.id)
|
||||
return
|
||||
}
|
||||
|
||||
cartStore.addItem({
|
||||
productId: product.id,
|
||||
name: product.name,
|
||||
unitPrice: product.price,
|
||||
quantity: 1,
|
||||
coverImageUrl: product.coverImageUrl,
|
||||
addonItemIds: [],
|
||||
addonNames: []
|
||||
})
|
||||
|
||||
await showToast({ title: '已加入购物车', icon: 'success' })
|
||||
}
|
||||
|
||||
return {
|
||||
changeDetailQuantity,
|
||||
confirmAddCurrentDetail,
|
||||
handleProductAction,
|
||||
toggleOption
|
||||
}
|
||||
}
|
||||
61
src/pages/menu/composables/menu-page/selection-helpers.ts
Normal file
61
src/pages/menu/composables/menu-page/selection-helpers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
MiniProductDetail,
|
||||
MiniProductOptionGroup
|
||||
} from '@/shared'
|
||||
|
||||
export function getSelectedIds (
|
||||
addonSelections: Record<string, string[]>,
|
||||
specSelections: Record<string, string[]>,
|
||||
groupId: string
|
||||
) {
|
||||
return addonSelections[groupId] || specSelections[groupId] || []
|
||||
}
|
||||
|
||||
export function resolveSkuId (
|
||||
detail: MiniProductDetail,
|
||||
nextSelections: Record<string, string[]>
|
||||
) {
|
||||
const selectedOptionIds = detail.optionGroups
|
||||
.filter((group) => group.groupType !== 'addon')
|
||||
.flatMap((group) => nextSelections[group.id] || [])
|
||||
.sort()
|
||||
|
||||
if (!detail.skus.length) return ''
|
||||
|
||||
const matchedSku = detail.skus.find((sku) =>
|
||||
[...sku.selectedOptionIds].sort().join('|') === selectedOptionIds.join('|')
|
||||
)
|
||||
|
||||
return matchedSku?.id || ''
|
||||
}
|
||||
|
||||
export function resolveSelectionError (payload: {
|
||||
activeDetail: MiniProductDetail | null
|
||||
addonSelections: Record<string, string[]>
|
||||
currentSkuId: string
|
||||
specSelections: Record<string, string[]>
|
||||
}) {
|
||||
const { activeDetail, addonSelections, currentSkuId, specSelections } = payload
|
||||
if (!activeDetail) return ''
|
||||
|
||||
for (const group of activeDetail.optionGroups) {
|
||||
const selectedCount = getSelectedIds(addonSelections, specSelections, group.id).length
|
||||
const requiredCount = group.required ? Math.max(group.minSelect, 1) : group.minSelect
|
||||
|
||||
if (requiredCount > 0 && selectedCount < requiredCount) return `请选择${group.name}`
|
||||
if (group.selectionType === 'single' && selectedCount > 1) return `${group.name}只能选择一项`
|
||||
if (group.maxSelect > 0 && selectedCount > group.maxSelect) return `${group.name}超出可选上限`
|
||||
}
|
||||
|
||||
if (activeDetail.skus.length && !currentSkuId) return '请先选择完整规格'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function buildSelectedNames (
|
||||
groups: MiniProductOptionGroup[],
|
||||
source: Record<string, string[]>
|
||||
) {
|
||||
return groups
|
||||
.flatMap((group) => group.options.filter((option) => (source[group.id] || []).includes(option.id)))
|
||||
.map((option) => option.name)
|
||||
}
|
||||
177
src/pages/menu/composables/useMenuPage.ts
Normal file
177
src/pages/menu/composables/useMenuPage.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { showToast, useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useAppStore, useCartStore } from '@/stores'
|
||||
import type {
|
||||
MiniCategory,
|
||||
MiniMenuSection,
|
||||
MiniProductDetail
|
||||
} from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
import { createMenuDataActions } from './menu-page/data-actions'
|
||||
import { createMenuDetailActions } from './menu-page/detail-actions'
|
||||
import {
|
||||
getSelectedIds,
|
||||
resolveSelectionError
|
||||
} from './menu-page/selection-helpers'
|
||||
|
||||
export function useMenuPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const cartStore = useCartStore(pinia)
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const categories = ref<MiniCategory[]>([])
|
||||
const sections = ref<MiniMenuSection[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const cartVisible = ref(false)
|
||||
const activeDetail = ref<MiniProductDetail | null>(null)
|
||||
const currentSkuId = ref('')
|
||||
const detailQuantity = ref(1)
|
||||
const specSelections = ref<Record<string, string[]>>({})
|
||||
const addonSelections = ref<Record<string, string[]>>({})
|
||||
|
||||
const currentStore = computed(() => appStore.currentStore)
|
||||
const cartCount = computed(() => cartStore.itemCount)
|
||||
const totalAmountText = computed(() => cartStore.totalAmountText)
|
||||
const lineList = computed(() => cartStore.lineList)
|
||||
const currentSku = computed(() =>
|
||||
activeDetail.value?.skus.find((sku) => sku.id === currentSkuId.value) || null
|
||||
)
|
||||
const currentDetailPrice = computed(() => {
|
||||
if (!activeDetail.value) return 0
|
||||
|
||||
const basePrice = currentSku.value?.price ?? activeDetail.value.basePrice
|
||||
const addonPrice = activeDetail.value.optionGroups
|
||||
.filter((group) => group.groupType === 'addon')
|
||||
.flatMap((group) =>
|
||||
group.options.filter((option) => (addonSelections.value[group.id] || []).includes(option.id))
|
||||
)
|
||||
.reduce((amount, option) => amount + option.extraPrice, 0)
|
||||
|
||||
return (basePrice + addonPrice) * detailQuantity.value
|
||||
})
|
||||
const displayUnitPrice = computed(() =>
|
||||
detailQuantity.value > 0 ? currentDetailPrice.value / detailQuantity.value : 0
|
||||
)
|
||||
const currentDetailPriceText = computed(() => currentDetailPrice.value.toFixed(2))
|
||||
const canAddCurrentDetail = computed(() => {
|
||||
if (!activeDetail.value) return false
|
||||
if (activeDetail.value.skus.length && !currentSkuId.value) return false
|
||||
|
||||
return !resolveSelectionError({
|
||||
activeDetail: activeDetail.value,
|
||||
addonSelections: addonSelections.value,
|
||||
currentSkuId: currentSkuId.value,
|
||||
specSelections: specSelections.value
|
||||
})
|
||||
})
|
||||
const summaryText = computed(() => {
|
||||
if (!activeDetail.value) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
activeDetail.value.optionGroups.forEach((group) => {
|
||||
const selected = getSelectedIds(addonSelections.value, specSelections.value, group.id)
|
||||
group.options
|
||||
.filter((option) => selected.includes(option.id))
|
||||
.forEach((option) => parts.push(option.name))
|
||||
})
|
||||
|
||||
return parts.join(' · ') || '请选择规格'
|
||||
})
|
||||
const disabledButtonText = computed(() =>
|
||||
resolveSelectionError({
|
||||
activeDetail: activeDetail.value,
|
||||
addonSelections: addonSelections.value,
|
||||
currentSkuId: currentSkuId.value,
|
||||
specSelections: specSelections.value
|
||||
}) || '请选择规格'
|
||||
)
|
||||
|
||||
function isOptionSelected (groupId: string, optionId: string) {
|
||||
return getSelectedIds(addonSelections.value, specSelections.value, groupId).includes(optionId)
|
||||
}
|
||||
|
||||
function closeDetail () {
|
||||
detailVisible.value = false
|
||||
activeDetail.value = null
|
||||
currentSkuId.value = ''
|
||||
detailQuantity.value = 1
|
||||
specSelections.value = {}
|
||||
addonSelections.value = {}
|
||||
}
|
||||
|
||||
const { handleSceneChange, loadMenu } = createMenuDataActions({
|
||||
appStore,
|
||||
categories,
|
||||
errorMessage,
|
||||
loading,
|
||||
sections
|
||||
})
|
||||
|
||||
const {
|
||||
changeDetailQuantity,
|
||||
confirmAddCurrentDetail,
|
||||
handleProductAction,
|
||||
toggleOption
|
||||
} = createMenuDetailActions({
|
||||
activeDetail,
|
||||
addonSelections,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartStore,
|
||||
closeDetail,
|
||||
currentDetailPrice,
|
||||
currentSkuId,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
specSelections
|
||||
})
|
||||
|
||||
function goStoreSelect () {
|
||||
void openRoute('/pages/store/select/index')
|
||||
}
|
||||
|
||||
async function goCheckout () {
|
||||
if (!cartStore.itemCount) {
|
||||
await showToast({ title: '请先加购商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
void openRoute('/pages/trade/checkout/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void loadMenu()
|
||||
})
|
||||
|
||||
return {
|
||||
activeDetail,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartCount,
|
||||
cartStore,
|
||||
cartVisible,
|
||||
categories,
|
||||
changeDetailQuantity,
|
||||
closeDetail,
|
||||
confirmAddCurrentDetail,
|
||||
currentDetailPriceText,
|
||||
currentStore,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
disabledButtonText,
|
||||
displayUnitPrice,
|
||||
errorMessage,
|
||||
goCheckout,
|
||||
goStoreSelect,
|
||||
handleProductAction,
|
||||
handleSceneChange,
|
||||
isOptionSelected,
|
||||
lineList,
|
||||
loadMenu,
|
||||
loading,
|
||||
sections,
|
||||
summaryText,
|
||||
toggleOption,
|
||||
totalAmountText
|
||||
}
|
||||
}
|
||||
3
src/pages/menu/index.config.ts
Normal file
3
src/pages/menu/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '点餐'
|
||||
})
|
||||
166
src/pages/menu/index.vue
Normal file
166
src/pages/menu/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<view class="menu-page">
|
||||
<!-- Store Header -->
|
||||
<view class="menu-page__store-card">
|
||||
<view class="menu-page__store-icon-wrap">
|
||||
<text class="menu-page__store-icon-emoji">📍</text>
|
||||
</view>
|
||||
<view class="menu-page__store-text">
|
||||
<view class="menu-page__store-name">
|
||||
<text class="menu-page__store-name-text">{{ currentStore.name }}</text>
|
||||
<view class="menu-page__store-status">
|
||||
<view class="menu-page__store-status-dot" />
|
||||
<text>营业中</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-page__store-addr">{{ currentStore.address }}</text>
|
||||
</view>
|
||||
<view class="menu-page__store-switch" @click="goStoreSelect">
|
||||
<text>切换</text>
|
||||
<text class="menu-page__chevron">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Scene Switcher -->
|
||||
<view class="menu-page__scene-tabs">
|
||||
<view
|
||||
v-for="option in appStore.sceneOptions"
|
||||
:key="option.value"
|
||||
class="menu-page__scene-tab"
|
||||
:class="{ 'menu-page__scene-tab--active': appStore.scene === option.value }"
|
||||
@click="handleSceneChange(option.value)"
|
||||
>
|
||||
<text>{{ option.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Category Strip -->
|
||||
<scroll-view v-if="categories.length" class="menu-page__category-scroll" scroll-x enhanced>
|
||||
<view class="menu-page__category-list">
|
||||
<view
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="menu-page__category-pill"
|
||||
>
|
||||
<text>{{ category.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Loading State -->
|
||||
<view v-if="loading" class="menu-page__loading">
|
||||
<text class="menu-page__loading-text">菜单加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- Error State -->
|
||||
<view v-else-if="errorMessage" class="menu-page__error">
|
||||
<text class="menu-page__error-text">{{ errorMessage }}</text>
|
||||
<view class="menu-page__error-btn" @click="loadMenu">
|
||||
<text>重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Product List -->
|
||||
<view v-else class="menu-page__sections">
|
||||
<view v-for="section in sections" :key="section.categoryId" class="menu-page__section">
|
||||
<view class="menu-page__section-head">
|
||||
<text class="menu-page__section-title">{{ section.categoryName }}</text>
|
||||
<text class="menu-page__section-count">{{ section.products.length }} 款在售</text>
|
||||
</view>
|
||||
<view class="menu-page__product-list">
|
||||
<ProductCard
|
||||
v-for="product in section.products"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
@select="handleProductAction(product)"
|
||||
@action="handleProductAction(product)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Bottom Cart Bar -->
|
||||
<view class="menu-page__cart-bar">
|
||||
<view class="menu-page__cart-main" @click="cartVisible = !cartVisible">
|
||||
<text class="menu-page__cart-label">购物车 {{ cartCount }} 件</text>
|
||||
<text class="menu-page__cart-price">¥{{ totalAmountText }}</text>
|
||||
</view>
|
||||
<view class="menu-page__cart-btn" @click="goCheckout">
|
||||
<text>去结算</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Cart Drawer -->
|
||||
<CartDrawer
|
||||
:visible="cartVisible"
|
||||
:line-list="lineList"
|
||||
:item-count="cartCount"
|
||||
:total-amount-text="totalAmountText"
|
||||
@close="cartVisible = false"
|
||||
@clear="cartStore.clear()"
|
||||
@checkout="goCheckout"
|
||||
@change-qty="(key, delta) => cartStore.changeQuantity(key, delta)"
|
||||
/>
|
||||
|
||||
<!-- Spec Popup -->
|
||||
<SpecPopup
|
||||
v-if="detailVisible && activeDetail"
|
||||
:visible="detailVisible"
|
||||
:product="activeDetail"
|
||||
:quantity="detailQuantity"
|
||||
:display-unit-price="displayUnitPrice"
|
||||
:total-price-text="currentDetailPriceText"
|
||||
:summary-text="summaryText"
|
||||
:can-add="canAddCurrentDetail"
|
||||
:disabled-text="disabledButtonText"
|
||||
:is-selected="isOptionSelected"
|
||||
@close="closeDetail"
|
||||
@add-to-cart="confirmAddCurrentDetail"
|
||||
@toggle-option="toggleOption"
|
||||
@change-qty="changeDetailQuantity"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProductCard from '@/components/product-card/index.vue'
|
||||
import CartDrawer from '@/components/cart-drawer/index.vue'
|
||||
import SpecPopup from '@/components/spec-popup/index.vue'
|
||||
import { useMenuPage } from './composables/useMenuPage'
|
||||
|
||||
const {
|
||||
activeDetail,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartCount,
|
||||
cartStore,
|
||||
cartVisible,
|
||||
categories,
|
||||
changeDetailQuantity,
|
||||
closeDetail,
|
||||
confirmAddCurrentDetail,
|
||||
currentDetailPriceText,
|
||||
currentStore,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
disabledButtonText,
|
||||
displayUnitPrice,
|
||||
errorMessage,
|
||||
goCheckout,
|
||||
goStoreSelect,
|
||||
handleProductAction,
|
||||
handleSceneChange,
|
||||
isOptionSelected,
|
||||
lineList,
|
||||
loadMenu,
|
||||
loading,
|
||||
sections,
|
||||
summaryText,
|
||||
toggleOption,
|
||||
totalAmountText
|
||||
} = useMenuPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
185
src/pages/menu/styles/base.scss
Normal file
185
src/pages/menu/styles/base.scss
Normal file
@@ -0,0 +1,185 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.menu-page {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.menu-page__store-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 14px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-page__store-icon-wrap {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-page__store-icon-emoji {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.menu-page__store-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-page__store-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.menu-page__store-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-page__store-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-page__store-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $primary;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.menu-page__store-addr {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu-page__store-switch {
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-page__chevron {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu-page__scene-tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
border-radius: $r-md;
|
||||
padding: 4px;
|
||||
box-shadow: $shadow-xs;
|
||||
border: 1px solid $border;
|
||||
}
|
||||
|
||||
.menu-page__scene-tab {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-3;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--active {
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(22, 163, 74, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-page__category-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-page__category-list {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.menu-page__category-pill {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background: $card;
|
||||
border: 1px solid $border;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-page__loading,
|
||||
.menu-page__error {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.menu-page__loading-text {
|
||||
font-size: 14px;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.menu-page__error-text {
|
||||
font-size: 14px;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.menu-page__error-btn {
|
||||
margin-top: 16px;
|
||||
display: inline-flex;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 18px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
48
src/pages/menu/styles/cart.scss
Normal file
48
src/pages/menu/styles/cart.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.menu-page__cart-bar {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 22px;
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.menu-page__cart-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-page__cart-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.menu-page__cart-price {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.menu-page__cart-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
3
src/pages/menu/styles/index.scss
Normal file
3
src/pages/menu/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
@import './base.scss';
|
||||
@import './sections.scss';
|
||||
@import './cart.scss';
|
||||
30
src/pages/menu/styles/sections.scss
Normal file
30
src/pages/menu/styles/sections.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.menu-page__section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.menu-page__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.menu-page__section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.menu-page__section-count {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.menu-page__product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { showToast } from '@tarojs/taro'
|
||||
import { getOrderDetail, mockPayOrder } from '@/services'
|
||||
import type { OrderDetail } from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
export function createOrderDetailActions (payload: {
|
||||
loading: Ref<boolean>
|
||||
order: Ref<OrderDetail | null>
|
||||
orderId: Ref<string>
|
||||
}) {
|
||||
const { loading, order, orderId } = payload
|
||||
|
||||
async function loadDetail () {
|
||||
if (!orderId.value) {
|
||||
order.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
order.value = await getOrderDetail(orderId.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePrimaryAction () {
|
||||
if (!order.value) return
|
||||
|
||||
if (order.value.actionText === '再来一单') {
|
||||
void openRoute('/pages/menu/index')
|
||||
return
|
||||
}
|
||||
|
||||
if (order.value.actionText === '去支付') {
|
||||
await mockPayOrder(order.value.id)
|
||||
await showToast({ title: '支付成功', icon: 'success' })
|
||||
await loadDetail()
|
||||
return
|
||||
}
|
||||
|
||||
await showToast({ title: '当前订单处理中', icon: 'none' })
|
||||
}
|
||||
|
||||
function handleContact () {
|
||||
void showToast({ title: '拨号功能暂不可用', icon: 'none' })
|
||||
}
|
||||
|
||||
function goOrders () {
|
||||
void openRoute('/pages/orders/index')
|
||||
}
|
||||
|
||||
return {
|
||||
goOrders,
|
||||
handleContact,
|
||||
handlePrimaryAction,
|
||||
loadDetail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FulfillmentScenes, formatPrice as formatPriceUtil, SCENE_LABEL_MAP } from '@/shared'
|
||||
import type { FulfillmentScene, OrderDetail } from '@/shared'
|
||||
|
||||
export function formatPrice (value: number) {
|
||||
return formatPriceUtil(value)
|
||||
}
|
||||
|
||||
export function resolveSceneLabel (scene: FulfillmentScene) {
|
||||
return SCENE_LABEL_MAP[scene]
|
||||
}
|
||||
|
||||
export function resolveOrderSubtitle (order: OrderDetail | null) {
|
||||
if (!order) return ''
|
||||
if (order.tableNo) return `桌号 ${order.tableNo}`
|
||||
return order.customerName
|
||||
}
|
||||
|
||||
export function resolveStatusDescription (order: OrderDetail | null) {
|
||||
if (!order) return ''
|
||||
|
||||
const statusText = order.statusText
|
||||
if (statusText === '待支付') return '请尽快完成支付'
|
||||
if (statusText === '已接单' || statusText === '制作中') return '商家已接单,正在为您精心准备餐品'
|
||||
if (statusText === '配送中') return '骑手正在为您配送'
|
||||
if (statusText === '已完成') return '订单已完成,欢迎再次光临'
|
||||
return '订单处理中'
|
||||
}
|
||||
|
||||
export function resolveFulfillmentHint (order: OrderDetail | null) {
|
||||
if (!order) return ''
|
||||
if (order.scene === FulfillmentScenes.DineIn) return '餐品制作完成后将尽快为您上桌,请留意取餐提醒'
|
||||
if (order.scene === FulfillmentScenes.Pickup) return '餐品制作完成后请到店取餐'
|
||||
return '预计 30 分钟送达'
|
||||
}
|
||||
|
||||
export function resolveTimelineNodeClass (index: number, currentTimelineIndex: number) {
|
||||
if (index < currentTimelineIndex) return 'od-page__tl-node--done'
|
||||
if (index === currentTimelineIndex) return 'od-page__tl-node--current'
|
||||
return 'od-page__tl-node--pending'
|
||||
}
|
||||
64
src/pages/order/detail/composables/useOrderDetailPage.ts
Normal file
64
src/pages/order/detail/composables/useOrderDetailPage.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDidShow, useLoad } from '@tarojs/taro'
|
||||
import { pinia, useAppStore } from '@/stores'
|
||||
import type { OrderDetail } from '@/shared'
|
||||
import { createOrderDetailActions } from './order-detail-page/actions'
|
||||
import {
|
||||
formatPrice,
|
||||
resolveFulfillmentHint,
|
||||
resolveOrderSubtitle,
|
||||
resolveSceneLabel,
|
||||
resolveStatusDescription,
|
||||
resolveTimelineNodeClass
|
||||
} from './order-detail-page/status-helpers'
|
||||
|
||||
export function useOrderDetailPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const orderId = ref('')
|
||||
const loading = ref(true)
|
||||
const order = ref<OrderDetail | null>(null)
|
||||
|
||||
const { goOrders, handleContact, handlePrimaryAction, loadDetail } = createOrderDetailActions({
|
||||
loading,
|
||||
order,
|
||||
orderId
|
||||
})
|
||||
|
||||
const sceneLabel = computed(() => order.value ? resolveSceneLabel(order.value.scene) : '')
|
||||
const orderSubtitle = computed(() => resolveOrderSubtitle(order.value))
|
||||
const statusDescription = computed(() => resolveStatusDescription(order.value))
|
||||
const storeAddress = computed(() => appStore.currentStore.address)
|
||||
const fulfillmentHint = computed(() => resolveFulfillmentHint(order.value))
|
||||
const currentTimelineIndex = computed(() => {
|
||||
if (!order.value) return 0
|
||||
return Math.max(order.value.timeline.length - 1, 0)
|
||||
})
|
||||
|
||||
function timelineNodeClass (index: number) {
|
||||
return resolveTimelineNodeClass(index, currentTimelineIndex.value)
|
||||
}
|
||||
|
||||
useLoad((options) => {
|
||||
orderId.value = options?.id || ''
|
||||
})
|
||||
|
||||
useDidShow(() => {
|
||||
void loadDetail()
|
||||
})
|
||||
|
||||
return {
|
||||
currentTimelineIndex,
|
||||
fulfillmentHint,
|
||||
formatPrice,
|
||||
goOrders,
|
||||
handleContact,
|
||||
handlePrimaryAction,
|
||||
loading,
|
||||
order,
|
||||
orderSubtitle,
|
||||
sceneLabel,
|
||||
statusDescription,
|
||||
storeAddress,
|
||||
timelineNodeClass
|
||||
}
|
||||
}
|
||||
3
src/pages/order/detail/index.config.ts
Normal file
3
src/pages/order/detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单详情'
|
||||
})
|
||||
212
src/pages/order/detail/index.vue
Normal file
212
src/pages/order/detail/index.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<view class="od-page">
|
||||
<!-- Loading -->
|
||||
<view v-if="loading" class="od-page__loading">
|
||||
<text class="od-page__loading-text">订单加载中...</text>
|
||||
</view>
|
||||
|
||||
<template v-else-if="order">
|
||||
<!-- Status Hero -->
|
||||
<view class="od-page__status-hero">
|
||||
<view class="od-page__status-deco1" />
|
||||
<view class="od-page__status-deco2" />
|
||||
<view class="od-page__status-row">
|
||||
<view class="od-page__status-icon">
|
||||
<text class="od-page__status-icon-text">🕐</text>
|
||||
</view>
|
||||
<view class="od-page__status-text">
|
||||
<text class="od-page__status-title">{{ order.statusText }}</text>
|
||||
<text class="od-page__status-desc">{{ statusDescription }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="od-page__status-badge">
|
||||
<text>{{ sceneLabel }} · {{ orderSubtitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Store Card -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__store">
|
||||
<view class="od-page__store-icon">
|
||||
<text>🏠</text>
|
||||
</view>
|
||||
<view class="od-page__store-info">
|
||||
<text class="od-page__store-name">{{ order.storeName }}</text>
|
||||
<text class="od-page__store-addr">{{ storeAddress }}</text>
|
||||
</view>
|
||||
<view class="od-page__store-call">
|
||||
<text>📞</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Fulfillment Info -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__fulfill">
|
||||
<view v-if="order.tableNo" class="od-page__fulfill-row">
|
||||
<text class="od-page__fulfill-label">桌号</text>
|
||||
<view class="od-page__table-badge">
|
||||
<text>{{ order.tableNo }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="od-page__fulfill-row">
|
||||
<text class="od-page__fulfill-label">联系人</text>
|
||||
<text class="od-page__fulfill-value">{{ order.customerName }}</text>
|
||||
</view>
|
||||
<view class="od-page__fulfill-row">
|
||||
<text class="od-page__fulfill-label">手机号</text>
|
||||
<text class="od-page__fulfill-value">{{ order.customerPhone }}</text>
|
||||
</view>
|
||||
<view class="od-page__fulfill-hint">
|
||||
<text>🛡</text>
|
||||
<text class="od-page__fulfill-hint-text">{{ fulfillmentHint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Timeline -->
|
||||
<view v-if="order.timeline.length" class="od-page__card">
|
||||
<view class="od-page__timeline-section">
|
||||
<text class="od-page__section-title">📋 订单进度</text>
|
||||
<view class="od-page__timeline">
|
||||
<view
|
||||
v-for="(node, index) in order.timeline"
|
||||
:key="index"
|
||||
class="od-page__tl-node"
|
||||
:class="timelineNodeClass(index)"
|
||||
>
|
||||
<view class="od-page__tl-dot">
|
||||
<text v-if="index < currentTimelineIndex" class="od-page__tl-check">✓</text>
|
||||
<text v-else-if="index === currentTimelineIndex" class="od-page__tl-pulse">●</text>
|
||||
</view>
|
||||
<view class="od-page__tl-content">
|
||||
<text class="od-page__tl-title">{{ node.statusText }}</text>
|
||||
<text class="od-page__tl-time">{{ node.occurredAt }}</text>
|
||||
<text v-if="node.notes" class="od-page__tl-sub">{{ node.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Products + Fees -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__goods-section">
|
||||
<text class="od-page__section-title">🛍 商品明细</text>
|
||||
<view class="od-page__goods-list">
|
||||
<view v-for="item in order.items" :key="item.id" class="od-page__good">
|
||||
<view class="od-page__good-img-placeholder" />
|
||||
<view class="od-page__good-info">
|
||||
<text class="od-page__good-name">{{ item.productName }}</text>
|
||||
<text v-if="item.skuName" class="od-page__good-spec">{{ item.skuName }}</text>
|
||||
</view>
|
||||
<view class="od-page__good-right">
|
||||
<text class="od-page__good-price"><text class="od-page__good-unit">¥</text>{{ item.subTotalText }}</text>
|
||||
<text class="od-page__good-qty">×{{ item.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="od-page__divider" />
|
||||
|
||||
<view class="od-page__fees">
|
||||
<view class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">商品小计</text>
|
||||
<text class="od-page__fee-value">¥{{ formatPrice(order.itemsAmount) }}</text>
|
||||
</view>
|
||||
<view class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">打包费</text>
|
||||
<text class="od-page__fee-value">¥{{ formatPrice(order.packagingFee) }}</text>
|
||||
</view>
|
||||
<view v-if="order.deliveryFee > 0" class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">配送费</text>
|
||||
<text class="od-page__fee-value">¥{{ formatPrice(order.deliveryFee) }}</text>
|
||||
</view>
|
||||
<view v-if="order.discountAmount > 0" class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">优惠抵扣</text>
|
||||
<text class="od-page__fee-value od-page__fee-value--green">-¥{{ formatPrice(order.discountAmount) }}</text>
|
||||
</view>
|
||||
<view class="od-page__fee-row od-page__fee-row--total">
|
||||
<text class="od-page__fee-label">实付金额</text>
|
||||
<text class="od-page__fee-value od-page__fee-value--total">
|
||||
<text class="od-page__fee-unit">¥</text>{{ formatPrice(order.paidAmount || order.payableAmount) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Order Info -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__info-section">
|
||||
<text class="od-page__section-title">📄 订单信息</text>
|
||||
<view class="od-page__info-rows">
|
||||
<view class="od-page__info-row">
|
||||
<text class="od-page__info-label">订单编号</text>
|
||||
<text class="od-page__info-value">{{ order.orderNo }}</text>
|
||||
</view>
|
||||
<view class="od-page__info-row">
|
||||
<text class="od-page__info-label">下单时间</text>
|
||||
<text class="od-page__info-value">{{ order.createdAt }}</text>
|
||||
</view>
|
||||
<view v-if="order.paidAt" class="od-page__info-row">
|
||||
<text class="od-page__info-label">支付时间</text>
|
||||
<text class="od-page__info-value">{{ order.paidAt }}</text>
|
||||
</view>
|
||||
<view class="od-page__info-row">
|
||||
<text class="od-page__info-label">支付状态</text>
|
||||
<text class="od-page__info-value">{{ order.paymentStatusText }}</text>
|
||||
</view>
|
||||
<view v-if="order.remark" class="od-page__info-row">
|
||||
<text class="od-page__info-label">订单备注</text>
|
||||
<view class="od-page__info-remark">
|
||||
<text>{{ order.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<view class="od-page__bottom-bar">
|
||||
<view class="od-page__btn-outline" @click="handleContact">
|
||||
<text>📞 联系门店</text>
|
||||
</view>
|
||||
<view class="od-page__btn-primary" @click="handlePrimaryAction">
|
||||
<text>{{ order.actionText || '再来一单' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- Not found -->
|
||||
<view v-else class="od-page__empty">
|
||||
<text class="od-page__empty-title">未找到对应订单</text>
|
||||
<view class="od-page__empty-btn" @click="goOrders">
|
||||
<text>返回订单列表</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOrderDetailPage } from './composables/useOrderDetailPage'
|
||||
|
||||
const {
|
||||
fulfillmentHint,
|
||||
formatPrice,
|
||||
goOrders,
|
||||
handleContact,
|
||||
handlePrimaryAction,
|
||||
loading,
|
||||
order,
|
||||
orderSubtitle,
|
||||
sceneLabel,
|
||||
statusDescription,
|
||||
storeAddress,
|
||||
timelineNodeClass
|
||||
} = useOrderDetailPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
229
src/pages/order/detail/styles/base.scss
Normal file
229
src/pages/order/detail/styles/base.scss
Normal file
@@ -0,0 +1,229 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.od-page__loading,
|
||||
.od-page__empty {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.od-page__loading-text,
|
||||
.od-page__empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.od-page__status-hero {
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 60%, $primary-darker 100%);
|
||||
border-radius: $r-xl;
|
||||
padding: 24px 22px 22px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(22, 163, 74, 0.2);
|
||||
}
|
||||
|
||||
.od-page__status-deco1,
|
||||
.od-page__status-deco2 {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.od-page__status-deco1 {
|
||||
top: -30px;
|
||||
right: -30px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.od-page__status-deco2 {
|
||||
bottom: -20px;
|
||||
left: 40%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.od-page__status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.od-page__status-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__status-icon-text {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.od-page__status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.od-page__status-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.od-page__status-desc {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.od-page__status-badge {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 14px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.od-page__card {
|
||||
background: $card;
|
||||
border-radius: $r-xl;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.od-page__store {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.od-page__store-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.od-page__store-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.od-page__store-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.od-page__store-addr {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.od-page__store-call {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid $border;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.od-page__fulfill {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.od-page__fulfill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.od-page__fulfill-label {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
width: 58px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__fulfill-value {
|
||||
font-size: 14px;
|
||||
color: $text-1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.od-page__table-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 14px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-lighter;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.od-page__fulfill-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: $primary-lighter;
|
||||
border-radius: $r-sm;
|
||||
font-size: 12px;
|
||||
color: $primary-dark;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.od-page__fulfill-hint-text {
|
||||
font-size: 12px;
|
||||
color: $primary-dark;
|
||||
font-weight: 500;
|
||||
}
|
||||
56
src/pages/order/detail/styles/bottom.scss
Normal file
56
src/pages/order/detail/styles/bottom.scss
Normal file
@@ -0,0 +1,56 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page__bottom-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $card;
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 10px 20px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.od-page__btn-outline,
|
||||
.od-page__btn-primary,
|
||||
.od-page__empty-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.od-page__btn-outline,
|
||||
.od-page__btn-primary {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.od-page__btn-outline {
|
||||
border: 1.5px solid $border;
|
||||
background: $card;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.od-page__btn-primary {
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 3px 12px rgba(22, 163, 74, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.od-page__empty-btn {
|
||||
margin-top: 16px;
|
||||
height: 38px;
|
||||
padding: 0 24px;
|
||||
border-radius: 19px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
124
src/pages/order/detail/styles/goods.scss
Normal file
124
src/pages/order/detail/styles/goods.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page__goods-section {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.od-page__goods-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.od-page__good {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 0.5px solid $border;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.od-page__good-img-placeholder {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: $r-sm;
|
||||
background: $border;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__good-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.od-page__good-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.od-page__good-spec {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.od-page__good-right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.od-page__good-price {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.od-page__good-unit {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.od-page__good-qty {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.od-page__divider {
|
||||
height: 0.5px;
|
||||
background: $border;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.od-page__fees {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.od-page__fee-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
|
||||
&--total {
|
||||
margin-top: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__fee-label {
|
||||
font-size: 13px;
|
||||
color: $text-3;
|
||||
|
||||
.od-page__fee-row--total & {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__fee-value {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
font-weight: 500;
|
||||
|
||||
&--green { color: $primary; }
|
||||
|
||||
&--total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__fee-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
4
src/pages/order/detail/styles/index.scss
Normal file
4
src/pages/order/detail/styles/index.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import './base.scss';
|
||||
@import './timeline.scss';
|
||||
@import './goods.scss';
|
||||
@import './bottom.scss';
|
||||
148
src/pages/order/detail/styles/timeline.scss
Normal file
148
src/pages/order/detail/styles/timeline.scss
Normal file
@@ -0,0 +1,148 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page__timeline-section,
|
||||
.od-page__info-section {
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
|
||||
.od-page__section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.od-page__timeline {
|
||||
position: relative;
|
||||
padding-left: 28px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, $primary 0%, $primary-light 60%, $border 100%);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__tl-node {
|
||||
position: relative;
|
||||
padding-bottom: 20px;
|
||||
|
||||
&:last-child { padding-bottom: 0; }
|
||||
}
|
||||
|
||||
.od-page__tl-dot {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
|
||||
.od-page__tl-node--done & {
|
||||
background: $primary;
|
||||
box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.12);
|
||||
}
|
||||
|
||||
.od-page__tl-node--current & {
|
||||
background: $primary;
|
||||
box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.15), 0 0 0 10px rgba(22, 163, 74, 0.06);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.od-page__tl-node--pending & {
|
||||
background: $card;
|
||||
border: 2px solid $text-5;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__tl-check,
|
||||
.od-page__tl-pulse {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.od-page__tl-check {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.od-page__tl-pulse {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.od-page__tl-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.od-page__tl-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
|
||||
.od-page__tl-node--pending & { color: $text-5; font-weight: 500; }
|
||||
.od-page__tl-node--current & { color: $primary-dark; }
|
||||
}
|
||||
|
||||
.od-page__tl-time {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
font-weight: 400;
|
||||
|
||||
.od-page__tl-node--pending & { color: $text-5; }
|
||||
}
|
||||
|
||||
.od-page__tl-sub {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
|
||||
.od-page__tl-node--current & { color: $primary; }
|
||||
}
|
||||
|
||||
.od-page__info-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.od-page__info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.od-page__info-label {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__info-value {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.od-page__info-remark {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
background: $bg;
|
||||
padding: 8px 12px;
|
||||
border-radius: $r-xs;
|
||||
line-height: 1.5;
|
||||
}
|
||||
95
src/pages/orders/composables/useOrdersPage.ts
Normal file
95
src/pages/orders/composables/useOrdersPage.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import { getOrders } from '@/services'
|
||||
import { pinia, useAppStore, useCustomerStore } from '@/stores'
|
||||
import type { OrderSummary } from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待支付' },
|
||||
{ key: 'processing', label: '履约中' },
|
||||
{ key: 'done', label: '已完成' }
|
||||
] as const
|
||||
|
||||
type OrderTabKey = (typeof tabs)[number]['key']
|
||||
|
||||
export function useOrdersPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
const loading = ref(true)
|
||||
const activeTab = ref<OrderTabKey>('all')
|
||||
const orders = ref<OrderSummary[]>([])
|
||||
|
||||
const visibleOrders = computed(() => orders.value.filter((order) => {
|
||||
if (activeTab.value === 'pending') {
|
||||
return order.statusText === '待支付'
|
||||
}
|
||||
|
||||
if (activeTab.value === 'processing') {
|
||||
return !['待支付', '已完成', '已取消'].includes(order.statusText)
|
||||
}
|
||||
|
||||
if (activeTab.value === 'done') {
|
||||
return order.statusText === '已完成'
|
||||
}
|
||||
|
||||
return true
|
||||
}))
|
||||
|
||||
const processingCount = computed(() =>
|
||||
orders.value.filter((order) => !['待支付', '已完成', '已取消'].includes(order.statusText)).length
|
||||
)
|
||||
|
||||
async function loadOrdersList () {
|
||||
loading.value = true
|
||||
try {
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
customerStore.ensureDefaults()
|
||||
orders.value = await getOrders()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOrderTagType (statusText: string) {
|
||||
if (statusText === '待支付') {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
if (statusText === '已完成') {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (statusText === '已取消') {
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function openOrderDetail (orderId: string) {
|
||||
void openRoute(`/pages/order/detail/index?id=${orderId}`)
|
||||
}
|
||||
|
||||
function goMenu () {
|
||||
void openRoute('/pages/menu/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void loadOrdersList()
|
||||
})
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
loading,
|
||||
openOrderDetail,
|
||||
orders,
|
||||
processingCount,
|
||||
resolveOrderTagType,
|
||||
tabs,
|
||||
visibleOrders,
|
||||
goMenu
|
||||
}
|
||||
}
|
||||
3
src/pages/orders/index.config.ts
Normal file
3
src/pages/orders/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单'
|
||||
})
|
||||
88
src/pages/orders/index.vue
Normal file
88
src/pages/orders/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view class="page-shell orders-page">
|
||||
<PageHero title="订单" subtitle="查看订单状态、支付情况和配送进度" badge="我的订单">
|
||||
<view class="orders-page__hero-row">
|
||||
<view>
|
||||
<text class="orders-page__hero-label">订单总数</text>
|
||||
<text class="orders-page__hero-value">{{ orders.length }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="orders-page__hero-label">履约中</text>
|
||||
<text class="orders-page__hero-value">{{ processingCount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</PageHero>
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">筛选状态</text>
|
||||
<view class="orders-page__tabs">
|
||||
<NutButton
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
size="small"
|
||||
:type="tab.key === activeTab ? 'primary' : 'default'"
|
||||
:plain="tab.key !== activeTab"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="surface-card">
|
||||
<text class="section-title">订单加载中</text>
|
||||
<view v-for="item in 3" :key="item" class="orders-page__skeleton"></view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="visibleOrders.length">
|
||||
<view v-for="order in visibleOrders" :key="order.id" class="surface-card orders-page__card">
|
||||
<view class="row-between">
|
||||
<view>
|
||||
<text class="section-title">{{ order.storeName }}</text>
|
||||
<text class="caption-text">订单号:{{ order.orderNo }}</text>
|
||||
</view>
|
||||
<Tag round :type="resolveOrderTagType(order.statusText)">{{ order.statusText }}</Tag>
|
||||
</view>
|
||||
<text class="value-text orders-page__summary">{{ order.itemSummary }}</text>
|
||||
<text class="caption-text">支付状态:{{ order.paymentStatusText }}</text>
|
||||
<view class="row-between orders-page__footer">
|
||||
<view>
|
||||
<Price :price="order.totalAmount" />
|
||||
<text class="caption-text orders-page__time">{{ order.createdAt }}</text>
|
||||
</view>
|
||||
<NutButton size="small" type="primary" @click="openOrderDetail(order.id)">{{ order.actionText }}</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="surface-card empty-wrap">
|
||||
<Empty description="当前状态下暂无订单" />
|
||||
<view class="orders-page__empty-action">
|
||||
<NutButton type="primary" block @click="goMenu">去点餐</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton, Empty, Price, Tag } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useOrdersPage } from './composables/useOrdersPage'
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
goMenu,
|
||||
loading,
|
||||
openOrderDetail,
|
||||
orders,
|
||||
processingCount,
|
||||
resolveOrderTagType,
|
||||
tabs,
|
||||
visibleOrders
|
||||
} = useOrdersPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
|
||||
52
src/pages/orders/styles/index.scss
Normal file
52
src/pages/orders/styles/index.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
.orders-page__hero-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orders-page__hero-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
.orders-page__hero-value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.orders-page__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.orders-page__skeleton {
|
||||
height: 92px;
|
||||
margin-top: 14px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.orders-page__summary {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.orders-page__footer {
|
||||
margin-top: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.orders-page__time {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.orders-page__empty-action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
58
src/pages/profile/composables/useProfilePage.ts
Normal file
58
src/pages/profile/composables/useProfilePage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ref } from 'vue'
|
||||
import { showToast, useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useCustomerStore, useFulfillmentStore } from '@/stores'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useProfilePage () {
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||
const draftName = ref(customerStore.name)
|
||||
const draftPhone = ref(customerStore.phone)
|
||||
|
||||
function handleNameInput (event: InputLikeEvent) {
|
||||
draftName.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
function handlePhoneInput (event: InputLikeEvent) {
|
||||
draftPhone.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
async function saveProfile () {
|
||||
customerStore.updateProfile({
|
||||
name: draftName.value,
|
||||
phone: draftPhone.value
|
||||
})
|
||||
await showToast({ title: '信息已保存', icon: 'success' })
|
||||
}
|
||||
|
||||
function goAddress () {
|
||||
void openRoute('/pages/address/index')
|
||||
}
|
||||
|
||||
function goDineIn () {
|
||||
void openRoute('/pages/dinein/confirm/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
draftName.value = customerStore.name
|
||||
draftPhone.value = customerStore.phone
|
||||
})
|
||||
|
||||
return {
|
||||
customerStore,
|
||||
draftName,
|
||||
draftPhone,
|
||||
fulfillmentStore,
|
||||
goAddress,
|
||||
goDineIn,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
saveProfile
|
||||
}
|
||||
}
|
||||
3
src/pages/profile/index.config.ts
Normal file
3
src/pages/profile/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的'
|
||||
})
|
||||
70
src/pages/profile/index.vue
Normal file
70
src/pages/profile/index.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<view class="page-shell profile-page">
|
||||
<PageHero title="我的" subtitle="管理联系人、地址和常用信息">
|
||||
<view class="profile-page__hero-row">
|
||||
<view>
|
||||
<text class="profile-page__hero-label">联系人</text>
|
||||
<text class="profile-page__hero-value">{{ customerStore.name || '未填写' }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="profile-page__hero-label">手机号</text>
|
||||
<text class="profile-page__hero-value">{{ customerStore.phone || '未填写' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</PageHero>
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">顾客信息</text>
|
||||
<input class="field-input" :value="draftName" placeholder="请输入顾客姓名" @input="handleNameInput" />
|
||||
<input class="field-input profile-page__field-gap" :value="draftPhone" type="number" placeholder="请输入手机号" @input="handlePhoneInput" />
|
||||
<view class="profile-page__action-row">
|
||||
<NutButton type="primary" block @click="saveProfile">保存信息</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="surface-card">
|
||||
<view class="row-between">
|
||||
<view>
|
||||
<text class="section-title">常用信息</text>
|
||||
<text class="section-subtitle">提前填写地址或桌号,下单时更方便。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-page__kv-list">
|
||||
<view class="profile-page__kv-item">
|
||||
<text class="caption-text">配送地址</text>
|
||||
<text class="value-text profile-page__break-all">{{ fulfillmentStore.addressText || '暂未填写' }}</text>
|
||||
</view>
|
||||
<view class="profile-page__kv-item">
|
||||
<text class="caption-text">堂食桌号</text>
|
||||
<text class="value-text">{{ fulfillmentStore.tableNo || '暂未填写' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-page__action-grid">
|
||||
<NutButton block plain @click="goAddress">编辑地址</NutButton>
|
||||
<NutButton block plain @click="goDineIn">编辑桌号</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useProfilePage } from './composables/useProfilePage'
|
||||
|
||||
const {
|
||||
customerStore,
|
||||
draftName,
|
||||
draftPhone,
|
||||
fulfillmentStore,
|
||||
goAddress,
|
||||
goDineIn,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
saveProfile
|
||||
} = useProfilePage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
42
src/pages/profile/styles/index.scss
Normal file
42
src/pages/profile/styles/index.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.profile-page__hero-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-page__hero-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
.profile-page__hero-value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.profile-page__field-gap,
|
||||
.profile-page__action-row,
|
||||
.profile-page__action-grid,
|
||||
.profile-page__kv-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.profile-page__kv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-page__break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.profile-page__action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
52
src/pages/store/select/composables/useStoreSelectPage.ts
Normal file
52
src/pages/store/select/composables/useStoreSelectPage.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { navigateBack, showToast, useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useAppStore } from '@/stores'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useStoreSelectPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const keyword = ref('')
|
||||
const filteredStores = computed(() => appStore.stores.filter((store) => {
|
||||
const search = keyword.value.trim().toLowerCase()
|
||||
if (!search) {
|
||||
return true
|
||||
}
|
||||
|
||||
return [store.name, store.address].some((text) => text.toLowerCase().includes(search))
|
||||
}))
|
||||
|
||||
function handleInput (event: InputLikeEvent) {
|
||||
keyword.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
async function selectStore (storeId: string) {
|
||||
if (storeId === appStore.currentStoreId) {
|
||||
await showToast({ title: '当前已是该门店', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
appStore.setStore(storeId)
|
||||
await showToast({ title: '门店已切换', icon: 'success' })
|
||||
|
||||
setTimeout(() => {
|
||||
void navigateBack()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void appStore.initStores()
|
||||
})
|
||||
|
||||
return {
|
||||
appStore,
|
||||
filteredStores,
|
||||
handleInput,
|
||||
keyword,
|
||||
selectStore
|
||||
}
|
||||
}
|
||||
3
src/pages/store/select/index.config.ts
Normal file
3
src/pages/store/select/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '门店选择'
|
||||
})
|
||||
60
src/pages/store/select/index.vue
Normal file
60
src/pages/store/select/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<view class="page-shell store-select-page">
|
||||
<PageHero title="门店选择" subtitle="优先进入正确门店,再衔接菜单、配送规则和结算上下文" badge="选店优先">
|
||||
<view class="store-select-page__hero-meta">
|
||||
<text class="store-select-page__hero-text">当前门店:{{ appStore.currentStore.name }}</text>
|
||||
</view>
|
||||
</PageHero>
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">搜索门店</text>
|
||||
<input class="field-input" :value="keyword" placeholder="输入门店名称或地址" @input="handleInput" />
|
||||
</view>
|
||||
|
||||
<view v-for="store in filteredStores" :key="store.id" :class="['surface-card', 'store-select-page__card', { 'store-select-page__card--active': store.id === appStore.currentStoreId }]">
|
||||
<view class="row-between">
|
||||
<view>
|
||||
<text class="section-title">{{ store.name }}</text>
|
||||
<text class="section-subtitle">{{ store.address }}</text>
|
||||
</view>
|
||||
<Tag round :type="store.id === appStore.currentStoreId ? 'success' : 'primary'">
|
||||
{{ store.id === appStore.currentStoreId ? '当前门店' : '可切换' }}
|
||||
</Tag>
|
||||
</view>
|
||||
|
||||
<view class="store-select-page__content">
|
||||
<text class="caption-text">营业时间:{{ store.businessHours || '商家营业中' }}</text>
|
||||
<view class="inline-tags store-select-page__tag-area">
|
||||
<Tag v-for="tag in store.tagTexts" :key="tag" round plain>{{ tag }}</Tag>
|
||||
</view>
|
||||
<view class="inline-tags store-select-page__tag-area">
|
||||
<Tag v-for="scene in store.supports" :key="scene" round plain type="primary">{{ SCENE_LABEL_MAP[scene] }}</Tag>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="store-select-page__action">
|
||||
<NutButton
|
||||
block
|
||||
:type="store.id === appStore.currentStoreId ? 'default' : 'primary'"
|
||||
:plain="store.id === appStore.currentStoreId"
|
||||
@click="selectStore(store.id)"
|
||||
>
|
||||
{{ store.id === appStore.currentStoreId ? '已在当前门店' : '切换到此门店' }}
|
||||
</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton, Tag } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { SCENE_LABEL_MAP } from '@/shared'
|
||||
import { useStoreSelectPage } from './composables/useStoreSelectPage'
|
||||
|
||||
const { appStore, filteredStores, handleInput, keyword, selectStore } = useStoreSelectPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
22
src/pages/store/select/styles/index.scss
Normal file
22
src/pages/store/select/styles/index.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.store-select-page__hero-text {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.store-select-page__card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.store-select-page__card--active {
|
||||
border-color: rgba(22, 163, 74, 0.34);
|
||||
}
|
||||
|
||||
.store-select-page__content,
|
||||
.store-select-page__action {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.store-select-page__tag-area {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { PriceEstimateResult } from '@/shared'
|
||||
import { checkoutValidate, estimatePrice } from '@/services'
|
||||
import { buildCheckoutPayload } from './payload'
|
||||
import type { useAppStore, useCartStore } from '@/stores'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
type CartStoreInstance = ReturnType<typeof useCartStore>
|
||||
|
||||
export const emptyEstimate: PriceEstimateResult = {
|
||||
storeId: '',
|
||||
scene: 'Delivery',
|
||||
totalCount: 0,
|
||||
originalAmount: 0,
|
||||
originalAmountText: '0.00',
|
||||
packagingFee: 0,
|
||||
packagingFeeText: '0.00',
|
||||
deliveryFee: 0,
|
||||
deliveryFeeText: '0.00',
|
||||
discountAmount: 0,
|
||||
discountAmountText: '0.00',
|
||||
payableAmount: 0,
|
||||
payableAmountText: '0.00'
|
||||
}
|
||||
|
||||
export function createEstimateActions (payload: {
|
||||
appStore: AppStoreInstance
|
||||
cartStore: CartStoreInstance
|
||||
estimate: Ref<PriceEstimateResult>
|
||||
isEstimating: Ref<boolean>
|
||||
}) {
|
||||
const { appStore, cartStore, estimate, isEstimating } = payload
|
||||
|
||||
async function handleEstimate () {
|
||||
if (!cartStore.itemCount) {
|
||||
estimate.value = emptyEstimate
|
||||
return
|
||||
}
|
||||
|
||||
isEstimating.value = true
|
||||
|
||||
try {
|
||||
const requestPayload = buildCheckoutPayload(appStore, cartStore)
|
||||
const [nextEstimate] = await Promise.all([
|
||||
estimatePrice(requestPayload),
|
||||
checkoutValidate(requestPayload)
|
||||
])
|
||||
estimate.value = nextEstimate
|
||||
} catch {
|
||||
estimate.value = emptyEstimate
|
||||
} finally {
|
||||
isEstimating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleEstimate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MiniChannels } from '@/shared'
|
||||
import type { useAppStore, useCartStore } from '@/stores'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
type CartStoreInstance = ReturnType<typeof useCartStore>
|
||||
|
||||
export function buildCheckoutPayload (
|
||||
appStore: AppStoreInstance,
|
||||
cartStore: CartStoreInstance
|
||||
) {
|
||||
return {
|
||||
storeId: appStore.currentStore.id,
|
||||
scene: appStore.scene,
|
||||
channel: appStore.channel || MiniChannels.WeChatMiniProgram,
|
||||
items: cartStore.lineList.map((line) => ({
|
||||
productId: line.productId,
|
||||
skuId: line.skuId,
|
||||
quantity: line.quantity,
|
||||
addonItemIds: line.addonItemIds
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FulfillmentScenes, SCENE_LABEL_MAP } from '@/shared'
|
||||
import type { FulfillmentScene } from '@/shared'
|
||||
|
||||
export function resolveSceneLabel (scene: FulfillmentScene) {
|
||||
return SCENE_LABEL_MAP[scene]
|
||||
}
|
||||
|
||||
export function resolveSceneSuffix (scene: FulfillmentScene) {
|
||||
if (scene === FulfillmentScenes.Delivery) return '配送'
|
||||
if (scene === FulfillmentScenes.Pickup) return '自提'
|
||||
return '用餐'
|
||||
}
|
||||
|
||||
export function resolveSceneHint (scene: FulfillmentScene) {
|
||||
if (scene === FulfillmentScenes.Delivery) return '预计 30 分钟送达'
|
||||
if (scene === FulfillmentScenes.Pickup) return '下单后可到店取餐'
|
||||
return '下单后商家将尽快制作'
|
||||
}
|
||||
159
src/pages/trade/checkout/composables/useCheckoutPage.ts
Normal file
159
src/pages/trade/checkout/composables/useCheckoutPage.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { showToast, useDidShow } from '@tarojs/taro'
|
||||
import { createOrder } from '@/services'
|
||||
import { pinia, useAppStore, useCartStore, useCustomerStore, useFulfillmentStore } from '@/stores'
|
||||
import { FulfillmentScenes } from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
import { createEstimateActions, emptyEstimate } from './checkout-page/estimate-actions'
|
||||
import { buildCheckoutPayload } from './checkout-page/payload'
|
||||
import {
|
||||
resolveSceneHint,
|
||||
resolveSceneLabel,
|
||||
resolveSceneSuffix
|
||||
} from './checkout-page/scene-helpers'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useCheckoutPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const cartStore = useCartStore(pinia)
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||
const isEstimating = ref(false)
|
||||
const submitting = ref(false)
|
||||
const estimate = ref(emptyEstimate)
|
||||
const draftName = ref(customerStore.name)
|
||||
const draftPhone = ref(customerStore.phone)
|
||||
const remark = ref('')
|
||||
|
||||
const itemCount = computed(() => cartStore.itemCount)
|
||||
const lineList = computed(() => cartStore.lineList)
|
||||
const sceneLabel = computed(() => resolveSceneLabel(appStore.scene))
|
||||
const sceneSuffix = computed(() => resolveSceneSuffix(appStore.scene))
|
||||
const sceneHint = computed(() => resolveSceneHint(appStore.scene))
|
||||
|
||||
const { handleEstimate } = createEstimateActions({
|
||||
appStore,
|
||||
cartStore,
|
||||
estimate,
|
||||
isEstimating
|
||||
})
|
||||
|
||||
function handleNameInput (event: InputLikeEvent) {
|
||||
draftName.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
function handlePhoneInput (event: InputLikeEvent) {
|
||||
draftPhone.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
function handleRemarkInput (event: InputLikeEvent) {
|
||||
remark.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
async function submitOrder () {
|
||||
if (!cartStore.itemCount) {
|
||||
await showToast({ title: '请先加购商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!draftName.value.trim() || !draftPhone.value.trim()) {
|
||||
await showToast({ title: '请完善顾客信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (appStore.scene === FulfillmentScenes.Delivery && !fulfillmentStore.addressText) {
|
||||
await showToast({ title: '请先填写配送地址', icon: 'none' })
|
||||
void openRoute('/pages/address/index')
|
||||
return
|
||||
}
|
||||
|
||||
if (appStore.scene === FulfillmentScenes.DineIn && !fulfillmentStore.tableNo) {
|
||||
await showToast({ title: '请先填写桌号', icon: 'none' })
|
||||
void openRoute('/pages/dinein/confirm/index')
|
||||
return
|
||||
}
|
||||
|
||||
customerStore.updateProfile({
|
||||
name: draftName.value,
|
||||
phone: draftPhone.value
|
||||
})
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
await handleEstimate()
|
||||
const order = await createOrder({
|
||||
...buildCheckoutPayload(appStore, cartStore),
|
||||
remark: remark.value || (appStore.scene === FulfillmentScenes.Delivery
|
||||
? fulfillmentStore.addressText
|
||||
: undefined),
|
||||
tableNo: appStore.scene === FulfillmentScenes.DineIn
|
||||
? fulfillmentStore.tableNo
|
||||
: undefined
|
||||
})
|
||||
|
||||
cartStore.clear()
|
||||
void openRoute(`/pages/trade/success/index?orderId=${order.orderId}&orderNo=${order.orderNo}`)
|
||||
} catch (error: unknown) {
|
||||
await showToast({
|
||||
title: error instanceof Error ? error.message : '下单失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goSceneContext () {
|
||||
if (appStore.scene === FulfillmentScenes.Delivery) {
|
||||
void openRoute('/pages/address/index')
|
||||
return
|
||||
}
|
||||
|
||||
if (appStore.scene === FulfillmentScenes.DineIn) {
|
||||
void openRoute('/pages/dinein/confirm/index')
|
||||
return
|
||||
}
|
||||
|
||||
void showToast({ title: '自提场景无需额外填写', icon: 'none' })
|
||||
}
|
||||
|
||||
function goMenu () {
|
||||
void openRoute('/pages/menu/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
draftName.value = customerStore.name
|
||||
draftPhone.value = customerStore.phone
|
||||
void appStore.initBootstrap()
|
||||
void appStore.initStores()
|
||||
void handleEstimate()
|
||||
})
|
||||
|
||||
return {
|
||||
appStore,
|
||||
draftName,
|
||||
draftPhone,
|
||||
estimate,
|
||||
fulfillmentStore,
|
||||
goMenu,
|
||||
goSceneContext,
|
||||
handleEstimate,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
handleRemarkInput,
|
||||
itemCount,
|
||||
lineList,
|
||||
remark,
|
||||
sceneHint,
|
||||
sceneLabel,
|
||||
sceneSuffix,
|
||||
submitOrder,
|
||||
submitting
|
||||
}
|
||||
}
|
||||
3
src/pages/trade/checkout/index.config.ts
Normal file
3
src/pages/trade/checkout/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '确认订单'
|
||||
})
|
||||
230
src/pages/trade/checkout/index.vue
Normal file
230
src/pages/trade/checkout/index.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<view class="checkout-page">
|
||||
<!-- Empty State -->
|
||||
<view v-if="!itemCount" class="checkout-page__empty-card">
|
||||
<text class="checkout-page__empty-title">购物车还是空的</text>
|
||||
<text class="checkout-page__empty-desc">先去点餐页加购吧</text>
|
||||
<view class="checkout-page__empty-btn" @click="goMenu">
|
||||
<text>去点餐</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- Scene Tabs -->
|
||||
<view class="checkout-page__scene-tabs">
|
||||
<view
|
||||
v-for="option in appStore.sceneOptions"
|
||||
:key="option.value"
|
||||
class="checkout-page__scene-tab"
|
||||
:class="{ 'checkout-page__scene-tab--active': appStore.scene === option.value }"
|
||||
@click="appStore.setScene(option.value)"
|
||||
>
|
||||
<text>{{ option.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Store Card -->
|
||||
<view class="checkout-page__oc-card">
|
||||
<view class="checkout-page__store">
|
||||
<view class="checkout-page__store-icon">
|
||||
<text>🏠</text>
|
||||
</view>
|
||||
<view class="checkout-page__store-info">
|
||||
<text class="checkout-page__store-name">{{ appStore.currentStore.name }}</text>
|
||||
<view class="checkout-page__store-meta">
|
||||
<view class="checkout-page__mode-badge">
|
||||
<text>{{ sceneLabel }}{{ sceneSuffix }}</text>
|
||||
</view>
|
||||
<text class="checkout-page__store-hint">{{ sceneHint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Delivery: Address -->
|
||||
<view v-if="appStore.scene === 'Delivery'" class="checkout-page__oc-card">
|
||||
<view class="checkout-page__dash-divider" />
|
||||
<view class="checkout-page__address" @click="goSceneContext">
|
||||
<view class="checkout-page__addr-icon">
|
||||
<text>📍</text>
|
||||
</view>
|
||||
<view class="checkout-page__addr-body">
|
||||
<view class="checkout-page__addr-name">
|
||||
<text>{{ draftName }}</text>
|
||||
<text class="checkout-page__addr-phone">{{ draftPhone }}</text>
|
||||
</view>
|
||||
<text class="checkout-page__addr-detail">
|
||||
{{ fulfillmentStore.addressText || '请填写配送地址' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="checkout-page__addr-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Pickup: Contact + info -->
|
||||
<view v-if="appStore.scene === 'Pickup'" class="checkout-page__oc-card">
|
||||
<view class="checkout-page__oc-card-body">
|
||||
<view class="checkout-page__pickup-contact">
|
||||
<view class="checkout-page__addr-icon checkout-page__addr-icon--green">
|
||||
<text>👤</text>
|
||||
</view>
|
||||
<view class="checkout-page__pickup-contact-body">
|
||||
<view class="checkout-page__addr-name">
|
||||
<text>{{ draftName }}</text>
|
||||
<text class="checkout-page__addr-phone">{{ draftPhone }}</text>
|
||||
</view>
|
||||
<text class="checkout-page__addr-detail">自提门店:{{ appStore.currentStore.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="checkout-page__pickup-hint">
|
||||
<text>🕐</text>
|
||||
<text class="checkout-page__pickup-hint-text">预计取餐时间 约 15 分钟后</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- DineIn: Table Number -->
|
||||
<view v-if="appStore.scene === 'DineIn'" class="checkout-page__oc-card">
|
||||
<view class="checkout-page__oc-card-body">
|
||||
<view class="checkout-page__dinein-table">
|
||||
<view class="checkout-page__addr-icon checkout-page__addr-icon--green">
|
||||
<text>📅</text>
|
||||
</view>
|
||||
<view class="checkout-page__table-num">
|
||||
<text class="checkout-page__table-label">桌号</text>
|
||||
<text class="checkout-page__table-value">{{ fulfillmentStore.tableNo || '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="checkout-page__dinein-contact">
|
||||
<view class="checkout-page__addr-icon checkout-page__addr-icon--green checkout-page__addr-icon--sm">
|
||||
<text>👤</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="checkout-page__dinein-name">{{ draftName }}</text>
|
||||
<text class="checkout-page__dinein-phone">{{ draftPhone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="checkout-page__pickup-hint">
|
||||
<text>🛡</text>
|
||||
<text class="checkout-page__pickup-hint-text">下单后商家将尽快制作,请留意取餐提醒</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Customer Info -->
|
||||
<view class="checkout-page__oc-card">
|
||||
<view class="checkout-page__oc-card-body">
|
||||
<text class="checkout-page__field-title">顾客信息</text>
|
||||
<input class="field-input" :value="draftName" placeholder="请输入顾客姓名" @input="handleNameInput" />
|
||||
<input class="field-input checkout-page__field-gap" :value="draftPhone" type="number" placeholder="请输入手机号" @input="handlePhoneInput" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Product List -->
|
||||
<view class="checkout-page__oc-card">
|
||||
<view class="checkout-page__goods-title">
|
||||
<text class="checkout-page__goods-title-text">商品清单</text>
|
||||
<text class="checkout-page__goods-count">共 {{ itemCount }} 件</text>
|
||||
</view>
|
||||
<view class="checkout-page__goods-list">
|
||||
<view v-for="line in lineList" :key="line.lineKey" class="checkout-page__good">
|
||||
<image
|
||||
v-if="line.coverImageUrl"
|
||||
class="checkout-page__good-img"
|
||||
:src="line.coverImageUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="checkout-page__good-info">
|
||||
<text class="checkout-page__good-name">{{ line.name }}</text>
|
||||
<text v-if="line.specText" class="checkout-page__good-spec">{{ line.specText }}</text>
|
||||
</view>
|
||||
<view class="checkout-page__good-right">
|
||||
<text class="checkout-page__good-price"><text class="checkout-page__good-unit">¥</text>{{ line.lineAmountText }}</text>
|
||||
<text class="checkout-page__good-qty">×{{ line.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Remark -->
|
||||
<view class="checkout-page__oc-card">
|
||||
<view class="checkout-page__remark">
|
||||
<text class="checkout-page__remark-title">✏️ 订单备注</text>
|
||||
<input class="field-input" type="text" placeholder="如少冰、少辣、不要香菜等" :value="remark" @input="handleRemarkInput" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Fees -->
|
||||
<view class="checkout-page__oc-card">
|
||||
<view class="checkout-page__fees">
|
||||
<view class="checkout-page__fee-row">
|
||||
<text class="checkout-page__fee-label">商品小计</text>
|
||||
<text class="checkout-page__fee-value">¥{{ estimate.originalAmountText }}</text>
|
||||
</view>
|
||||
<view class="checkout-page__fee-row">
|
||||
<text class="checkout-page__fee-label">打包费</text>
|
||||
<text class="checkout-page__fee-value">¥{{ estimate.packagingFeeText }}</text>
|
||||
</view>
|
||||
<view v-if="appStore.scene === 'Delivery'" class="checkout-page__fee-row">
|
||||
<text class="checkout-page__fee-label">配送费</text>
|
||||
<text class="checkout-page__fee-value">¥{{ estimate.deliveryFeeText }}</text>
|
||||
</view>
|
||||
<view v-if="estimate.discountAmount > 0" class="checkout-page__fee-row">
|
||||
<text class="checkout-page__fee-label">优惠抵扣</text>
|
||||
<text class="checkout-page__fee-value checkout-page__fee-value--green">-¥{{ estimate.discountAmountText }}</text>
|
||||
</view>
|
||||
<view class="checkout-page__fee-row checkout-page__fee-row--total">
|
||||
<text class="checkout-page__fee-label">应付金额</text>
|
||||
<text class="checkout-page__fee-value checkout-page__fee-value--total">
|
||||
<text class="checkout-page__fee-unit">¥</text>{{ estimate.payableAmountText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Bottom Submit Bar -->
|
||||
<view class="checkout-page__submit-bar">
|
||||
<view class="checkout-page__submit-left">
|
||||
<text class="checkout-page__submit-label">应付金额</text>
|
||||
<view class="checkout-page__submit-price">
|
||||
<text class="checkout-page__submit-unit">¥</text>
|
||||
<text class="checkout-page__submit-value">{{ estimate.payableAmountText }}</text>
|
||||
</view>
|
||||
<text v-if="estimate.discountAmount > 0" class="checkout-page__submit-saved">已优惠 ¥{{ estimate.discountAmountText }}</text>
|
||||
</view>
|
||||
<view class="checkout-page__submit-btn" @click="submitOrder">
|
||||
<text>{{ submitting ? '提交中...' : '提交订单' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCheckoutPage } from './composables/useCheckoutPage'
|
||||
|
||||
const {
|
||||
appStore,
|
||||
draftName,
|
||||
draftPhone,
|
||||
estimate,
|
||||
fulfillmentStore,
|
||||
goMenu,
|
||||
goSceneContext,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
handleRemarkInput,
|
||||
itemCount,
|
||||
lineList,
|
||||
remark,
|
||||
sceneHint,
|
||||
sceneLabel,
|
||||
sceneSuffix,
|
||||
submitOrder,
|
||||
submitting
|
||||
} = useCheckoutPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
155
src/pages/trade/checkout/styles/base.scss
Normal file
155
src/pages/trade/checkout/styles/base.scss
Normal file
@@ -0,0 +1,155 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.checkout-page {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.checkout-page__empty-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkout-page__empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.checkout-page__empty-desc {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.checkout-page__empty-btn {
|
||||
margin-top: 20px;
|
||||
height: 38px;
|
||||
padding: 0 24px;
|
||||
border-radius: 19px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.checkout-page__scene-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: $border;
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.checkout-page__scene-tab {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--active {
|
||||
background: $card;
|
||||
color: $primary-dark;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-page__oc-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkout-page__oc-card-body {
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkout-page__store {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.checkout-page__store-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.checkout-page__store-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checkout-page__store-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkout-page__store-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.checkout-page__mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: $primary-lighter;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.checkout-page__store-hint {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkout-page__field-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.checkout-page__field-gap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
241
src/pages/trade/checkout/styles/cards.scss
Normal file
241
src/pages/trade/checkout/styles/cards.scss
Normal file
@@ -0,0 +1,241 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.checkout-page__dash-divider {
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
$primary 0, $primary 8px,
|
||||
$accent 8px, $accent 16px,
|
||||
$red 16px, $red 24px,
|
||||
#60A5FA 24px, #60A5FA 32px
|
||||
);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-page__address,
|
||||
.checkout-page__pickup-contact,
|
||||
.checkout-page__dinein-table,
|
||||
.checkout-page__dinein-contact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkout-page__pickup-contact-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkout-page__address {
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.checkout-page__addr-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
|
||||
&--green {
|
||||
background: linear-gradient(135deg, $primary-lighter 0%, $primary-light 100%);
|
||||
}
|
||||
|
||||
&--sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-page__addr-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checkout-page__addr-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.checkout-page__addr-phone {
|
||||
font-size: 13px;
|
||||
color: $text-3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkout-page__addr-detail {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkout-page__addr-arrow {
|
||||
color: $text-5;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkout-page__pickup-hint {
|
||||
background: $primary-lighter;
|
||||
border-radius: $r-sm;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkout-page__pickup-hint-text {
|
||||
font-size: 13px;
|
||||
color: $primary-dark;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.checkout-page__table-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.checkout-page__table-label {
|
||||
font-size: 14px;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.checkout-page__table-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
background: $primary-lighter;
|
||||
padding: 4px 14px;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
|
||||
.checkout-page__dinein-name {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.checkout-page__dinein-phone {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.checkout-page__goods-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px 0;
|
||||
}
|
||||
|
||||
.checkout-page__goods-title-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.checkout-page__goods-count {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.checkout-page__goods-list {
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.checkout-page__good {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 0.5px solid $border;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.checkout-page__good-img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r-sm;
|
||||
flex-shrink: 0;
|
||||
background: $border;
|
||||
}
|
||||
|
||||
.checkout-page__good-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checkout-page__good-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkout-page__good-spec {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.checkout-page__good-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.checkout-page__good-price {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.checkout-page__good-unit {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.checkout-page__good-qty {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.checkout-page__remark {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.checkout-page__remark-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
48
src/pages/trade/checkout/styles/fees.scss
Normal file
48
src/pages/trade/checkout/styles/fees.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.checkout-page__fees {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.checkout-page__fee-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
|
||||
&--total {
|
||||
margin-top: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-page__fee-label {
|
||||
font-size: 13px;
|
||||
color: $text-3;
|
||||
|
||||
.checkout-page__fee-row--total & {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-page__fee-value {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
font-weight: 500;
|
||||
|
||||
&--green { color: $primary; }
|
||||
|
||||
&--total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-page__fee-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
4
src/pages/trade/checkout/styles/index.scss
Normal file
4
src/pages/trade/checkout/styles/index.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import './base.scss';
|
||||
@import './cards.scss';
|
||||
@import './fees.scss';
|
||||
@import './submit.scss';
|
||||
68
src/pages/trade/checkout/styles/submit.scss
Normal file
68
src/pages/trade/checkout/styles/submit.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.checkout-page__submit-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $card;
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 10px 18px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.checkout-page__submit-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checkout-page__submit-label {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.checkout-page__submit-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.checkout-page__submit-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.checkout-page__submit-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.checkout-page__submit-saved {
|
||||
font-size: 11px;
|
||||
color: $primary;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.checkout-page__submit-btn {
|
||||
height: 50px;
|
||||
min-width: 140px;
|
||||
padding: 0 32px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
37
src/pages/trade/success/composables/useTradeSuccessPage.ts
Normal file
37
src/pages/trade/success/composables/useTradeSuccessPage.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
export function useTradeSuccessPage () {
|
||||
const orderId = ref('')
|
||||
const orderNo = ref('')
|
||||
|
||||
function goOrderDetail () {
|
||||
if (!orderId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
void openRoute(`/pages/order/detail/index?id=${orderId.value}`)
|
||||
}
|
||||
|
||||
function goOrders () {
|
||||
void openRoute('/pages/orders/index')
|
||||
}
|
||||
|
||||
function goMenu () {
|
||||
void openRoute('/pages/menu/index')
|
||||
}
|
||||
|
||||
useLoad((options) => {
|
||||
orderId.value = options?.orderId || ''
|
||||
orderNo.value = options?.orderNo || ''
|
||||
})
|
||||
|
||||
return {
|
||||
goMenu,
|
||||
goOrderDetail,
|
||||
goOrders,
|
||||
orderId,
|
||||
orderNo
|
||||
}
|
||||
}
|
||||
3
src/pages/trade/success/index.config.ts
Normal file
3
src/pages/trade/success/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单提交'
|
||||
})
|
||||
29
src/pages/trade/success/index.vue
Normal file
29
src/pages/trade/success/index.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<view class="page-shell success-page">
|
||||
<PageHero title="提交成功" subtitle="订单已提交,可前往订单详情继续查看" badge="订单已创建" />
|
||||
|
||||
<view class="surface-card success-page__card">
|
||||
<text class="success-page__icon">✓</text>
|
||||
<text class="section-title">订单已提交</text>
|
||||
<text class="section-subtitle">订单号:{{ orderNo || '-' }}</text>
|
||||
<view class="success-page__actions">
|
||||
<NutButton type="primary" block @click="goOrderDetail">查看订单</NutButton>
|
||||
<NutButton block plain @click="goOrders">订单列表</NutButton>
|
||||
<NutButton block plain @click="goMenu">继续点餐</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useTradeSuccessPage } from './composables/useTradeSuccessPage'
|
||||
|
||||
const { goMenu, goOrderDetail, goOrders, orderNo } = useTradeSuccessPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
|
||||
22
src/pages/trade/success/styles/index.scss
Normal file
22
src/pages/trade/success/styles/index.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.success-page__card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-page__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 999px;
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.success-page__actions {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
7
src/services/config.ts
Normal file
7
src/services/config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const requestConfig = {
|
||||
appEnv: __APP_ENV__,
|
||||
baseUrl: __API_BASE_URL__,
|
||||
useMock: __USE_MOCK__,
|
||||
timeout: __REQUEST_TIMEOUT__,
|
||||
tenantCode: __TENANT_CODE__
|
||||
}
|
||||
115
src/services/http.ts
Normal file
115
src/services/http.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { ApiResponse } from '@/shared'
|
||||
import { requestConfig } from './config'
|
||||
|
||||
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
export type RequestBody = string | object | ArrayBuffer
|
||||
export type RequestHeaderProvider = () => Record<string, string> | undefined
|
||||
|
||||
let requestHeaderProvider: RequestHeaderProvider | undefined
|
||||
|
||||
export interface RequestOptions {
|
||||
path: string
|
||||
method?: RequestMethod
|
||||
query?: Record<string, unknown>
|
||||
body?: RequestBody
|
||||
header?: Record<string, string>
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export function setRequestHeaderProvider (provider?: RequestHeaderProvider) {
|
||||
requestHeaderProvider = provider
|
||||
}
|
||||
|
||||
export class MiniRequestError extends Error {
|
||||
code: number | string
|
||||
details?: unknown
|
||||
|
||||
constructor (message: string, code: number | string, details?: unknown) {
|
||||
super(message)
|
||||
this.name = 'MiniRequestError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuery (query?: Record<string, unknown>) {
|
||||
if (!query) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Object.entries(query).reduce<Record<string, string>>((result, [key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return result
|
||||
}
|
||||
|
||||
result[key] = String(value)
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
function buildUrl (path: string, query?: Record<string, unknown>) {
|
||||
const normalizedBase = requestConfig.baseUrl.replace(/\/+$/, '')
|
||||
const normalizedPath = path.replace(/^\/+/, '')
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
|
||||
if (!normalizedQuery || !Object.keys(normalizedQuery).length) {
|
||||
return `${normalizedBase}/${normalizedPath}`
|
||||
}
|
||||
|
||||
const search = Object.entries(normalizedQuery)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
|
||||
return `${normalizedBase}/${normalizedPath}?${search}`
|
||||
}
|
||||
|
||||
function resolveRequestHeaders (header?: Record<string, string>) {
|
||||
return {
|
||||
...(requestHeaderProvider?.() || {}),
|
||||
...(header || {})
|
||||
}
|
||||
}
|
||||
|
||||
function isApiResponse<T> (payload: unknown): payload is ApiResponse<T> {
|
||||
return typeof payload === 'object' && payload !== null && 'success' in payload && 'data' in payload
|
||||
}
|
||||
|
||||
function unwrapApiResponse<T> (payload: unknown): T {
|
||||
if (!isApiResponse<T>(payload)) {
|
||||
return payload as T
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
throw new MiniRequestError(payload.message || '请求失败', payload.code, payload.errors)
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export async function request<TResponse> (options: RequestOptions) {
|
||||
const response = await Taro.request({
|
||||
url: buildUrl(options.path, options.query),
|
||||
method: options.method || 'GET',
|
||||
data: options.body,
|
||||
timeout: options.timeout || requestConfig.timeout,
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
...resolveRequestHeaders(options.header)
|
||||
}
|
||||
})
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
throw new MiniRequestError(`请求失败,状态码 ${response.statusCode}`, response.statusCode, response.data)
|
||||
}
|
||||
|
||||
return unwrapApiResponse<TResponse>(response.data)
|
||||
}
|
||||
|
||||
export function get<TResponse> (path: string, query?: Record<string, unknown>, header?: Record<string, string>) {
|
||||
return request<TResponse>({ path, method: 'GET', query, header })
|
||||
}
|
||||
|
||||
export function post<TResponse> (path: string, body?: RequestBody, query?: Record<string, unknown>, header?: Record<string, string>) {
|
||||
return request<TResponse>({ path, method: 'POST', body, query, header })
|
||||
}
|
||||
3
src/services/index.ts
Normal file
3
src/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './config'
|
||||
export * from './http'
|
||||
export * from './mini'
|
||||
64
src/services/mini.ts
Normal file
64
src/services/mini.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
type CheckoutValidationResult,
|
||||
type FulfillmentScene,
|
||||
type MiniBootstrap,
|
||||
type MiniCategory,
|
||||
type MiniChannel,
|
||||
type MiniMenuSection,
|
||||
type MiniProductDetail,
|
||||
type OrderDetail,
|
||||
type OrderSummary,
|
||||
type PriceEstimatePayload,
|
||||
type PriceEstimateResult,
|
||||
type StoreSummary,
|
||||
type CreateOrderPayload,
|
||||
type CreateOrderResult,
|
||||
type MockPayResult
|
||||
} from '@/shared'
|
||||
import { get, post } from './http'
|
||||
|
||||
export function getStores () {
|
||||
return get<StoreSummary[]>('stores')
|
||||
}
|
||||
|
||||
export function getCategories (storeId: string, scene: FulfillmentScene, channel: MiniChannel) {
|
||||
return get<MiniCategory[]>('categories', { storeId, scene, channel })
|
||||
}
|
||||
|
||||
export function getMiniBootstrap () {
|
||||
return get<MiniBootstrap>('bootstrap')
|
||||
}
|
||||
|
||||
|
||||
export function getMenu (storeId: string, scene: FulfillmentScene, channel: MiniChannel) {
|
||||
return get<MiniMenuSection[]>(`menus/${storeId}`, { scene, channel })
|
||||
}
|
||||
|
||||
export function getProductDetail (productId: string, scene: FulfillmentScene, channel: MiniChannel) {
|
||||
return get<MiniProductDetail>(`products/${productId}`, { scene, channel })
|
||||
}
|
||||
|
||||
export function estimatePrice (payload: PriceEstimatePayload) {
|
||||
return post<PriceEstimateResult>('products/price-estimate', payload)
|
||||
}
|
||||
|
||||
export function checkoutValidate (payload: PriceEstimatePayload) {
|
||||
return post<CheckoutValidationResult>('products/checkout-validate', payload)
|
||||
}
|
||||
|
||||
export function createOrder (payload: CreateOrderPayload) {
|
||||
return post<CreateOrderResult>('orders', payload)
|
||||
}
|
||||
|
||||
export function mockPayOrder (orderId: string) {
|
||||
return post<MockPayResult>(`orders/${orderId}/mock-pay`)
|
||||
}
|
||||
|
||||
export function getOrders () {
|
||||
return get<OrderSummary[]>('orders')
|
||||
}
|
||||
|
||||
export function getOrderDetail (orderId: string) {
|
||||
return get<OrderDetail>(`orders/${orderId}`)
|
||||
}
|
||||
|
||||
34
src/shared/constants/business.ts
Normal file
34
src/shared/constants/business.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const FulfillmentScenes = {
|
||||
Delivery: 'Delivery',
|
||||
Pickup: 'Pickup',
|
||||
DineIn: 'DineIn'
|
||||
} as const
|
||||
|
||||
export type FulfillmentScene = (typeof FulfillmentScenes)[keyof typeof FulfillmentScenes]
|
||||
|
||||
export const MiniChannels = {
|
||||
WeChatMiniProgram: 'WeChatMiniProgram'
|
||||
} as const
|
||||
|
||||
export type MiniChannel = (typeof MiniChannels)[keyof typeof MiniChannels]
|
||||
|
||||
export const SCENE_OPTIONS = [
|
||||
{ label: '外卖', value: FulfillmentScenes.Delivery, description: '适合配送到家' },
|
||||
{ label: '自提', value: FulfillmentScenes.Pickup, description: '适合到店取餐' },
|
||||
{ label: '堂食', value: FulfillmentScenes.DineIn, description: '适合扫码点餐' }
|
||||
] as const
|
||||
|
||||
export const SCENE_LABEL_MAP: Record<FulfillmentScene, string> = {
|
||||
[FulfillmentScenes.Delivery]: '外卖',
|
||||
[FulfillmentScenes.Pickup]: '自提',
|
||||
[FulfillmentScenes.DineIn]: '堂食'
|
||||
}
|
||||
|
||||
|
||||
export function buildMiniContextQuery (scene: FulfillmentScene, channel: MiniChannel = MiniChannels.WeChatMiniProgram) {
|
||||
return {
|
||||
scene,
|
||||
channel
|
||||
}
|
||||
}
|
||||
|
||||
10
src/shared/index.ts
Normal file
10
src/shared/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './constants/business'
|
||||
export * from './mock/menu'
|
||||
export * from './mock/orders'
|
||||
export * from './mock/stores'
|
||||
export * from './types/api'
|
||||
export * from './types/mini'
|
||||
export * from './types/menu'
|
||||
export * from './types/order'
|
||||
export * from './types/store'
|
||||
export * from './utils/format'
|
||||
155
src/shared/mock/menu.ts
Normal file
155
src/shared/mock/menu.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Id } from '../types/api'
|
||||
import type { MiniCategory, MiniMenuSection, MiniProductCard, PriceEstimatePayload, PriceEstimateResult } from '../types/menu'
|
||||
import { FulfillmentScenes } from '../constants/business'
|
||||
import { formatPrice } from '../utils/format'
|
||||
|
||||
const productMap: Record<Id, MiniProductCard> = {
|
||||
'2000000000000000001': {
|
||||
id: '2000000000000000001',
|
||||
name: '招牌鸡腿饭',
|
||||
description: '招牌酱汁鸡腿搭配时蔬与米饭',
|
||||
coverImageUrl: 'https://dummyimage.com/160x160/f3f4f6/111827&text=Chicken',
|
||||
price: 22,
|
||||
priceText: formatPrice(22),
|
||||
originalPrice: 26,
|
||||
originalPriceText: formatPrice(26),
|
||||
salesText: '月售 268',
|
||||
tagTexts: ['招牌', '热销'],
|
||||
soldOut: false,
|
||||
hasOptions: false
|
||||
},
|
||||
'2000000000000000002': {
|
||||
id: '2000000000000000002',
|
||||
name: '黑椒牛柳饭',
|
||||
description: '黑椒风味牛柳,适合工作日快餐',
|
||||
coverImageUrl: 'https://dummyimage.com/160x160/e5e7eb/111827&text=Beef',
|
||||
price: 29,
|
||||
priceText: formatPrice(29),
|
||||
originalPrice: 33,
|
||||
originalPriceText: formatPrice(33),
|
||||
salesText: '月售 186',
|
||||
tagTexts: ['牛肉', '推荐'],
|
||||
soldOut: false,
|
||||
hasOptions: false
|
||||
},
|
||||
'2000000000000000003': {
|
||||
id: '2000000000000000003',
|
||||
name: '藤椒鸡丝沙拉',
|
||||
description: '轻食组合,适合晚餐与健身场景',
|
||||
coverImageUrl: 'https://dummyimage.com/160x160/dbeafe/111827&text=Salad',
|
||||
price: 18,
|
||||
priceText: formatPrice(18),
|
||||
salesText: '月售 92',
|
||||
tagTexts: ['轻食'],
|
||||
soldOut: false,
|
||||
hasOptions: false
|
||||
},
|
||||
'2000000000000000004': {
|
||||
id: '2000000000000000004',
|
||||
name: '芝士薯球',
|
||||
description: '外酥里糯的轻食小吃',
|
||||
coverImageUrl: 'https://dummyimage.com/160x160/fef3c7/111827&text=Snack',
|
||||
price: 12,
|
||||
priceText: formatPrice(12),
|
||||
salesText: '月售 143',
|
||||
tagTexts: ['加购王'],
|
||||
soldOut: false,
|
||||
hasOptions: false
|
||||
},
|
||||
'2000000000000000005': {
|
||||
id: '2000000000000000005',
|
||||
name: '茉莉轻乳茶',
|
||||
description: '清爽不腻,适合套餐搭配',
|
||||
coverImageUrl: 'https://dummyimage.com/160x160/ecfccb/111827&text=Tea',
|
||||
price: 10,
|
||||
priceText: formatPrice(10),
|
||||
salesText: '月售 310',
|
||||
tagTexts: ['畅销'],
|
||||
soldOut: false,
|
||||
hasOptions: false
|
||||
},
|
||||
'2000000000000000006': {
|
||||
id: '2000000000000000006',
|
||||
name: '冰美式',
|
||||
description: '办公场景高频搭配饮品',
|
||||
coverImageUrl: 'https://dummyimage.com/160x160/e0f2fe/111827&text=Coffee',
|
||||
price: 14,
|
||||
priceText: formatPrice(14),
|
||||
salesText: '月售 201',
|
||||
tagTexts: ['办公必备'],
|
||||
soldOut: false,
|
||||
hasOptions: false
|
||||
}
|
||||
}
|
||||
|
||||
export const demoCategories: MiniCategory[] = [
|
||||
{ id: '3000000000000000001', name: '招牌热饭', sort: 1, productCount: 2 },
|
||||
{ id: '3000000000000000002', name: '轻食小吃', sort: 2, productCount: 2 },
|
||||
{ id: '3000000000000000003', name: '饮品搭配', sort: 3, productCount: 2 }
|
||||
]
|
||||
|
||||
export const demoMenuSections: MiniMenuSection[] = [
|
||||
{
|
||||
categoryId: '3000000000000000001',
|
||||
categoryName: '招牌热饭',
|
||||
products: [productMap['2000000000000000001'], productMap['2000000000000000002']]
|
||||
},
|
||||
{
|
||||
categoryId: '3000000000000000002',
|
||||
categoryName: '轻食小吃',
|
||||
products: [productMap['2000000000000000003'], productMap['2000000000000000004']]
|
||||
},
|
||||
{
|
||||
categoryId: '3000000000000000003',
|
||||
categoryName: '饮品搭配',
|
||||
products: [productMap['2000000000000000005'], productMap['2000000000000000006']]
|
||||
}
|
||||
]
|
||||
|
||||
export const demoHotProducts: MiniProductCard[] = [
|
||||
productMap['2000000000000000001'],
|
||||
productMap['2000000000000000005'],
|
||||
productMap['2000000000000000002']
|
||||
]
|
||||
|
||||
export const demoRecommendedProducts: MiniProductCard[] = [
|
||||
productMap['2000000000000000003'],
|
||||
productMap['2000000000000000004'],
|
||||
productMap['2000000000000000006']
|
||||
]
|
||||
|
||||
export function findDemoProduct (productId: Id) {
|
||||
return productMap[productId]
|
||||
}
|
||||
|
||||
export function buildMockPriceEstimate (payload: PriceEstimatePayload): PriceEstimateResult {
|
||||
const originalAmount = payload.items.reduce((total, item) => {
|
||||
const product = findDemoProduct(item.productId)
|
||||
if (!product) return total
|
||||
return total + product.price * item.quantity
|
||||
}, 0)
|
||||
const totalCount = payload.items.reduce((count, item) => count + item.quantity, 0)
|
||||
const packagingFee = payload.scene === FulfillmentScenes.DineIn ? 0 : totalCount * 1
|
||||
const deliveryFee = payload.scene === FulfillmentScenes.Delivery ? 4 : 0
|
||||
const discountAmount = originalAmount >= 40 ? 6 : 0
|
||||
const payableAmount = Math.max(originalAmount + packagingFee + deliveryFee - discountAmount, 0)
|
||||
|
||||
return {
|
||||
storeId: payload.storeId,
|
||||
scene: payload.scene,
|
||||
totalCount,
|
||||
originalAmount,
|
||||
originalAmountText: formatPrice(originalAmount),
|
||||
packagingFee,
|
||||
packagingFeeText: formatPrice(packagingFee),
|
||||
deliveryFee,
|
||||
deliveryFeeText: formatPrice(deliveryFee),
|
||||
discountAmount,
|
||||
discountAmountText: formatPrice(discountAmount),
|
||||
payableAmount,
|
||||
payableAmountText: formatPrice(payableAmount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
46
src/shared/mock/orders.ts
Normal file
46
src/shared/mock/orders.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { FulfillmentScenes } from '../constants/business'
|
||||
import type { OrderSummary } from '../types/order'
|
||||
import { formatPrice } from '../utils/format'
|
||||
|
||||
export const demoOrders: OrderSummary[] = [
|
||||
{
|
||||
id: '5000000000000000001',
|
||||
orderNo: 'TC202603090001',
|
||||
storeName: '古镇路旗舰店',
|
||||
statusText: '待支付',
|
||||
scene: FulfillmentScenes.Delivery,
|
||||
itemSummary: '招牌鸡腿饭 ×1、茉莉轻乳茶 ×1',
|
||||
totalAmount: 33,
|
||||
totalAmountText: formatPrice(33),
|
||||
createdAt: '2026-03-09 10:32',
|
||||
paymentStatusText: '待支付',
|
||||
actionText: '继续支付'
|
||||
},
|
||||
{
|
||||
id: '5000000000000000002',
|
||||
orderNo: 'TC202603080128',
|
||||
storeName: '七宝万科店',
|
||||
statusText: '配送中',
|
||||
scene: FulfillmentScenes.Delivery,
|
||||
itemSummary: '黑椒牛柳饭 ×1、芝士薯球 ×1',
|
||||
totalAmount: 43,
|
||||
totalAmountText: formatPrice(43),
|
||||
createdAt: '2026-03-08 18:24',
|
||||
paymentStatusText: '已支付',
|
||||
actionText: '查看详情'
|
||||
},
|
||||
{
|
||||
id: '5000000000000000003',
|
||||
orderNo: 'TC202603070075',
|
||||
storeName: '漕河泾园区店',
|
||||
statusText: '已完成',
|
||||
scene: FulfillmentScenes.Pickup,
|
||||
itemSummary: '藤椒鸡丝沙拉 ×1、冰美式 ×1',
|
||||
totalAmount: 32,
|
||||
totalAmountText: formatPrice(32),
|
||||
createdAt: '2026-03-07 12:10',
|
||||
paymentStatusText: '已支付',
|
||||
actionText: '再来一单'
|
||||
}
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user