refactor: 拆分小程序 vue 结构

This commit is contained in:
2026-03-11 14:11:26 +08:00
commit b050c01a24
141 changed files with 24904 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

4
.env.development Normal file
View File

@@ -0,0 +1,4 @@
TARO_APP_API_BASE_URL=https://api-mini-dev.laosankeji.com/api/mini/v1
TARO_APP_USE_MOCK=false
TARO_APP_REQUEST_TIMEOUT=10000
TARO_APP_TENANT_CODE=t1770086772899

4
.env.production Normal file
View File

@@ -0,0 +1,4 @@
TARO_APP_API_BASE_URL=https://api-mini-dev.laosankeji.com/api/mini/v1
TARO_APP_USE_MOCK=false
TARO_APP_REQUEST_TIMEOUT=10000
TARO_APP_TENANT_CODE=t1770086772899

1
.env.test Normal file
View File

@@ -0,0 +1 @@
# TARO_APP_ID="测试环境下的小程序 AppID"

14
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
root: true,
extends: ['taro/vue3'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2022,
sourceType: 'module',
extraFileExtensions: ['.vue']
},
rules: {
'vue/multi-word-component-names': 'off'
}
}

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
dist/
deploy_versions/
.temp/
.rn_temp/
node_modules/
.DS_Store
.swc
*.local

4
.husky/commit-msg Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
# 运行 commitlint 检查 commit message
npx --no -- commitlint --edit ${1}

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
registry=https://registry.npmmirror.com/
shamefully-hoist=true
strict-peer-dependencies=false

49
AGENTS.md Normal file
View File

