Compare commits
6 Commits
cfcd378c08
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d7981115 | |||
| 2b2a02fe88 | |||
| 007d380757 | |||
| 79a78da82c | |||
| b4c36923ea | |||
| b050c01a24 |
12
.editorconfig
Normal 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
@@ -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
@@ -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
|
||||||
14
.eslintrc.cjs
Normal 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
@@ -0,0 +1,8 @@
|
|||||||
|
dist/
|
||||||
|
deploy_versions/
|
||||||
|
.temp/
|
||||||
|
.rn_temp/
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
.swc
|
||||||
|
*.local
|
||||||
4
.husky/commit-msg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# 运行 commitlint 检查 commit message
|
||||||
|
npx --no -- commitlint --edit ${1}
|
||||||
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
registry=https://registry.npmmirror.com/
|
||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
49
AGENTS.md
Normal 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`,只保留模板、薄胶水脚本和 `@use './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
@@ -1,3 +1,53 @@
|
|||||||
# TakeoutSaaS.C-Side-Mini-Program-V1
|
# TakeoutSaaS.C-Side-Mini-Program-V1
|
||||||
|
|
||||||
这是一个 C 端小程序仓库,用于承载面向消费者的小程序前端项目代码。
|
基于官方 `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`
|
||||||
|
- `mini/custom-tab-bar`:微信小程序原生 `custom-tab-bar` 源文件,会在构建时复制到 `dist/custom-tab-bar`
|
||||||
|
- `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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export default { extends: ["@commitlint/config-conventional"] };
|
||||||
5
config/dev.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { UserConfigExport } from '@tarojs/cli'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mini: {}
|
||||||
|
} satisfies UserConfigExport<'vite'>
|
||||||
70
config/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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: [
|
||||||
|
{
|
||||||
|
from: 'mini/custom-tab-bar',
|
||||||
|
to: 'dist/custom-tab-bar'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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
@@ -0,0 +1,5 @@
|
|||||||
|
import type { UserConfigExport } from '@tarojs/cli'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mini: {}
|
||||||
|
} satisfies UserConfigExport<'vite'>
|
||||||
49
mini/custom-tab-bar/index.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
Component({
|
||||||
|
data: {
|
||||||
|
selected: 0,
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
pagePath: 'pages/home/index',
|
||||||
|
text: '首页',
|
||||||
|
iconPath: '../assets/tabbar/home.png',
|
||||||
|
selectedIconPath: '../assets/tabbar/home-active.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/menu/index',
|
||||||
|
text: '点餐',
|
||||||
|
iconPath: '../assets/tabbar/menu.png',
|
||||||
|
selectedIconPath: '../assets/tabbar/menu-active.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/orders/index',
|
||||||
|
text: '订单',
|
||||||
|
iconPath: '../assets/tabbar/orders.png',
|
||||||
|
selectedIconPath: '../assets/tabbar/orders-active.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/profile/index',
|
||||||
|
text: '我的',
|
||||||
|
iconPath: '../assets/tabbar/profile.png',
|
||||||
|
selectedIconPath: '../assets/tabbar/profile-active.png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setCurrent(index) {
|
||||||
|
this.setData({ selected: index })
|
||||||
|
},
|
||||||
|
switchTab(event) {
|
||||||
|
const index = Number(event.currentTarget.dataset.index)
|
||||||
|
const currentItem = this.data.list[this.data.selected]
|
||||||
|
const targetItem = this.data.list[index]
|
||||||
|
const currentPath = currentItem ? currentItem.pagePath : ''
|
||||||
|
|
||||||
|
if (!targetItem || currentPath === targetItem.pagePath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({ selected: index })
|
||||||
|
wx.switchTab({ url: `/${targetItem.pagePath}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
3
mini/custom-tab-bar/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"component": true
|
||||||
|
}
|
||||||
20
mini/custom-tab-bar/index.wxml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<view class="custom-tab-bar">
|
||||||
|
<view
|
||||||
|
wx:for="{{list}}"
|
||||||
|
wx:key="pagePath"
|
||||||
|
class="custom-tab-bar__item"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindtap="switchTab"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="custom-tab-bar__icon"
|
||||||
|
src="{{selected === index ? item.selectedIconPath : item.iconPath}}"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
class="custom-tab-bar__label {{selected === index ? 'custom-tab-bar__label--active' : ''}}"
|
||||||
|
>
|
||||||
|
{{item.text}}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
42
mini/custom-tab-bar/index.wxss
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
.custom-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-bottom: calc(6px + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(6px + env(safe-area-inset-bottom));
|
||||||
|
min-height: 72px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1rpx solid #e5e7eb;
|
||||||
|
box-shadow: 0 -4px 18px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-bar__item {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-bar__icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-bar__label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tab-bar__label--active {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
62
package.json
Normal 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
9
pnpm-workspace.yaml
Normal 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
@@ -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": true,
|
||||||
|
"enhance": true,
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
22
project.private.config.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app.config.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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: {
|
||||||
|
custom: true,
|
||||||
|
color: '#64748b',
|
||||||
|
selectedColor: '#16a34a',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderStyle: 'black',
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
pagePath: 'pages/home/index',
|
||||||
|
text: '首页',
|
||||||
|
iconPath: 'assets/tabbar/home.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/home-active.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/menu/index',
|
||||||
|
text: '点餐',
|
||||||
|
iconPath: 'assets/tabbar/menu.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/menu-active.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/orders/index',
|
||||||
|
text: '订单',
|
||||||
|
iconPath: 'assets/tabbar/orders.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/orders-active.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/profile/index',
|
||||||
|
text: '我的',
|
||||||
|
iconPath: 'assets/tabbar/profile.png',
|
||||||
|
selectedIconPath: 'assets/tabbar/profile-active.png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
107
src/app.scss
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
@use './styles/variables' as *;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
33
src/app.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import '@nutui/nutui-taro/dist/style.css'
|
||||||
|
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
|
||||||
BIN
src/assets/tabbar/home-active.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/tabbar/home.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/tabbar/menu-active.png
Normal file
|
After Width: | Height: | Size: 875 B |
BIN
src/assets/tabbar/menu.png
Normal file
|
After Width: | Height: | Size: 987 B |
BIN
src/assets/tabbar/orders-active.png
Normal file
|
After Width: | Height: | Size: 776 B |
BIN
src/assets/tabbar/orders.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/tabbar/profile-active.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/tabbar/profile.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
116
src/components/cart-drawer/index.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="props.visible" class="overlay" @click="emit('close')" />
|
||||||
|
<view v-if="props.visible" class="cart-drawer">
|
||||||
|
<view class="cart-drawer__handle" />
|
||||||
|
|
||||||
|
<template v-if="props.lineList.length">
|
||||||
|
<scroll-view scroll-y class="cart-drawer__scroll">
|
||||||
|
<view class="cart-drawer__header">
|
||||||
|
<view class="cart-drawer__header-left">
|
||||||
|
<view class="cart-drawer__header-icon">
|
||||||
|
<text class="cart-drawer__icon-text">🛒</text>
|
||||||
|
</view>
|
||||||
|
<text class="cart-drawer__title">购物车</text>
|
||||||
|
<text class="cart-drawer__count">{{ props.itemCount }} 件</text>
|
||||||
|
</view>
|
||||||
|
<view class="cart-drawer__clear" @click="emit('clear')">
|
||||||
|
<text>清空</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="cart-drawer__list">
|
||||||
|
<view
|
||||||
|
v-for="line in props.lineList"
|
||||||
|
:key="line.lineKey"
|
||||||
|
class="cart-drawer__item"
|
||||||
|
>
|
||||||
|
<view class="cart-drawer__item-info">
|
||||||
|
<text class="cart-drawer__item-name">{{ line.name }}</text>
|
||||||
|
<text v-if="line.specText" class="cart-drawer__item-spec">
|
||||||
|
{{ line.specText }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="cart-drawer__item-right">
|
||||||
|
<text class="cart-drawer__item-price">
|
||||||
|
<text class="cart-drawer__unit">¥</text>{{ line.lineAmountText }}
|
||||||
|
</text>
|
||||||
|
<view class="cart-drawer__stepper">
|
||||||
|
<view
|
||||||
|
class="cart-drawer__stepper-btn"
|
||||||
|
@click="emit('changeQty', line.lineKey, -1)"
|
||||||
|
>
|
||||||
|
<text>-</text>
|
||||||
|
</view>
|
||||||
|
<text class="cart-drawer__stepper-val">{{ line.quantity }}</text>
|
||||||
|
<view
|
||||||
|
class="cart-drawer__stepper-btn"
|
||||||
|
@click="emit('changeQty', line.lineKey, 1)"
|
||||||
|
>
|
||||||
|
<text>+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="cart-drawer__amount">
|
||||||
|
<view class="cart-drawer__amount-row">
|
||||||
|
<text class="cart-drawer__amount-label">商品小计</text>
|
||||||
|
<text class="cart-drawer__amount-value">¥{{ props.totalAmountText }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="cart-drawer__amount-row cart-drawer__amount-row--total">
|
||||||
|
<text class="cart-drawer__amount-label">合计</text>
|
||||||
|
<text class="cart-drawer__amount-value cart-drawer__amount-value--total">
|
||||||
|
<text class="cart-drawer__unit">¥</text>{{ props.totalAmountText }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="cart-drawer__footer">
|
||||||
|
<view class="cart-drawer__footer-left">
|
||||||
|
<text class="cart-drawer__footer-summary">
|
||||||
|
共 <text class="cart-drawer__footer-num">{{ props.itemCount }}</text> 件
|
||||||
|
</text>
|
||||||
|
<view class="cart-drawer__footer-total">
|
||||||
|
<text class="cart-drawer__footer-unit">¥</text>
|
||||||
|
<text class="cart-drawer__footer-value">{{ props.totalAmountText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="cart-drawer__footer-btn" @click="emit('checkout')">
|
||||||
|
<text>去结算</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view v-else class="cart-drawer__empty">
|
||||||
|
<text class="cart-drawer__empty-title">购物车还是空的</text>
|
||||||
|
<text class="cart-drawer__empty-desc">先去选几样喜欢的商品吧</text>
|
||||||
|
<view class="cart-drawer__empty-btn" @click="emit('close')">
|
||||||
|
<text>去选购</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CartDrawerLineItem } from './useCartDrawer'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
lineList: CartDrawerLineItem[]
|
||||||
|
itemCount: number
|
||||||
|
totalAmountText: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
clear: []
|
||||||
|
checkout: []
|
||||||
|
changeQty: [lineKey: string, delta: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use './styles/index.scss';
|
||||||
|
</style>
|
||||||
79
src/components/cart-drawer/styles/base.scss
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.cart-drawer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-height: 75%;
|
||||||
|
background: $card;
|
||||||
|
border-radius: 28px 28px 0 0;
|
||||||
|
z-index: 210;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.10);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: $text-5;
|
||||||
|
margin: 10px auto 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 20px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__header-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $primary-light;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__icon-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
font-weight: 500;
|
||||||
|
background: $border;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__clear {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-4;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
38
src/components/cart-drawer/styles/empty.scss
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.cart-drawer__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px 60px;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__empty-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__empty-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-4;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__empty-btn {
|
||||||
|
margin-top: 20px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 19px;
|
||||||
|
border: 1.5px solid $primary;
|
||||||
|
background: transparent;
|
||||||
|
color: $primary;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
63
src/components/cart-drawer/styles/footer.scss
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.cart-drawer__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 20px 28px;
|
||||||
|
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||||
|
background: $card;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__footer-left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__footer-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__footer-num {
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__footer-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__footer-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__footer-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $primary-dark;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__footer-btn {
|
||||||
|
height: 50px;
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 0 32px;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
4
src/components/cart-drawer/styles/index.scss
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@forward './base.scss';
|
||||||
|
@forward './list.scss';
|
||||||
|
@forward './footer.scss';
|
||||||
|
@forward './empty.scss';
|
||||||
124
src/components/cart-drawer/styles/list.scss
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.cart-drawer__list {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 0.5px solid $border;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__item-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__item-spec {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
margin-top: 3px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__item-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__item-price {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__unit {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__stepper-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid $text-5;
|
||||||
|
background: $card;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-2;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__stepper-val {
|
||||||
|
width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__amount {
|
||||||
|
margin: 0 20px;
|
||||||
|
padding: 14px 0 6px;
|
||||||
|
border-top: 1px dashed $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__amount-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 0;
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
padding: 10px 0 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-top: 0.5px solid $border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__amount-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-3;
|
||||||
|
|
||||||
|
.cart-drawer__amount-row--total & {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-drawer__amount-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-2;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/components/cart-drawer/useCartDrawer.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { CartLine } from '@/stores/cart'
|
||||||
|
|
||||||
|
export interface CartDrawerLineItem extends CartLine {
|
||||||
|
lineAmount: number
|
||||||
|
lineAmountText: string
|
||||||
|
unitPriceText: string
|
||||||
|
specText: string
|
||||||
|
}
|
||||||
30
src/components/page-hero/index.vue
Normal file
@@ -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">
|
||||||
|
@use './styles.scss';
|
||||||
|
</style>
|
||||||
46
src/components/page-hero/styles.scss
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
.page-hero {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(135deg, rgba(17, 24, 39, 0.96), rgba(22, 163, 74, 0.92));
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__copy {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__title {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__badge {
|
||||||
|
flex: none;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hero__extra {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
10
src/components/page-hero/usePageHero.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { computed, useSlots } from 'vue'
|
||||||
|
|
||||||
|
export function usePageHero () {
|
||||||
|
const slots = useSlots()
|
||||||
|
const hasExtra = computed(() => Boolean(slots.default))
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasExtra
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/components/product-card/index.vue
Normal file
@@ -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">
|
||||||
|
@use './styles.scss';
|
||||||
|
</style>
|
||||||
132
src/components/product-card/styles.scss
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__img-wrap {
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #FED7AA, #FDBA74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__img-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&--hot { background: rgba(239, 68, 68, 0.85); }
|
||||||
|
&--new { background: rgba(22, 163, 74, 0.85); }
|
||||||
|
&--sale { background: rgba(245, 158, 11, 0.85); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-3;
|
||||||
|
margin-top: 3px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__sales {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__price {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__price-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__price-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-dark;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__price-origin {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-4;
|
||||||
|
text-decoration: line-through;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/components/product-card/useProductCard.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import type { MiniProductCard } from '@/shared'
|
||||||
|
|
||||||
|
export function useProductCard (props: { product: MiniProductCard }) {
|
||||||
|
const tagClass = computed(() => {
|
||||||
|
const tag = props.product.tagTexts?.[0] || ''
|
||||||
|
if (['招牌', '热卖', '热销'].includes(tag)) return 'product-card__img-tag--hot'
|
||||||
|
if (['新品'].includes(tag)) return 'product-card__img-tag--new'
|
||||||
|
if (['超值', '加购王'].includes(tag)) return 'product-card__img-tag--sale'
|
||||||
|
return 'product-card__img-tag--hot'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagClass
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/components/scene-switcher/index.vue
Normal file
@@ -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">
|
||||||
|
@use './styles.scss';
|
||||||
|
</style>
|
||||||
17
src/components/scene-switcher/styles.scss
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.scene-switcher {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-switcher__item {
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-switcher__desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
15
src/components/scene-switcher/useSceneSwitcher.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
interface SceneSwitcherEmits {
|
||||||
|
(event: 'change', value: string): void
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSceneSwitcher (emit: SceneSwitcherEmits) {
|
||||||
|
function handleSelect (value: string) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
emit('change', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSelect
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/components/spec-popup/index.vue
Normal file
@@ -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">
|
||||||
|
@use './styles/index.scss';
|
||||||
|
</style>
|
||||||
31
src/components/spec-popup/styles/base.scss
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.spec-popup {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-height: 82%;
|
||||||
|
background: $card;
|
||||||
|
border-radius: 28px 28px 0 0;
|
||||||
|
z-index: 210;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.10);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: $text-5;
|
||||||
|
margin: 10px auto 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
74
src/components/spec-popup/styles/footer.scss
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.spec-popup__footer {
|
||||||
|
padding: 14px 20px;
|
||||||
|
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: $card;
|
||||||
|
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__footer-price {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__footer-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-4;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__footer-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__footer-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__footer-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $primary-dark;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__footer-summary {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-4;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__footer-btn {
|
||||||
|
height: 48px;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0 28px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||||
|
box-shadow: 0 4px 14px rgba(22, 163, 74, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
background: $text-5;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/components/spec-popup/styles/header.scss
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.spec-popup__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 6px 20px 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
background: linear-gradient(135deg, #FED7AA, #FDBA74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-3;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&--hot { background: $red-light; color: #DC2626; }
|
||||||
|
&--new { background: $primary-light; color: $primary; }
|
||||||
|
&--sales { background: $border; color: $text-3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__price-unit {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__price-current {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $primary-dark;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__price-origin {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-4;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
5
src/components/spec-popup/styles/index.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@forward './base.scss';
|
||||||
|
@forward './header.scss';
|
||||||
|
@forward './options.scss';
|
||||||
|
@forward './stepper.scss';
|
||||||
|
@forward './footer.scss';
|
||||||
73
src/components/spec-popup/styles/options.scss
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.spec-popup__group {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 0.5px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__group-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__group-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
&--required { background: $red-light; color: $red; }
|
||||||
|
&--multi { background: $primary-light; color: $primary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__pill {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-2;
|
||||||
|
background: $bg;
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background: $primary-lighter;
|
||||||
|
border-color: $primary;
|
||||||
|
color: $primary-darker;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__pill-extra {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.spec-popup__pill--selected & {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/components/spec-popup/styles/stepper.scss
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.spec-popup__stepper-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 0.5px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__stepper-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__stepper-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid $text-5;
|
||||||
|
background: $card;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-2;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-popup__stepper-value {
|
||||||
|
width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
11
src/components/spec-popup/useSpecPopup.ts
Normal file
@@ -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
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
|
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-touch-fullscreen" content="yes">
|
||||||
|
<meta name="format-detection" content="telephone=no,address=no">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
||||||
|
<title>TakeoutSaaS.C-Side-Mini-Program-Taro</title>
|
||||||
|
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
src/pages/address/composables/useAddressPage.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { navigateBack, showToast } from '@tarojs/taro'
|
||||||
|
import { pinia, useCustomerStore, useFulfillmentStore } from '@/stores'
|
||||||
|
|
||||||
|
interface InputLikeEvent {
|
||||||
|
detail?: {
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInputValue (event: InputLikeEvent) {
|
||||||
|
return event.detail?.value || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddressPage () {
|
||||||
|
const customerStore = useCustomerStore(pinia)
|
||||||
|
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||||
|
const name = ref(fulfillmentStore.address?.name || customerStore.name)
|
||||||
|
const phone = ref(fulfillmentStore.address?.phone || customerStore.phone)
|
||||||
|
const address = ref(fulfillmentStore.address?.address || '')
|
||||||
|
const detail = ref(fulfillmentStore.address?.detail || '')
|
||||||
|
|
||||||
|
function handleNameInput (event: InputLikeEvent) {
|
||||||
|
name.value = readInputValue(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhoneInput (event: InputLikeEvent) {
|
||||||
|
phone.value = readInputValue(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddressInput (event: InputLikeEvent) {
|
||||||
|
address.value = readInputValue(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetailInput (event: InputLikeEvent) {
|
||||||
|
detail.value = readInputValue(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave () {
|
||||||
|
if (!name.value.trim() || !phone.value.trim() || !address.value.trim()) {
|
||||||
|
await showToast({ title: '请完善地址信息', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fulfillmentStore.setAddress({
|
||||||
|
id: 'local-default',
|
||||||
|
name: name.value.trim(),
|
||||||
|
phone: phone.value.trim(),
|
||||||
|
address: address.value.trim(),
|
||||||
|
detail: detail.value.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
await showToast({ title: '地址已保存', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
void navigateBack()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
detail,
|
||||||
|
handleAddressInput,
|
||||||
|
handleDetailInput,
|
||||||
|
handleNameInput,
|
||||||
|
handlePhoneInput,
|
||||||
|
handleSave,
|
||||||
|
name,
|
||||||
|
phone
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/pages/address/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '配送地址'
|
||||||
|
})
|
||||||
42
src/pages/address/index.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page-shell address-page">
|
||||||
|
<PageHero title="配送地址" subtitle="填写收货人和配送地址,方便下单时直接使用" />
|
||||||
|
|
||||||
|
<view class="surface-card">
|
||||||
|
<text class="section-title">联系人</text>
|
||||||
|
<input class="field-input" :value="name" placeholder="请输入收货人姓名" @input="handleNameInput" />
|
||||||
|
<text class="section-title address-page__title-gap">联系电话</text>
|
||||||
|
<input class="field-input" :value="phone" type="number" placeholder="请输入联系电话" @input="handlePhoneInput" />
|
||||||
|
<text class="section-title address-page__title-gap">地址</text>
|
||||||
|
<input class="field-input" :value="address" placeholder="请输入配送地址" @input="handleAddressInput" />
|
||||||
|
<text class="section-title address-page__title-gap">门牌号 / 详细地址</text>
|
||||||
|
<input class="field-input" :value="detail" placeholder="例如 3号楼 501" @input="handleDetailInput" />
|
||||||
|
<view class="address-page__actions">
|
||||||
|
<NutButton type="primary" block @click="handleSave">保存地址</NutButton>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||||
|
import PageHero from '@/components/page-hero/index.vue'
|
||||||
|
import { useAddressPage } from './composables/useAddressPage'
|
||||||
|
|
||||||
|
const {
|
||||||
|
address,
|
||||||
|
detail,
|
||||||
|
handleAddressInput,
|
||||||
|
handleDetailInput,
|
||||||
|
handleNameInput,
|
||||||
|
handlePhoneInput,
|
||||||
|
handleSave,
|
||||||
|
name,
|
||||||
|
phone
|
||||||
|
} = useAddressPage()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use './styles/index.scss';
|
||||||
|
</style>
|
||||||
|
|
||||||
7
src/pages/address/styles/index.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.address-page__title-gap {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-page__actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
37
src/pages/dinein/confirm/composables/useDineInConfirmPage.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { navigateBack, showToast } from '@tarojs/taro'
|
||||||
|
import { pinia, useFulfillmentStore } from '@/stores'
|
||||||
|
|
||||||
|
interface InputLikeEvent {
|
||||||
|
detail?: {
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDineInConfirmPage () {
|
||||||
|
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||||
|
const tableNo = ref(fulfillmentStore.tableNo)
|
||||||
|
|
||||||
|
function handleInput (event: InputLikeEvent) {
|
||||||
|
tableNo.value = event.detail?.value || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave () {
|
||||||
|
if (!tableNo.value.trim()) {
|
||||||
|
await showToast({ title: '请先填写桌号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fulfillmentStore.setTableNo(tableNo.value)
|
||||||
|
await showToast({ title: '桌号已保存', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
void navigateBack()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleInput,
|
||||||
|
handleSave,
|
||||||
|
tableNo
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/pages/dinein/confirm/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '确认桌号'
|
||||||
|
})
|
||||||
26
src/pages/dinein/confirm/index.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page-shell dinein-page">
|
||||||
|
<PageHero title="确认桌号" subtitle="请先确认桌号,方便商家及时送餐" />
|
||||||
|
|
||||||
|
<view class="surface-card">
|
||||||
|
<text class="section-title">桌号</text>
|
||||||
|
<input class="field-input" :value="tableNo" placeholder="请输入桌号,例如 A08" @input="handleInput" />
|
||||||
|
<view class="dinein-page__actions">
|
||||||
|
<NutButton type="primary" block @click="handleSave">保存桌号</NutButton>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||||
|
import PageHero from '@/components/page-hero/index.vue'
|
||||||
|
import { useDineInConfirmPage } from './composables/useDineInConfirmPage'
|
||||||
|
|
||||||
|
const { handleInput, handleSave, tableNo } = useDineInConfirmPage()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use './styles/index.scss';
|
||||||
|
</style>
|
||||||
|
|
||||||
3
src/pages/dinein/confirm/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.dinein-page__actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
86
src/pages/home/composables/useHomePage.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useDidShow } from '@tarojs/taro'
|
||||||
|
import { pinia, useAppStore, useCartStore } from '@/stores'
|
||||||
|
import {
|
||||||
|
FulfillmentScenes,
|
||||||
|
demoHotProducts,
|
||||||
|
type FulfillmentScene,
|
||||||
|
type MiniProductCard
|
||||||
|
} from '@/shared'
|
||||||
|
import { openRoute } from '@/utils/router'
|
||||||
|
|
||||||
|
export interface HomeCategoryItem {
|
||||||
|
key: string
|
||||||
|
char: string
|
||||||
|
label: string
|
||||||
|
tone: string
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeTrustItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryCards: HomeCategoryItem[] = [
|
||||||
|
{ key: 'recommend', char: '荐', label: '推荐', tone: 'warm' },
|
||||||
|
{ key: 'staple', char: '饭', label: '主食', tone: 'green' },
|
||||||
|
{ key: 'snack', char: '食', label: '小吃', tone: 'amber', badge: '热卖' },
|
||||||
|
{ key: 'drink', char: '饮', label: '饮品', tone: 'teal' },
|
||||||
|
{ key: 'combo', char: '套', label: '套餐', tone: 'blue' },
|
||||||
|
{ key: 'dessert', char: '甜', label: '甜品', tone: 'rose' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const trustItems: HomeTrustItem[] = [
|
||||||
|
{ key: 'fresh', label: '现炒现做' },
|
||||||
|
{ key: 'fast', label: '30分钟送达' },
|
||||||
|
{ key: 'pickup', label: '自提免等' },
|
||||||
|
{ key: 'quality', label: '品质保障' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function useHomePage () {
|
||||||
|
const appStore = useAppStore(pinia)
|
||||||
|
const cartStore = useCartStore(pinia)
|
||||||
|
|
||||||
|
const currentStore = computed(() => appStore.currentStore)
|
||||||
|
const cartCount = computed(() => cartStore.itemCount)
|
||||||
|
const currentScene = computed(() => appStore.scene)
|
||||||
|
const sceneOptions = computed(() => appStore.sceneOptions)
|
||||||
|
const isDineIn = computed(() => appStore.scene === FulfillmentScenes.DineIn)
|
||||||
|
const recommendedProducts = ref<MiniProductCard[]>(demoHotProducts)
|
||||||
|
|
||||||
|
function switchScene (scene: string) {
|
||||||
|
appStore.setScene(scene as FulfillmentScene)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
currentScene,
|
||||||
|
currentStore,
|
||||||
|
goMenu,
|
||||||
|
goStoreSelect,
|
||||||
|
isDineIn,
|
||||||
|
recommendedProducts,
|
||||||
|
sceneOptions,
|
||||||
|
switchScene,
|
||||||
|
trustItems
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/pages/home/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '首页',
|
||||||
|
usingComponents: {}
|
||||||
|
})
|
||||||
169
src/pages/home/index.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<view class="hp">
|
||||||
|
<!-- Store Card -->
|
||||||
|
<view class="hp__store" @click="goStoreSelect">
|
||||||
|
<view class="hp__store-info">
|
||||||
|
<view class="hp__store-row">
|
||||||
|
<text class="hp__store-name">{{ currentStore.name }}</text>
|
||||||
|
<view class="hp__store-status">
|
||||||
|
<view class="hp__store-dot" />
|
||||||
|
<text>营业中</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="hp__store-addr">{{ currentStore.address }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="hp__store-switch">
|
||||||
|
<text>切换门店</text>
|
||||||
|
<text class="hp__store-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Dine-in Scan Button -->
|
||||||
|
<view class="hp__scan" @click="goMenu">
|
||||||
|
<view class="hp__scan-ico">
|
||||||
|
<IconScan size="18" color="#fff" />
|
||||||
|
</view>
|
||||||
|
<text class="hp__scan-text">堂食扫码点餐</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<view class="hp__search" @click="goMenu">
|
||||||
|
<view class="hp__search-ico">
|
||||||
|
<IconSearch size="14" color="#9CA3AF" />
|
||||||
|
</view>
|
||||||
|
<text class="hp__search-ph">搜索菜品、套餐、饮品</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Banner -->
|
||||||
|
<view class="hp__banner" @click="goMenu">
|
||||||
|
<view class="hp__banner-body">
|
||||||
|
<view class="hp__banner-label">
|
||||||
|
<text>新客专享</text>
|
||||||
|
</view>
|
||||||
|
<text class="hp__banner-title">首单立减</text>
|
||||||
|
<view class="hp__banner-price">
|
||||||
|
<text class="hp__banner-sym">¥</text>
|
||||||
|
<text class="hp__banner-val">12</text>
|
||||||
|
</view>
|
||||||
|
<text class="hp__banner-note">全场满 38 再减 8 · 限时三天</text>
|
||||||
|
<view class="hp__banner-btn">
|
||||||
|
<text>立即领取</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<image
|
||||||
|
class="hp__banner-img"
|
||||||
|
src="https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=280&h=280&fit=crop&q=80"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view class="hp__banner-ring" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<view class="hp__cats">
|
||||||
|
<view
|
||||||
|
v-for="c in categoryCards"
|
||||||
|
:key="c.key"
|
||||||
|
class="hp__cat"
|
||||||
|
@click="goMenu"
|
||||||
|
>
|
||||||
|
<view class="hp__cat-ico" :class="`hp__cat-ico--${c.tone}`">
|
||||||
|
<text>{{ c.char }}</text>
|
||||||
|
<text v-if="c.badge" class="hp__cat-badge">{{ c.badge }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="hp__cat-name">{{ c.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Promo Strip -->
|
||||||
|
<view class="hp__promo" @click="goMenu">
|
||||||
|
<view class="hp__promo-tag">
|
||||||
|
<text>限时</text>
|
||||||
|
</view>
|
||||||
|
<text class="hp__promo-text">人气双人餐 超值特惠 ¥49 起</text>
|
||||||
|
<text class="hp__promo-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Section Header: Hot Products -->
|
||||||
|
<view class="hp__hd">
|
||||||
|
<text class="hp__hd-title">热门推荐</text>
|
||||||
|
<view class="hp__hd-more" @click="goMenu">
|
||||||
|
<text>查看全部</text>
|
||||||
|
<text class="hp__hd-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Product Grid -->
|
||||||
|
<view class="hp__grid">
|
||||||
|
<view
|
||||||
|
v-for="p in recommendedProducts"
|
||||||
|
:key="p.id"
|
||||||
|
class="hp__card"
|
||||||
|
@click="goMenu"
|
||||||
|
>
|
||||||
|
<view class="hp__card-cover">
|
||||||
|
<image class="hp__card-img" :src="p.coverImageUrl" mode="aspectFill" />
|
||||||
|
<text v-if="p.tagTexts?.length" class="hp__card-tag">{{ p.tagTexts[0] }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="hp__card-body">
|
||||||
|
<text class="hp__card-name">{{ p.name }}</text>
|
||||||
|
<text class="hp__card-desc">{{ p.description }}</text>
|
||||||
|
<text class="hp__card-sales">{{ p.salesText }}</text>
|
||||||
|
<view class="hp__card-foot">
|
||||||
|
<view class="hp__card-prices">
|
||||||
|
<text class="hp__card-sym">¥</text>
|
||||||
|
<text class="hp__card-num">{{ p.price }}</text>
|
||||||
|
<text v-if="p.originalPriceText" class="hp__card-old">¥{{ p.originalPriceText }}</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="hp__card-add"
|
||||||
|
:class="{ 'hp__card-add--pill': p.hasOptions }"
|
||||||
|
@click.stop="goMenu"
|
||||||
|
>
|
||||||
|
<text>{{ p.hasOptions ? '选规格' : '+' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Trust Strip -->
|
||||||
|
<view class="hp__trust">
|
||||||
|
<view v-for="t in trustItems" :key="t.key" class="hp__trust-pill">
|
||||||
|
<view class="hp__trust-dot" />
|
||||||
|
<text>{{ t.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Floating Cart FAB -->
|
||||||
|
<view v-if="cartCount > 0" class="hp__fab" @click="goMenu">
|
||||||
|
<view class="hp__fab-ico">
|
||||||
|
<IconCart size="20" color="#fff" />
|
||||||
|
</view>
|
||||||
|
<view class="hp__fab-badge">
|
||||||
|
<text>{{ cartCount }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Search as IconSearch, Cart2 as IconCart, Scan2 as IconScan } from '@nutui/icons-vue-taro'
|
||||||
|
import { useTabBarSelection } from '@/utils/useTabBarSelection'
|
||||||
|
import { useHomePage } from './composables/useHomePage'
|
||||||
|
|
||||||
|
useTabBarSelection(0)
|
||||||
|
|
||||||
|
const {
|
||||||
|
cartCount,
|
||||||
|
categoryCards,
|
||||||
|
currentStore,
|
||||||
|
goMenu,
|
||||||
|
goStoreSelect,
|
||||||
|
recommendedProducts,
|
||||||
|
trustItems
|
||||||
|
} = useHomePage()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use './styles/index.scss';
|
||||||
|
</style>
|
||||||
110
src/pages/home/styles/banner.scss
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.hp__banner {
|
||||||
|
border-radius: $r-xl;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
min-height: 156px;
|
||||||
|
background: linear-gradient(135deg, #14532D 0%, #166534 40%, #15803D 100%);
|
||||||
|
padding: 22px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-label {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
width: fit-content;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
display: block;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-sym {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-val {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
margin-top: 6px;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.93);
|
||||||
|
color: #14532D;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-top: 14px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-img {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 116px;
|
||||||
|
height: 116px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__banner-ring {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
228
src/pages/home/styles/base.scss
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
// ─── Page Shell ───
|
||||||
|
.hp {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store Card ───
|
||||||
|
.hp__store {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary;
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-addr {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-3;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__store-arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scene Tabs ───
|
||||||
|
.hp__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
background: #EEF2F6;
|
||||||
|
border-radius: $r-full;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__tab {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $r-full;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-3;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__tab--on {
|
||||||
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dine-in Scan Button ───
|
||||||
|
.hp__scan {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: $primary;
|
||||||
|
box-shadow: 0 2px 12px rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__scan-ico {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__scan-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search Bar ───
|
||||||
|
.hp__search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #F1F5F9;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__search-ico {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__search-ph {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section Header ───
|
||||||
|
.hp__hd {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__hd-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__hd-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-4;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__hd-arrow {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Promo Strip ───
|
||||||
|
.hp__promo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #FFFBEB;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #FEF3C7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__promo-tag {
|
||||||
|
background: $accent;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__promo-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400E;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__promo-arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
color: $accent;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
54
src/pages/home/styles/category.scss
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.hp__cats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__cat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__cat-ico {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--warm { background: #FFF4ED; color: #EA580C; }
|
||||||
|
&--green { background: #F0FDF4; color: #16A34A; }
|
||||||
|
&--amber { background: #FFFBEB; color: #D97706; }
|
||||||
|
&--teal { background: #F0FDFA; color: #0D9488; }
|
||||||
|
&--blue { background: #EFF6FF; color: #2563EB; }
|
||||||
|
&--rose { background: #FFF1F2; color: #E11D48; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__cat-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -8px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: $red;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__cat-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-2;
|
||||||
|
}
|
||||||
40
src/pages/home/styles/fab.scss
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.hp__fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 92px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 90;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary;
|
||||||
|
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__fab-ico {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__fab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: $red;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
6
src/pages/home/styles/index.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@forward './base.scss';
|
||||||
|
@forward './banner.scss';
|
||||||
|
@forward './category.scss';
|
||||||
|
@forward './product.scss';
|
||||||
|
@forward './trust.scss';
|
||||||
|
@forward './fab.scss';
|
||||||
137
src/pages/home/styles/product.scss
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.hp__grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card {
|
||||||
|
width: calc(50% - 6px);
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 136px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(22, 163, 74, 0.88);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-body {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-3;
|
||||||
|
margin-top: 3px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-sales {
|
||||||
|
font-size: 10px;
|
||||||
|
color: $text-4;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-prices {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-sym {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-num {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-dark;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-old {
|
||||||
|
font-size: 10px;
|
||||||
|
color: $text-4;
|
||||||
|
text-decoration: line-through;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__card-add {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--pill {
|
||||||
|
width: auto;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/pages/home/styles/trust.scss
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.hp__trust {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__trust-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: $primary-lighter;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $primary-dark;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp__trust-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
52
src/pages/menu/composables/menu-page/data-actions.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type {
|
||||||
|
FulfillmentScene,
|
||||||
|
MiniCategory,
|
||||||
|
MiniMenuSection
|
||||||
|
} from '@/shared'
|
||||||
|
import { getCategories, getMenu } from '@/services'
|
||||||
|
import type { useAppStore } from '@/stores'
|
||||||
|
|
||||||
|
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||||
|
|
||||||
|
export function createMenuDataActions (payload: {
|
||||||
|
appStore: AppStoreInstance
|
||||||
|
categories: Ref<MiniCategory[]>
|
||||||
|
errorMessage: Ref<string>
|
||||||
|
loading: Ref<boolean>
|
||||||
|
sections: Ref<MiniMenuSection[]>
|
||||||
|
}) {
|
||||||
|
const { appStore, categories, errorMessage, loading, sections } = payload
|
||||||
|
|
||||||
|
async function loadMenu () {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await appStore.initBootstrap()
|
||||||
|
await appStore.initStores()
|
||||||
|
|
||||||
|
const [nextCategories, nextSections] = await Promise.all([
|
||||||
|
getCategories(appStore.currentStore.id, appStore.scene, appStore.channel),
|
||||||
|
getMenu(appStore.currentStore.id, appStore.scene, appStore.channel)
|
||||||
|
])
|
||||||
|
|
||||||
|
categories.value = nextCategories
|
||||||
|
sections.value = nextSections
|
||||||
|
} catch (error: unknown) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '菜单加载失败,请检查接口是否可用'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSceneChange (value: string) {
|
||||||
|
appStore.setScene(value as FulfillmentScene)
|
||||||
|
await loadMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSceneChange,
|
||||||
|
loadMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/pages/menu/composables/menu-page/detail-actions.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import type {
|
||||||
|
MiniProductCard,
|
||||||
|
MiniProductDetail,
|
||||||
|
MiniProductOption,
|
||||||
|
MiniProductOptionGroup
|
||||||
|
} from '@/shared'
|
||||||
|
import { showToast } from '@tarojs/taro'
|
||||||
|
import { getProductDetail } from '@/services'
|
||||||
|
import type { useAppStore, useCartStore } from '@/stores'
|
||||||
|
import {
|
||||||
|
buildSelectedNames,
|
||||||
|
resolveSelectionError,
|
||||||
|
resolveSkuId
|
||||||
|
} from './selection-helpers'
|
||||||
|
|
||||||
|
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||||
|
type CartStoreInstance = ReturnType<typeof useCartStore>
|
||||||
|
|
||||||
|
export function createMenuDetailActions (payload: {
|
||||||
|
activeDetail: Ref<MiniProductDetail | null>
|
||||||
|
addonSelections: Ref<Record<string, string[]>>
|
||||||
|
appStore: AppStoreInstance
|
||||||
|
canAddCurrentDetail: ComputedRef<boolean>
|
||||||
|
cartStore: CartStoreInstance
|
||||||
|
closeDetail: () => void
|
||||||
|
currentDetailPrice: ComputedRef<number>
|
||||||
|
currentSkuId: Ref<string>
|
||||||
|
detailQuantity: Ref<number>
|
||||||
|
detailVisible: Ref<boolean>
|
||||||
|
specSelections: Ref<Record<string, string[]>>
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
activeDetail,
|
||||||
|
addonSelections,
|
||||||
|
appStore,
|
||||||
|
canAddCurrentDetail,
|
||||||
|
cartStore,
|
||||||
|
closeDetail,
|
||||||
|
currentDetailPrice,
|
||||||
|
currentSkuId,
|
||||||
|
detailQuantity,
|
||||||
|
detailVisible,
|
||||||
|
specSelections
|
||||||
|
} = payload
|
||||||
|
|
||||||
|
async function openProductDetail (productId: string) {
|
||||||
|
try {
|
||||||
|
const detail = await getProductDetail(productId, appStore.scene, appStore.channel)
|
||||||
|
activeDetail.value = detail
|
||||||
|
detailQuantity.value = 1
|
||||||
|
|
||||||
|
const nextSpecSelections: Record<string, string[]> = {}
|
||||||
|
const nextAddonSelections: Record<string, string[]> = {}
|
||||||
|
const defaultSku = detail.skus.find((sku) => sku.id === detail.defaultSkuId) || detail.skus[0]
|
||||||
|
|
||||||
|
detail.optionGroups.forEach((group) => {
|
||||||
|
if (group.groupType === 'addon') {
|
||||||
|
const defaults = group.required
|
||||||
|
? group.options
|
||||||
|
.filter((option) => !option.soldOut)
|
||||||
|
.slice(0, Math.max(group.minSelect, 1))
|
||||||
|
.map((option) => option.id)
|
||||||
|
: []
|
||||||
|
nextAddonSelections[group.id] = defaults
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = group.options.find((option) => defaultSku?.selectedOptionIds.includes(option.id))
|
||||||
|
|| group.options.find((option) => !option.soldOut)
|
||||||
|
nextSpecSelections[group.id] = matched ? [matched.id] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
specSelections.value = nextSpecSelections
|
||||||
|
addonSelections.value = nextAddonSelections
|
||||||
|
currentSkuId.value = detail.skus.length ? resolveSkuId(detail, nextSpecSelections) : ''
|
||||||
|
detailVisible.value = true
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await showToast({
|
||||||
|
title: error instanceof Error ? error.message : '商品详情加载失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOption (group: MiniProductOptionGroup, option: MiniProductOption) {
|
||||||
|
if (option.soldOut) return
|
||||||
|
|
||||||
|
if (group.groupType === 'addon') {
|
||||||
|
const current = addonSelections.value[group.id] || []
|
||||||
|
|
||||||
|
if (group.selectionType === 'single') {
|
||||||
|
addonSelections.value = { ...addonSelections.value, [group.id]: [option.id] }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelected = current.includes(option.id)
|
||||||
|
const next = hasSelected
|
||||||
|
? current.filter((id) => id !== option.id)
|
||||||
|
: [...current, option.id]
|
||||||
|
addonSelections.value = { ...addonSelections.value, [group.id]: next }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
specSelections.value = { ...specSelections.value, [group.id]: [option.id] }
|
||||||
|
if (activeDetail.value) {
|
||||||
|
currentSkuId.value = resolveSkuId(activeDetail.value, specSelections.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDetailQuantity (delta: number) {
|
||||||
|
detailQuantity.value = Math.max(1, detailQuantity.value + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAddCurrentDetail () {
|
||||||
|
if (!activeDetail.value || !canAddCurrentDetail.value) {
|
||||||
|
const message = resolveSelectionError({
|
||||||
|
activeDetail: activeDetail.value,
|
||||||
|
addonSelections: addonSelections.value,
|
||||||
|
currentSkuId: currentSkuId.value,
|
||||||
|
specSelections: specSelections.value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
await showToast({ title: message, icon: 'none' })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const specNames = buildSelectedNames(
|
||||||
|
activeDetail.value.optionGroups.filter((group) => group.groupType !== 'addon'),
|
||||||
|
specSelections.value
|
||||||
|
)
|
||||||
|
const addonNames = buildSelectedNames(
|
||||||
|
activeDetail.value.optionGroups.filter((group) => group.groupType === 'addon'),
|
||||||
|
addonSelections.value
|
||||||
|
)
|
||||||
|
const addonIds = activeDetail.value.optionGroups
|
||||||
|
.filter((group) => group.groupType === 'addon')
|
||||||
|
.flatMap((group) => addonSelections.value[group.id] || [])
|
||||||
|
|
||||||
|
cartStore.addItem({
|
||||||
|
productId: activeDetail.value.id,
|
||||||
|
name: activeDetail.value.name,
|
||||||
|
unitPrice: currentDetailPrice.value / detailQuantity.value,
|
||||||
|
quantity: detailQuantity.value,
|
||||||
|
skuId: currentSkuId.value || undefined,
|
||||||
|
skuName: specNames.join('/'),
|
||||||
|
addonItemIds: addonIds,
|
||||||
|
addonNames,
|
||||||
|
coverImageUrl: activeDetail.value.coverImageUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
await showToast({ title: '已加入购物车', icon: 'success' })
|
||||||
|
closeDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProductAction (product: MiniProductCard) {
|
||||||
|
if (product.hasOptions) {
|
||||||
|
await openProductDetail(product.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cartStore.addItem({
|
||||||
|
productId: product.id,
|
||||||
|
name: product.name,
|
||||||
|
unitPrice: product.price,
|
||||||
|
quantity: 1,
|
||||||
|
coverImageUrl: product.coverImageUrl,
|
||||||
|
addonItemIds: [],
|
||||||
|
addonNames: []
|
||||||
|
})
|
||||||
|
|
||||||
|
await showToast({ title: '已加入购物车', icon: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeDetailQuantity,
|
||||||
|
confirmAddCurrentDetail,
|
||||||
|
handleProductAction,
|
||||||
|
toggleOption
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/pages/menu/composables/menu-page/selection-helpers.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
MiniProductDetail,
|
||||||
|
MiniProductOptionGroup
|
||||||
|
} from '@/shared'
|
||||||
|
|
||||||
|
export function getSelectedIds (
|
||||||
|
addonSelections: Record<string, string[]>,
|
||||||
|
specSelections: Record<string, string[]>,
|
||||||
|
groupId: string
|
||||||
|
) {
|
||||||
|
return addonSelections[groupId] || specSelections[groupId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSkuId (
|
||||||
|
detail: MiniProductDetail,
|
||||||
|
nextSelections: Record<string, string[]>
|
||||||
|
) {
|
||||||
|
const selectedOptionIds = detail.optionGroups
|
||||||
|
.filter((group) => group.groupType !== 'addon')
|
||||||
|
.flatMap((group) => nextSelections[group.id] || [])
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
if (!detail.skus.length) return ''
|
||||||
|
|
||||||
|
const matchedSku = detail.skus.find((sku) =>
|
||||||
|
[...sku.selectedOptionIds].sort().join('|') === selectedOptionIds.join('|')
|
||||||
|
)
|
||||||
|
|
||||||
|
return matchedSku?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectionError (payload: {
|
||||||
|
activeDetail: MiniProductDetail | null
|
||||||
|
addonSelections: Record<string, string[]>
|
||||||
|
currentSkuId: string
|
||||||
|
specSelections: Record<string, string[]>
|
||||||
|
}) {
|
||||||
|
const { activeDetail, addonSelections, currentSkuId, specSelections } = payload
|
||||||
|
if (!activeDetail) return ''
|
||||||
|
|
||||||
|
for (const group of activeDetail.optionGroups) {
|
||||||
|
const selectedCount = getSelectedIds(addonSelections, specSelections, group.id).length
|
||||||
|
const requiredCount = group.required ? Math.max(group.minSelect, 1) : group.minSelect
|
||||||
|
|
||||||
|
if (requiredCount > 0 && selectedCount < requiredCount) return `请选择${group.name}`
|
||||||
|
if (group.selectionType === 'single' && selectedCount > 1) return `${group.name}只能选择一项`
|
||||||
|
if (group.maxSelect > 0 && selectedCount > group.maxSelect) return `${group.name}超出可选上限`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDetail.skus.length && !currentSkuId) return '请先选择完整规格'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectedNames (
|
||||||
|
groups: MiniProductOptionGroup[],
|
||||||
|
source: Record<string, string[]>
|
||||||
|
) {
|
||||||
|
return groups
|
||||||
|
.flatMap((group) => group.options.filter((option) => (source[group.id] || []).includes(option.id)))
|
||||||
|
.map((option) => option.name)
|
||||||
|
}
|
||||||
177
src/pages/menu/composables/useMenuPage.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/pages/menu/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '点餐',
|
||||||
|
usingComponents: {}
|
||||||
|
})
|
||||||
169
src/pages/menu/index.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<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 { useTabBarSelection } from '@/utils/useTabBarSelection'
|
||||||
|
import { useMenuPage } from './composables/useMenuPage'
|
||||||
|
|
||||||
|
useTabBarSelection(1)
|
||||||
|
|
||||||
|
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">
|
||||||
|
@use './styles/index.scss';
|
||||||
|
</style>
|
||||||
185
src/pages/menu/styles/base.scss
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.menu-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 14px 16px 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-card {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-icon-wrap {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $primary-light;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-icon-emoji {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-name-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary;
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-addr {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-3;
|
||||||
|
margin-top: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__store-switch {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__chevron {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__scene-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-md;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: $shadow-xs;
|
||||||
|
border: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__scene-tab {
|
||||||
|
flex: 1;
|
||||||
|
height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-3;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 10px rgba(22, 163, 74, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__category-scroll {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__category-list {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__category-pill {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $card;
|
||||||
|
border: 1px solid $border;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__loading,
|
||||||
|
.menu-page__error {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__loading-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__error-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__error-btn {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
48
src/pages/menu/styles/cart.scss
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.menu-page__cart-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(15, 23, 42, 0.96);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__cart-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__cart-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__cart-price {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__cart-btn {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
3
src/pages/menu/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@forward './base.scss';
|
||||||
|
@forward './sections.scss';
|
||||||
|
@forward './cart.scss';
|
||||||
30
src/pages/menu/styles/sections.scss
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.menu-page__section {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__section-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-page__product-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { showToast } from '@tarojs/taro'
|
||||||
|
import { getOrderDetail, mockPayOrder } from '@/services'
|
||||||
|
import type { OrderDetail } from '@/shared'
|
||||||
|
import { openRoute } from '@/utils/router'
|
||||||
|
|
||||||
|
export function createOrderDetailActions (payload: {
|
||||||
|
loading: Ref<boolean>
|
||||||
|
order: Ref<OrderDetail | null>
|
||||||
|
orderId: Ref<string>
|
||||||
|
}) {
|
||||||
|
const { loading, order, orderId } = payload
|
||||||
|
|
||||||
|
async function loadDetail () {
|
||||||
|
if (!orderId.value) {
|
||||||
|
order.value = null
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
order.value = await getOrderDetail(orderId.value)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrimaryAction () {
|
||||||
|
if (!order.value) return
|
||||||
|
|
||||||
|
if (order.value.actionText === '再来一单') {
|
||||||
|
void openRoute('/pages/menu/index')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.value.actionText === '去支付') {
|
||||||
|
await mockPayOrder(order.value.id)
|
||||||
|
await showToast({ title: '支付成功', icon: 'success' })
|
||||||
|
await loadDetail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await showToast({ title: '当前订单处理中', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContact () {
|
||||||
|
void showToast({ title: '拨号功能暂不可用', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goOrders () {
|
||||||
|
void openRoute('/pages/orders/index')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
goOrders,
|
||||||
|
handleContact,
|
||||||
|
handlePrimaryAction,
|
||||||
|
loadDetail
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { FulfillmentScenes, formatPrice as formatPriceUtil, SCENE_LABEL_MAP } from '@/shared'
|
||||||
|
import type { FulfillmentScene, OrderDetail } from '@/shared'
|
||||||
|
|
||||||
|
export function formatPrice (value: number) {
|
||||||
|
return formatPriceUtil(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSceneLabel (scene: FulfillmentScene) {
|
||||||
|
return SCENE_LABEL_MAP[scene]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOrderSubtitle (order: OrderDetail | null) {
|
||||||
|
if (!order) return ''
|
||||||
|
if (order.tableNo) return `桌号 ${order.tableNo}`
|
||||||
|
return order.customerName
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStatusDescription (order: OrderDetail | null) {
|
||||||
|
if (!order) return ''
|
||||||
|
|
||||||
|
const statusText = order.statusText
|
||||||
|
if (statusText === '待支付') return '请尽快完成支付'
|
||||||
|
if (statusText === '已接单' || statusText === '制作中') return '商家已接单,正在为您精心准备餐品'
|
||||||
|
if (statusText === '配送中') return '骑手正在为您配送'
|
||||||
|
if (statusText === '已完成') return '订单已完成,欢迎再次光临'
|
||||||
|
return '订单处理中'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFulfillmentHint (order: OrderDetail | null) {
|
||||||
|
if (!order) return ''
|
||||||
|
if (order.scene === FulfillmentScenes.DineIn) return '餐品制作完成后将尽快为您上桌,请留意取餐提醒'
|
||||||
|
if (order.scene === FulfillmentScenes.Pickup) return '餐品制作完成后请到店取餐'
|
||||||
|
return '预计 30 分钟送达'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineNodeClass (index: number, currentTimelineIndex: number) {
|
||||||
|
if (index < currentTimelineIndex) return 'od-page__tl-node--done'
|
||||||
|
if (index === currentTimelineIndex) return 'od-page__tl-node--current'
|
||||||
|
return 'od-page__tl-node--pending'
|
||||||
|
}
|
||||||
64
src/pages/order/detail/composables/useOrderDetailPage.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useDidShow, useLoad } from '@tarojs/taro'
|
||||||
|
import { pinia, useAppStore } from '@/stores'
|
||||||
|
import type { OrderDetail } from '@/shared'
|
||||||
|
import { createOrderDetailActions } from './order-detail-page/actions'
|
||||||
|
import {
|
||||||
|
formatPrice,
|
||||||
|
resolveFulfillmentHint,
|
||||||
|
resolveOrderSubtitle,
|
||||||
|
resolveSceneLabel,
|
||||||
|
resolveStatusDescription,
|
||||||
|
resolveTimelineNodeClass
|
||||||
|
} from './order-detail-page/status-helpers'
|
||||||
|
|
||||||
|
export function useOrderDetailPage () {
|
||||||
|
const appStore = useAppStore(pinia)
|
||||||
|
const orderId = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const order = ref<OrderDetail | null>(null)
|
||||||
|
|
||||||
|
const { goOrders, handleContact, handlePrimaryAction, loadDetail } = createOrderDetailActions({
|
||||||
|
loading,
|
||||||
|
order,
|
||||||
|
orderId
|
||||||
|
})
|
||||||
|
|
||||||
|
const sceneLabel = computed(() => order.value ? resolveSceneLabel(order.value.scene) : '')
|
||||||
|
const orderSubtitle = computed(() => resolveOrderSubtitle(order.value))
|
||||||
|
const statusDescription = computed(() => resolveStatusDescription(order.value))
|
||||||
|
const storeAddress = computed(() => appStore.currentStore.address)
|
||||||
|
const fulfillmentHint = computed(() => resolveFulfillmentHint(order.value))
|
||||||
|
const currentTimelineIndex = computed(() => {
|
||||||
|
if (!order.value) return 0
|
||||||
|
return Math.max(order.value.timeline.length - 1, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function timelineNodeClass (index: number) {
|
||||||
|
return resolveTimelineNodeClass(index, currentTimelineIndex.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
useLoad((options) => {
|
||||||
|
orderId.value = options?.id || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
void loadDetail()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTimelineIndex,
|
||||||
|
fulfillmentHint,
|
||||||
|
formatPrice,
|
||||||
|
goOrders,
|
||||||
|
handleContact,
|
||||||
|
handlePrimaryAction,
|
||||||
|
loading,
|
||||||
|
order,
|
||||||
|
orderSubtitle,
|
||||||
|
sceneLabel,
|
||||||
|
statusDescription,
|
||||||
|
storeAddress,
|
||||||
|
timelineNodeClass
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/pages/order/detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '订单详情'
|
||||||
|
})
|
||||||
212
src/pages/order/detail/index.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<view class="od-page">
|
||||||
|
<!-- Loading -->
|
||||||
|
<view v-if="loading" class="od-page__loading">
|
||||||
|
<text class="od-page__loading-text">订单加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else-if="order">
|
||||||
|
<!-- Status Hero -->
|
||||||
|
<view class="od-page__status-hero">
|
||||||
|
<view class="od-page__status-deco1" />
|
||||||
|
<view class="od-page__status-deco2" />
|
||||||
|
<view class="od-page__status-row">
|
||||||
|
<view class="od-page__status-icon">
|
||||||
|
<text class="od-page__status-icon-text">🕐</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__status-text">
|
||||||
|
<text class="od-page__status-title">{{ order.statusText }}</text>
|
||||||
|
<text class="od-page__status-desc">{{ statusDescription }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__status-badge">
|
||||||
|
<text>{{ sceneLabel }} · {{ orderSubtitle }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Store Card -->
|
||||||
|
<view class="od-page__card">
|
||||||
|
<view class="od-page__store">
|
||||||
|
<view class="od-page__store-icon">
|
||||||
|
<text>🏠</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__store-info">
|
||||||
|
<text class="od-page__store-name">{{ order.storeName }}</text>
|
||||||
|
<text class="od-page__store-addr">{{ storeAddress }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__store-call">
|
||||||
|
<text>📞</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Fulfillment Info -->
|
||||||
|
<view class="od-page__card">
|
||||||
|
<view class="od-page__fulfill">
|
||||||
|
<view v-if="order.tableNo" class="od-page__fulfill-row">
|
||||||
|
<text class="od-page__fulfill-label">桌号</text>
|
||||||
|
<view class="od-page__table-badge">
|
||||||
|
<text>{{ order.tableNo }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__fulfill-row">
|
||||||
|
<text class="od-page__fulfill-label">联系人</text>
|
||||||
|
<text class="od-page__fulfill-value">{{ order.customerName }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__fulfill-row">
|
||||||
|
<text class="od-page__fulfill-label">手机号</text>
|
||||||
|
<text class="od-page__fulfill-value">{{ order.customerPhone }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__fulfill-hint">
|
||||||
|
<text>🛡</text>
|
||||||
|
<text class="od-page__fulfill-hint-text">{{ fulfillmentHint }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<view v-if="order.timeline.length" class="od-page__card">
|
||||||
|
<view class="od-page__timeline-section">
|
||||||
|
<text class="od-page__section-title">📋 订单进度</text>
|
||||||
|
<view class="od-page__timeline">
|
||||||
|
<view
|
||||||
|
v-for="(node, index) in order.timeline"
|
||||||
|
:key="index"
|
||||||
|
class="od-page__tl-node"
|
||||||
|
:class="timelineNodeClass(index)"
|
||||||
|
>
|
||||||
|
<view class="od-page__tl-dot">
|
||||||
|
<text v-if="index < currentTimelineIndex" class="od-page__tl-check">✓</text>
|
||||||
|
<text v-else-if="index === currentTimelineIndex" class="od-page__tl-pulse">●</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__tl-content">
|
||||||
|
<text class="od-page__tl-title">{{ node.statusText }}</text>
|
||||||
|
<text class="od-page__tl-time">{{ node.occurredAt }}</text>
|
||||||
|
<text v-if="node.notes" class="od-page__tl-sub">{{ node.notes }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Products + Fees -->
|
||||||
|
<view class="od-page__card">
|
||||||
|
<view class="od-page__goods-section">
|
||||||
|
<text class="od-page__section-title">🛍 商品明细</text>
|
||||||
|
<view class="od-page__goods-list">
|
||||||
|
<view v-for="item in order.items" :key="item.id" class="od-page__good">
|
||||||
|
<view class="od-page__good-img-placeholder" />
|
||||||
|
<view class="od-page__good-info">
|
||||||
|
<text class="od-page__good-name">{{ item.productName }}</text>
|
||||||
|
<text v-if="item.skuName" class="od-page__good-spec">{{ item.skuName }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__good-right">
|
||||||
|
<text class="od-page__good-price"><text class="od-page__good-unit">¥</text>{{ item.subTotalText }}</text>
|
||||||
|
<text class="od-page__good-qty">×{{ item.quantity }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="od-page__divider" />
|
||||||
|
|
||||||
|
<view class="od-page__fees">
|
||||||
|
<view class="od-page__fee-row">
|
||||||
|
<text class="od-page__fee-label">商品小计</text>
|
||||||
|
<text class="od-page__fee-value">¥{{ formatPrice(order.itemsAmount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__fee-row">
|
||||||
|
<text class="od-page__fee-label">打包费</text>
|
||||||
|
<text class="od-page__fee-value">¥{{ formatPrice(order.packagingFee) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="order.deliveryFee > 0" class="od-page__fee-row">
|
||||||
|
<text class="od-page__fee-label">配送费</text>
|
||||||
|
<text class="od-page__fee-value">¥{{ formatPrice(order.deliveryFee) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="order.discountAmount > 0" class="od-page__fee-row">
|
||||||
|
<text class="od-page__fee-label">优惠抵扣</text>
|
||||||
|
<text class="od-page__fee-value od-page__fee-value--green">-¥{{ formatPrice(order.discountAmount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__fee-row od-page__fee-row--total">
|
||||||
|
<text class="od-page__fee-label">实付金额</text>
|
||||||
|
<text class="od-page__fee-value od-page__fee-value--total">
|
||||||
|
<text class="od-page__fee-unit">¥</text>{{ formatPrice(order.paidAmount || order.payableAmount) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Order Info -->
|
||||||
|
<view class="od-page__card">
|
||||||
|
<view class="od-page__info-section">
|
||||||
|
<text class="od-page__section-title">📄 订单信息</text>
|
||||||
|
<view class="od-page__info-rows">
|
||||||
|
<view class="od-page__info-row">
|
||||||
|
<text class="od-page__info-label">订单编号</text>
|
||||||
|
<text class="od-page__info-value">{{ order.orderNo }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__info-row">
|
||||||
|
<text class="od-page__info-label">下单时间</text>
|
||||||
|
<text class="od-page__info-value">{{ order.createdAt }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="order.paidAt" class="od-page__info-row">
|
||||||
|
<text class="od-page__info-label">支付时间</text>
|
||||||
|
<text class="od-page__info-value">{{ order.paidAt }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__info-row">
|
||||||
|
<text class="od-page__info-label">支付状态</text>
|
||||||
|
<text class="od-page__info-value">{{ order.paymentStatusText }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="order.remark" class="od-page__info-row">
|
||||||
|
<text class="od-page__info-label">订单备注</text>
|
||||||
|
<view class="od-page__info-remark">
|
||||||
|
<text>{{ order.remark }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Bottom Bar -->
|
||||||
|
<view class="od-page__bottom-bar">
|
||||||
|
<view class="od-page__btn-outline" @click="handleContact">
|
||||||
|
<text>📞 联系门店</text>
|
||||||
|
</view>
|
||||||
|
<view class="od-page__btn-primary" @click="handlePrimaryAction">
|
||||||
|
<text>{{ order.actionText || '再来一单' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Not found -->
|
||||||
|
<view v-else class="od-page__empty">
|
||||||
|
<text class="od-page__empty-title">未找到对应订单</text>
|
||||||
|
<view class="od-page__empty-btn" @click="goOrders">
|
||||||
|
<text>返回订单列表</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useOrderDetailPage } from './composables/useOrderDetailPage'
|
||||||
|
|
||||||
|
const {
|
||||||
|
fulfillmentHint,
|
||||||
|
formatPrice,
|
||||||
|
goOrders,
|
||||||
|
handleContact,
|
||||||
|
handlePrimaryAction,
|
||||||
|
loading,
|
||||||
|
order,
|
||||||
|
orderSubtitle,
|
||||||
|
sceneLabel,
|
||||||
|
statusDescription,
|
||||||
|
storeAddress,
|
||||||
|
timelineNodeClass
|
||||||
|
} = useOrderDetailPage()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use './styles/index.scss';
|
||||||
|
</style>
|
||||||
229
src/pages/order/detail/styles/base.scss
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
@use '../../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.od-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 14px 16px 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__loading,
|
||||||
|
.od-page__empty {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__loading-text,
|
||||||
|
.od-page__empty-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-hero {
|
||||||
|
background: linear-gradient(135deg, $primary 0%, $primary-dark 60%, $primary-darker 100%);
|
||||||
|
border-radius: $r-xl;
|
||||||
|
padding: 24px 22px 22px;
|
||||||
|
color: #fff;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(22, 163, 74, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-deco1,
|
||||||
|
.od-page__status-deco2 {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-deco1 {
|
||||||
|
top: -30px;
|
||||||
|
right: -30px;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-deco2 {
|
||||||
|
bottom: -20px;
|
||||||
|
left: 40%;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-icon-text {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__status-badge {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__card {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-xl;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__store {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__store-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $primary-light;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__store-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__store-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__store-addr {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__store-call {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid $border;
|
||||||
|
background: $card;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fulfill {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fulfill-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fulfill-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-4;
|
||||||
|
width: 58px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fulfill-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__table-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $primary-lighter;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fulfill-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: $primary-lighter;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $primary-dark;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fulfill-hint-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $primary-dark;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
56
src/pages/order/detail/styles/bottom.scss
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
@use '../../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.od-page__bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: $card;
|
||||||
|
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 10px 20px 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__btn-outline,
|
||||||
|
.od-page__btn-primary,
|
||||||
|
.od-page__empty-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__btn-outline,
|
||||||
|
.od-page__btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__btn-outline {
|
||||||
|
border: 1.5px solid $border;
|
||||||
|
background: $card;
|
||||||
|
color: $text-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__btn-primary {
|
||||||
|
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||||
|
box-shadow: 0 3px 12px rgba(22, 163, 74, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__empty-btn {
|
||||||
|
margin-top: 16px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 19px;
|
||||||
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
124
src/pages/order/detail/styles/goods.scss
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
@use '../../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.od-page__goods-section {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__goods-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 0.5px solid $border;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-img-placeholder {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $border;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-spec {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-price {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-unit {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__good-qty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__divider {
|
||||||
|
height: 0.5px;
|
||||||
|
background: $border;
|
||||||
|
margin: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fees {
|
||||||
|
padding: 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fee-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 0;
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 0.5px solid $border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fee-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-3;
|
||||||
|
|
||||||
|
.od-page__fee-row--total & {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fee-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-2;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--green { color: $primary; }
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__fee-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
4
src/pages/order/detail/styles/index.scss
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@forward './base.scss';
|
||||||
|
@forward './timeline.scss';
|
||||||
|
@forward './goods.scss';
|
||||||
|
@forward './bottom.scss';
|
||||||
148
src/pages/order/detail/styles/timeline.scss
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
@use '../../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.od-page__timeline-section,
|
||||||
|
.od-page__info-section {
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 28px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 9px;
|
||||||
|
top: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(180deg, $primary 0%, $primary-light 60%, $border 100%);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-node {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child { padding-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -28px;
|
||||||
|
top: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.od-page__tl-node--done & {
|
||||||
|
background: $primary;
|
||||||
|
box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-node--current & {
|
||||||
|
background: $primary;
|
||||||
|
box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.15), 0 0 0 10px rgba(22, 163, 74, 0.06);
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-node--pending & {
|
||||||
|
background: $card;
|
||||||
|
border: 2px solid $text-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-check,
|
||||||
|
.od-page__tl-pulse {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-check {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-pulse {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-1;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
.od-page__tl-node--pending & { color: $text-5; font-weight: 500; }
|
||||||
|
.od-page__tl-node--current & { color: $primary-dark; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-4;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.od-page__tl-node--pending & { color: $text-5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__tl-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-3;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
.od-page__tl-node--current & { color: $primary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__info-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__info-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-4;
|
||||||
|
width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__info-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-2;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.od-page__info-remark {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-2;
|
||||||
|
background: $bg;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: $r-xs;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
95
src/pages/orders/composables/useOrdersPage.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/pages/orders/index.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '订单',
|
||||||
|
usingComponents: {}
|
||||||
|
})
|
||||||