@@ -0,0 +1,49 @@
# TakeoutSaaS.C-Side-Mini-Program-Taro
## 项目定位
- 这是 `TakeoutSaaS` 面向顾客的微信小程序前端仓库Taro 版本)。
- 当前技术栈固定为 `Taro 4 + Vue3 + TypeScript + Pinia + NutUI Taro + Vite`
- 目标先跑通 `V1.0` 顾客主交易闭环:选店、点餐、购物车、结算、订单查询。
## 强制约束
- 小程序前端必须基于官方 `Taro CLI` 脚手架维护,不要手搓工程骨架替代官方初始化结果。
- 当前输出端固定为 `微信小程序`,除非用户明确要求多端同发。
- 所有后端 `ID` 一律按 `string` 处理,不要在前端转 `number`
- 所有接口统一先经过 `src/services` 请求层封装,再进入页面或 store页面里不要直接散落 `Taro.request`
- 所有后端响应按 `ApiResponse<T>` 解包;页面层只消费已经解包后的业务数据。
## 当前接口约束
- 真实基址:`https://api-mini-dev.laosankeji.com/api/mini/v1`
- 固定租户头:`X-Tenant-Code: t1770086772899`
- 当前已确认真实接口:`GET /bootstrap``GET /health`
- 其它业务接口当前仍允许走 mock直到后端正式补齐。
## 目录约定
- `src/pages`:页面目录,按业务域拆分。
- `src/components`:页面级复用组件,统一使用目录入口结构。
- `src/stores`Pinia 状态。
- `src/services`:请求层与领域接口封装。
- `src/shared`类型、常量、格式化函数、mock 数据。
- `src/utils`:通用工具。
## 技术规范
- 页面负责展示与交互编排store 负责跨页状态services 负责接口协议适配shared 负责纯数据与类型。
- 场景枚举统一使用:`Delivery``Pickup``DineIn`
- 小程序渠道枚举统一使用:`WeChatMiniProgram`
- 环境变量统一通过构建期常量注入,不要在页面里硬编码域名。
- UI 风格保持轻量、清晰、可触达,优先保证 44px 以上点击区和明确状态反馈。
- 静态样式禁止直接写在模板的 `style=""` 上;改成语义类名并落到 `scss` 文件。
## Vue 文件拆分规范
- 页面入口固定为 `src/pages/**/index.vue`,只保留模板、薄胶水脚本和 `@import './styles/index.scss'`;业务加载、计算、提交流程统一下沉到 `composables/useXxxPage.ts`
- 复杂页面必须继续二级拆分到 `src/pages/**/composables/<page-key>/`,至少按 `helpers.ts``*actions.ts``constants.ts` 这类职责拆开,不要把大段流程重新塞回 `useXxxPage.ts`
- 页面样式固定放在 `src/pages/**/styles/``styles/index.scss` 只负责聚合;当样式超过 150 行或已经出现多个视觉域时,必须拆成 `base.scss``card.scss``submit.scss` 这类分片。
- 根组件固定使用目录入口:`src/components/<kebab-name>/index.vue`,并通过 `@/components/<kebab-name>/index.vue` 引用,不再新增平铺的 `src/components/*.vue`
- 组件逻辑放在同目录的 `useXxx.ts`;由于 Vue 宏限制,`defineProps` / `defineEmits` 可以留在 `index.vue`,但计算、事件、映射、辅助函数必须下沉到 `useXxx.ts`
- 当组件样式简单时允许使用同级 `styles.scss`;当组件样式超过 150 行或明显分区时,升级为 `styles/index.scss + 分片.scss`
- 新增页面或组件时,优先复用现有拆分模式;不要再创建“模板 + 大段脚本 + 大段样式”三段混写的单文件组件。
## 修改约定
- 新增页面后,必须同步注册 `src/app.config.ts` 路由。
- 新增接口后,优先补在 `src/services/mini.ts` 或对应领域文件,不要直接写进页面。
- 如果修改了目录结构、命令或工程规范,要同步更新 `README.md`

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# TakeoutSaaS C-Side Mini Program (Taro)
基于官方 `Taro CLI` 初始化的微信小程序前端,当前使用 `Taro 4 + Vue3 + Pinia + NutUI Taro + Vite`
## 当前状态
- 已切换到 `Taro + Vue3 + NutUI` 方案。
- 已接入真实 Mini API 基址:`https://api-mini-dev.laosankeji.com/api/mini/v1`
- 已默认注入租户头:`X-Tenant-Code: t1770086772899`
- 当前真实接口:`bootstrap``health`
- 业务接口:默认仍走 mock方便先完成前端主流程联调
## 本地开发
```bash
pnpm install
pnpm dev:weapp
```
## 常用命令
```bash
pnpm dev:weapp
pnpm build:weapp
pnpm typecheck
pnpm lint
```
## 环境变量
- `.env.development`
- `.env.production`
当前关键变量:
- `TARO_APP_API_BASE_URL`
- `TARO_APP_USE_MOCK`
- `TARO_APP_REQUEST_TIMEOUT`
- `TARO_APP_TENANT_CODE`
## 目录结构
- `src/pages`:按页面域拆分,每个页面保持 `index.vue + composables + styles`
- `src/components`:复用组件目录,统一使用 `src/components/<kebab-name>/index.vue`
- `src/stores``app``cart`
- `src/services`:请求封装与 Mini API 领域接口
- `src/shared`类型、常量、mock 数据、格式化函数
当前拆分约定:
- 页面入口 `src/pages/**/index.vue` 只保留模板和薄脚本,逻辑下沉到 `composables/useXxxPage.ts`
- 复杂页面继续在 `composables/<page-key>/` 下拆 `helpers.ts``*actions.ts`
- 页面样式统一落在 `styles/index.scss`,必要时按视觉域继续拆分
- 根组件统一改为目录入口,例如 `src/components/page-hero/index.vue`
## 联调说明
- `bootstrap``health` 走真实接口。
- 当前其余业务页面仍通过 `src/shared/mock` 数据驱动。
- 后端业务接口上线后,只需要在 `src/services/mini.ts` 中切换真实请求即可。

11
babel.config.js Normal file
View File

@@ -0,0 +1,11 @@
// babel-preset-taro 更多选项和默认值:
// https://docs.taro.zone/docs/next/babel-config
module.exports = {
presets: [
['taro', {
framework: 'vue3',
ts: true,
compiler: 'vite',
}]
]
}

1
commitlint.config.mjs Normal file
View File

@@ -0,0 +1 @@
export default { extends: ["@commitlint/config-conventional"] };

5
config/dev.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { UserConfigExport } from '@tarojs/cli'
export default {
mini: {}
} satisfies UserConfigExport<'vite'>

65
config/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import path from 'node:path'
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
import devConfig from './dev'
import prodConfig from './prod'
const appEnv = process.env.NODE_ENV || 'development'
const apiBaseUrl = process.env.TARO_APP_API_BASE_URL || 'https://api-mini-dev.laosankeji.com/api/mini/v1'
const useMock = String(process.env.TARO_APP_USE_MOCK || 'true') === 'true'
const requestTimeout = Number(process.env.TARO_APP_REQUEST_TIMEOUT || 10000)
const tenantCode = process.env.TARO_APP_TENANT_CODE || 't1770086772899'
export default defineConfig<'vite'>(async (merge) => {
const baseConfig: UserConfigExport<'vite'> = {
projectName: 'TakeoutSaaS.C-Side-Mini-Program-V1',
date: '2026-3-9',
designWidth: 375,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2
},
sourceRoot: 'src',
outputRoot: 'dist',
alias: {
'@': path.resolve(__dirname, '..', 'src')
},
plugins: ['@tarojs/plugin-html'],
defineConstants: {
__APP_ENV__: JSON.stringify(appEnv),
__API_BASE_URL__: JSON.stringify(apiBaseUrl),
__USE_MOCK__: JSON.stringify(useMock),
__REQUEST_TIMEOUT__: JSON.stringify(requestTimeout),
__TENANT_CODE__: JSON.stringify(tenantCode)
},
copy: {
patterns: [],
options: {}
},
framework: 'vue3',
compiler: 'vite',
mini: {
postcss: {
pxtransform: {
enable: true,
config: {}
},
cssModules: {
enable: false,
config: {
namingPattern: 'module',
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
}
}
}
if (process.env.NODE_ENV === 'development') {
return merge({}, baseConfig, devConfig)
}
return merge({}, baseConfig, prodConfig)
})

5
config/prod.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { UserConfigExport } from '@tarojs/cli'
export default {
mini: {}
} satisfies UserConfigExport<'vite'>

62
package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "takeout-c-side-mini-program-v1",
"version": "0.1.0",
"private": true,
"description": "Takeout SaaS C-end WeChat mini program based on Taro Vue3 NutUI",
"scripts": {
"dev": "pnpm run dev:weapp",
"dev:weapp": "taro build --type weapp --watch",
"build": "pnpm run build:weapp",
"build:weapp": "taro build --type weapp",
"lint": "eslint --ext .js,.ts,.vue src config",
"typecheck": "tsc --noEmit"
},
"browserslist": [
"defaults and fully supports es6-module",
"maintained node versions"
],
"dependencies": {
"@babel/runtime": "^7.24.4",
"@nutui/icons-vue-taro": "0.0.9",
"@nutui/nutui-taro": "4.3.14",
"@tarojs/components": "4.1.11",
"@tarojs/plugin-framework-vue3": "4.1.11",
"@tarojs/plugin-html": "4.1.11",
"@tarojs/plugin-platform-weapp": "4.1.11",
"@tarojs/runtime": "4.1.11",
"@tarojs/shared": "4.1.11",
"@tarojs/taro": "4.1.11",
"pinia": "^2.3.1",
"vue": "^3.5.18"
},
"devDependencies": {
"@babel/core": "^7.24.4",
"@tarojs/cli": "4.1.11",
"@tarojs/plugin-platform-alipay": "4.1.11",
"@tarojs/plugin-platform-jd": "4.1.11",
"@tarojs/plugin-platform-swan": "4.1.11",
"@tarojs/plugin-platform-tt": "4.1.11",
"@tarojs/taro-h5": "4.1.11",
"@tarojs/taro-rn": "4.1.11",
"@tarojs/vite-runner": "4.1.11",
"@types/minimatch": "^5.1.2",
"@types/react": "^18.3.28",
"@types/react-native": "~0.73.0",
"@vitejs/plugin-vue": "^4.6.2",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"babel-preset-taro": "4.1.11",
"eslint": "^8.57.0",
"eslint-config-taro": "4.1.11",
"eslint-plugin-vue": "^8.7.1",
"html-webpack-plugin": "^5.6.6",
"postcss": "^8.5.6",
"sass": "^1.75.0",
"stylelint": "^16.4.0",
"stylelint-config-standard": "^38.0.0",
"terser": "^5.30.4",
"typescript": "^5.4.5",
"vite": "^4.2.0",
"webpack-chain": "^6.5.1",
"webpack-dev-server": "^5.2.3"
}
}

17373
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,9 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@swc/core'
- '@tarojs/binding'
- '@tarojs/cli'
- core-js
- core-js-pure
- esbuild
- vue-demi

39
project.config.json Normal file
View File

@@ -0,0 +1,39 @@
{
"miniprogramRoot": "dist/",
"projectname": "TakeoutSaaS.C-Side-Mini-Program-Taro",
"description": "Takeout SaaS C-end WeChat mini program (Taro Vue3)",
"appid": "wx30f91e6afe79f405",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"minified": false,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}

View File

@@ -0,0 +1,22 @@
{
"libVersion": "3.14.3",
"projectname": "TakeoutSaaS.C-Side-Mini-Program-Taro",
"setting": {
"urlCheck": false,
"coverView": false,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

44
src/app.config.ts Normal file
View 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
View 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
View 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

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

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,4 @@
@import './base.scss';
@import './list.scss';
@import './footer.scss';
@import './empty.scss';

View 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;
}
}

View File

@@ -0,0 +1,8 @@
import type { CartLine } from '@/stores/cart'
export interface CartDrawerLineItem extends CartLine {
lineAmount: number
lineAmountText: string
unitPriceText: string
specText: string
}

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

View 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;
}

View File

@@ -0,0 +1,10 @@
import { computed, useSlots } from 'vue'
export function usePageHero () {
const slots = useSlots()
const hasExtra = computed(() => Boolean(slots.default))
return {
hasExtra
}
}

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

View 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;
}
}

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

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

View 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;
}

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

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

View 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;
}

View 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;
}
}

View 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;
}

View File

@@ -0,0 +1,5 @@
@import './base.scss';
@import './header.scss';
@import './options.scss';
@import './stepper.scss';
@import './footer.scss';

View 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;
}
}

View 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;
}

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '配送地址'
})

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

View File

@@ -0,0 +1,7 @@
.address-page__title-gap {
margin-top: 18px;
}
.address-page__actions {
margin-top: 20px;
}

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '确认桌号'
})

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

View File

@@ -0,0 +1,3 @@
.dinein-page__actions {
margin-top: 20px;
}

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})

134
src/pages/home/index.vue Normal file
View 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>

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,5 @@
@import './base.scss';
@import './banner.scss';
@import './category.scss';
@import './trust.scss';
@import './fab.scss';

View 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;
}

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

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

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '点餐'
})

166
src/pages/menu/index.vue Normal file
View 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>

View 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;
}

View 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;
}

View File

@@ -0,0 +1,3 @@
@import './base.scss';
@import './sections.scss';
@import './cart.scss';

View 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;
}

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单详情'
})

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

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,4 @@
@import './base.scss';
@import './timeline.scss';
@import './goods.scss';
@import './bottom.scss';

View 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;
}

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '订单'
})

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

View 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;
}

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '我的'
})

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

View 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;
}

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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '门店选择'
})

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

View 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;
}

View File

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

View File

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

View File

@@ -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 '下单后商家将尽快制作'
}

Some files were not shown because too many files have changed in this diff Show More