refactor: 拆分小程序 vue 结构
This commit is contained in:
12
.editorconfig
Normal file
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
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
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
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
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
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
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
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`,只保留模板、薄胶水脚本和 `@import './styles/index.scss'`;业务加载、计算、提交流程统一下沉到 `composables/useXxxPage.ts`。
|
||||
- 复杂页面必须继续二级拆分到 `src/pages/**/composables/<page-key>/`,至少按 `helpers.ts`、`*actions.ts`、`constants.ts` 这类职责拆开,不要把大段流程重新塞回 `useXxxPage.ts`。
|
||||
- 页面样式固定放在 `src/pages/**/styles/`,`styles/index.scss` 只负责聚合;当样式超过 150 行或已经出现多个视觉域时,必须拆成 `base.scss`、`card.scss`、`submit.scss` 这类分片。
|
||||
- 根组件固定使用目录入口:`src/components/<kebab-name>/index.vue`,并通过 `@/components/<kebab-name>/index.vue` 引用,不再新增平铺的 `src/components/*.vue`。
|
||||
- 组件逻辑放在同目录的 `useXxx.ts`;由于 Vue 宏限制,`defineProps` / `defineEmits` 可以留在 `index.vue`,但计算、事件、映射、辅助函数必须下沉到 `useXxx.ts`。
|
||||
- 当组件样式简单时允许使用同级 `styles.scss`;当组件样式超过 150 行或明显分区时,升级为 `styles/index.scss + 分片.scss`。
|
||||
- 新增页面或组件时,优先复用现有拆分模式;不要再创建“模板 + 大段脚本 + 大段样式”三段混写的单文件组件。
|
||||
|
||||
## 修改约定
|
||||
- 新增页面后,必须同步注册 `src/app.config.ts` 路由。
|
||||
- 新增接口后,优先补在 `src/services/mini.ts` 或对应领域文件,不要直接写进页面。
|
||||
- 如果修改了目录结构、命令或工程规范,要同步更新 `README.md`。
|
||||
52
README.md
Normal file
52
README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# TakeoutSaaS C-Side Mini Program (Taro)
|
||||
|
||||
基于官方 `Taro CLI` 初始化的微信小程序前端,当前使用 `Taro 4 + Vue3 + Pinia + NutUI Taro + Vite`。
|
||||
|
||||
## 当前状态
|
||||
- 已切换到 `Taro + Vue3 + NutUI` 方案。
|
||||
- 已接入真实 Mini API 基址:`https://api-mini-dev.laosankeji.com/api/mini/v1`
|
||||
- 已默认注入租户头:`X-Tenant-Code: t1770086772899`
|
||||
- 当前真实接口:`bootstrap`、`health`
|
||||
- 业务接口:默认仍走 mock,方便先完成前端主流程联调
|
||||
|
||||
## 本地开发
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev:weapp
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
```bash
|
||||
pnpm dev:weapp
|
||||
pnpm build:weapp
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
- `.env.development`
|
||||
- `.env.production`
|
||||
|
||||
当前关键变量:
|
||||
- `TARO_APP_API_BASE_URL`
|
||||
- `TARO_APP_USE_MOCK`
|
||||
- `TARO_APP_REQUEST_TIMEOUT`
|
||||
- `TARO_APP_TENANT_CODE`
|
||||
|
||||
## 目录结构
|
||||
- `src/pages`:按页面域拆分,每个页面保持 `index.vue + composables + styles`
|
||||
- `src/components`:复用组件目录,统一使用 `src/components/<kebab-name>/index.vue`
|
||||
- `src/stores`:`app`、`cart`
|
||||
- `src/services`:请求封装与 Mini API 领域接口
|
||||
- `src/shared`:类型、常量、mock 数据、格式化函数
|
||||
|
||||
当前拆分约定:
|
||||
- 页面入口 `src/pages/**/index.vue` 只保留模板和薄脚本,逻辑下沉到 `composables/useXxxPage.ts`
|
||||
- 复杂页面继续在 `composables/<page-key>/` 下拆 `helpers.ts`、`*actions.ts`
|
||||
- 页面样式统一落在 `styles/index.scss`,必要时按视觉域继续拆分
|
||||
- 根组件统一改为目录入口,例如 `src/components/page-hero/index.vue`
|
||||
|
||||
## 联调说明
|
||||
- `bootstrap` 和 `health` 走真实接口。
|
||||
- 当前其余业务页面仍通过 `src/shared/mock` 数据驱动。
|
||||
- 后端业务接口上线后,只需要在 `src/services/mini.ts` 中切换真实请求即可。
|
||||
11
babel.config.js
Normal file
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
1
commitlint.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export default { extends: ["@commitlint/config-conventional"] };
|
||||
5
config/dev.ts
Normal file
5
config/dev.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { UserConfigExport } from '@tarojs/cli'
|
||||
|
||||
export default {
|
||||
mini: {}
|
||||
} satisfies UserConfigExport<'vite'>
|
||||
65
config/index.ts
Normal file
65
config/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig, type UserConfigExport } from '@tarojs/cli'
|
||||
|
||||
import devConfig from './dev'
|
||||
import prodConfig from './prod'
|
||||
|
||||
const appEnv = process.env.NODE_ENV || 'development'
|
||||
const apiBaseUrl = process.env.TARO_APP_API_BASE_URL || 'https://api-mini-dev.laosankeji.com/api/mini/v1'
|
||||
const useMock = String(process.env.TARO_APP_USE_MOCK || 'true') === 'true'
|
||||
const requestTimeout = Number(process.env.TARO_APP_REQUEST_TIMEOUT || 10000)
|
||||
const tenantCode = process.env.TARO_APP_TENANT_CODE || 't1770086772899'
|
||||
|
||||
export default defineConfig<'vite'>(async (merge) => {
|
||||
const baseConfig: UserConfigExport<'vite'> = {
|
||||
projectName: 'TakeoutSaaS.C-Side-Mini-Program-V1',
|
||||
date: '2026-3-9',
|
||||
designWidth: 375,
|
||||
deviceRatio: {
|
||||
640: 2.34 / 2,
|
||||
750: 1,
|
||||
375: 2,
|
||||
828: 1.81 / 2
|
||||
},
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '..', 'src')
|
||||
},
|
||||
plugins: ['@tarojs/plugin-html'],
|
||||
defineConstants: {
|
||||
__APP_ENV__: JSON.stringify(appEnv),
|
||||
__API_BASE_URL__: JSON.stringify(apiBaseUrl),
|
||||
__USE_MOCK__: JSON.stringify(useMock),
|
||||
__REQUEST_TIMEOUT__: JSON.stringify(requestTimeout),
|
||||
__TENANT_CODE__: JSON.stringify(tenantCode)
|
||||
},
|
||||
copy: {
|
||||
patterns: [],
|
||||
options: {}
|
||||
},
|
||||
framework: 'vue3',
|
||||
compiler: 'vite',
|
||||
mini: {
|
||||
postcss: {
|
||||
pxtransform: {
|
||||
enable: true,
|
||||
config: {}
|
||||
},
|
||||
cssModules: {
|
||||
enable: false,
|
||||
config: {
|
||||
namingPattern: 'module',
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return merge({}, baseConfig, devConfig)
|
||||
}
|
||||
|
||||
return merge({}, baseConfig, prodConfig)
|
||||
})
|
||||
5
config/prod.ts
Normal file
5
config/prod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { UserConfigExport } from '@tarojs/cli'
|
||||
|
||||
export default {
|
||||
mini: {}
|
||||
} satisfies UserConfigExport<'vite'>
|
||||
62
package.json
Normal file
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
17373
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
pnpm-workspace.yaml
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
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": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": false,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
}
|
||||
22
project.private.config.json
Normal file
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
|
||||
}
|
||||
}
|
||||
44
src/app.config.ts
Normal file
44
src/app.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/home/index',
|
||||
'pages/menu/index',
|
||||
'pages/orders/index',
|
||||
'pages/profile/index',
|
||||
'pages/store/select/index',
|
||||
'pages/trade/checkout/index',
|
||||
'pages/order/detail/index',
|
||||
'pages/address/index',
|
||||
'pages/trade/success/index',
|
||||
'pages/dinein/confirm/index'
|
||||
],
|
||||
window: {
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundTextStyle: 'light',
|
||||
backgroundColor: '#F8FAFC'
|
||||
},
|
||||
tabBar: {
|
||||
color: '#64748b',
|
||||
selectedColor: '#16a34a',
|
||||
backgroundColor: '#ffffff',
|
||||
borderStyle: 'black',
|
||||
list: [
|
||||
{
|
||||
pagePath: 'pages/home/index',
|
||||
text: '首页'
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/menu/index',
|
||||
text: '点餐'
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/orders/index',
|
||||
text: '订单'
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/profile/index',
|
||||
text: '我的'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
108
src/app.scss
Normal file
108
src/app.scss
Normal file
@@ -0,0 +1,108 @@
|
||||
@import '@nutui/nutui-taro/dist/style.css';
|
||||
@import './styles/variables';
|
||||
|
||||
page {
|
||||
min-height: 100%;
|
||||
background: $bg;
|
||||
color: $text-1;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
scroll-view,
|
||||
image {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.row-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.inline-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.caption-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.value-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
padding: 28px 0 10px;
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
// Field input (used in checkout etc.)
|
||||
.field-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1.5px solid $border;
|
||||
border-radius: $r-md;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
color: $text-1;
|
||||
background: $bg;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-5;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
// Overlay
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 200;
|
||||
}
|
||||
32
src/app.ts
Normal file
32
src/app.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createApp } from 'vue'
|
||||
import './app.scss'
|
||||
import { setRequestHeaderProvider } from '@/services'
|
||||
import { pinia, useAppStore, useCustomerStore } from '@/stores'
|
||||
|
||||
const App = createApp({
|
||||
async onLaunch () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
customerStore.ensureDefaults()
|
||||
setRequestHeaderProvider(() => ({
|
||||
...appStore.requestHeaders,
|
||||
...customerStore.requestHeaders
|
||||
}))
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
},
|
||||
async onShow () {
|
||||
const appStore = useAppStore(pinia)
|
||||
if (appStore.bootstrapStatus === 'idle') {
|
||||
await appStore.initBootstrap()
|
||||
}
|
||||
|
||||
if (appStore.storesStatus === 'idle') {
|
||||
await appStore.initStores()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
App.use(pinia)
|
||||
|
||||
export default App
|
||||
116
src/components/cart-drawer/index.vue
Normal file
116
src/components/cart-drawer/index.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<view v-if="props.visible" class="overlay" @click="emit('close')" />
|
||||
<view v-if="props.visible" class="cart-drawer">
|
||||
<view class="cart-drawer__handle" />
|
||||
|
||||
<template v-if="props.lineList.length">
|
||||
<scroll-view scroll-y class="cart-drawer__scroll">
|
||||
<view class="cart-drawer__header">
|
||||
<view class="cart-drawer__header-left">
|
||||
<view class="cart-drawer__header-icon">
|
||||
<text class="cart-drawer__icon-text">🛒</text>
|
||||
</view>
|
||||
<text class="cart-drawer__title">购物车</text>
|
||||
<text class="cart-drawer__count">{{ props.itemCount }} 件</text>
|
||||
</view>
|
||||
<view class="cart-drawer__clear" @click="emit('clear')">
|
||||
<text>清空</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cart-drawer__list">
|
||||
<view
|
||||
v-for="line in props.lineList"
|
||||
:key="line.lineKey"
|
||||
class="cart-drawer__item"
|
||||
>
|
||||
<view class="cart-drawer__item-info">
|
||||
<text class="cart-drawer__item-name">{{ line.name }}</text>
|
||||
<text v-if="line.specText" class="cart-drawer__item-spec">
|
||||
{{ line.specText }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="cart-drawer__item-right">
|
||||
<text class="cart-drawer__item-price">
|
||||
<text class="cart-drawer__unit">¥</text>{{ line.lineAmountText }}
|
||||
</text>
|
||||
<view class="cart-drawer__stepper">
|
||||
<view
|
||||
class="cart-drawer__stepper-btn"
|
||||
@click="emit('changeQty', line.lineKey, -1)"
|
||||
>
|
||||
<text>-</text>
|
||||
</view>
|
||||
<text class="cart-drawer__stepper-val">{{ line.quantity }}</text>
|
||||
<view
|
||||
class="cart-drawer__stepper-btn"
|
||||
@click="emit('changeQty', line.lineKey, 1)"
|
||||
>
|
||||
<text>+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cart-drawer__amount">
|
||||
<view class="cart-drawer__amount-row">
|
||||
<text class="cart-drawer__amount-label">商品小计</text>
|
||||
<text class="cart-drawer__amount-value">¥{{ props.totalAmountText }}</text>
|
||||
</view>
|
||||
<view class="cart-drawer__amount-row cart-drawer__amount-row--total">
|
||||
<text class="cart-drawer__amount-label">合计</text>
|
||||
<text class="cart-drawer__amount-value cart-drawer__amount-value--total">
|
||||
<text class="cart-drawer__unit">¥</text>{{ props.totalAmountText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="cart-drawer__footer">
|
||||
<view class="cart-drawer__footer-left">
|
||||
<text class="cart-drawer__footer-summary">
|
||||
共 <text class="cart-drawer__footer-num">{{ props.itemCount }}</text> 件
|
||||
</text>
|
||||
<view class="cart-drawer__footer-total">
|
||||
<text class="cart-drawer__footer-unit">¥</text>
|
||||
<text class="cart-drawer__footer-value">{{ props.totalAmountText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="cart-drawer__footer-btn" @click="emit('checkout')">
|
||||
<text>去结算</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="cart-drawer__empty">
|
||||
<text class="cart-drawer__empty-title">购物车还是空的</text>
|
||||
<text class="cart-drawer__empty-desc">先去选几样喜欢的商品吧</text>
|
||||
<view class="cart-drawer__empty-btn" @click="emit('close')">
|
||||
<text>去选购</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CartDrawerLineItem } from './useCartDrawer'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
lineList: CartDrawerLineItem[]
|
||||
itemCount: number
|
||||
totalAmountText: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
clear: []
|
||||
checkout: []
|
||||
changeQty: [lineKey: string, delta: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
79
src/components/cart-drawer/styles/base.scss
Normal file
79
src/components/cart-drawer/styles/base.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 75%;
|
||||
background: $card;
|
||||
border-radius: 28px 28px 0 0;
|
||||
z-index: 210;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.10);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cart-drawer__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: $text-5;
|
||||
margin: 10px auto 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.cart-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 20px 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cart-drawer__header-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cart-drawer__icon-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.cart-drawer__count {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
font-weight: 500;
|
||||
background: $border;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.cart-drawer__clear {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
padding: 8px 0;
|
||||
}
|
||||
38
src/components/cart-drawer/styles/empty.scss
Normal file
38
src/components/cart-drawer/styles/empty.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px 60px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cart-drawer__empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.cart-drawer__empty-desc {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.cart-drawer__empty-btn {
|
||||
margin-top: 20px;
|
||||
height: 38px;
|
||||
padding: 0 24px;
|
||||
border-radius: 19px;
|
||||
border: 1.5px solid $primary;
|
||||
background: transparent;
|
||||
color: $primary;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
63
src/components/cart-drawer/styles/footer.scss
Normal file
63
src/components/cart-drawer/styles/footer.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px 28px;
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-summary {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-num {
|
||||
color: $primary;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cart-drawer__footer-btn {
|
||||
height: 50px;
|
||||
min-width: 130px;
|
||||
padding: 0 32px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
4
src/components/cart-drawer/styles/index.scss
Normal file
4
src/components/cart-drawer/styles/index.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import './base.scss';
|
||||
@import './list.scss';
|
||||
@import './footer.scss';
|
||||
@import './empty.scss';
|
||||
124
src/components/cart-drawer/styles/list.scss
Normal file
124
src/components/cart-drawer/styles/list.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.cart-drawer__list {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.cart-drawer__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 0.5px solid $border;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.cart-drawer__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cart-drawer__item-spec {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cart-drawer__item-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cart-drawer__item-price {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.cart-drawer__unit {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cart-drawer__stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cart-drawer__stepper-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid $text-5;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__stepper-val {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.cart-drawer__amount {
|
||||
margin: 0 20px;
|
||||
padding: 14px 0 6px;
|
||||
border-top: 1px dashed $border;
|
||||
}
|
||||
|
||||
.cart-drawer__amount-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
|
||||
&--total {
|
||||
padding: 10px 0 4px;
|
||||
margin-top: 6px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-drawer__amount-label {
|
||||
font-size: 13px;
|
||||
color: $text-3;
|
||||
|
||||
.cart-drawer__amount-row--total & {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-drawer__amount-value {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
font-weight: 500;
|
||||
|
||||
&--total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
}
|
||||
}
|
||||
8
src/components/cart-drawer/useCartDrawer.ts
Normal file
8
src/components/cart-drawer/useCartDrawer.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { CartLine } from '@/stores/cart'
|
||||
|
||||
export interface CartDrawerLineItem extends CartLine {
|
||||
lineAmount: number
|
||||
lineAmountText: string
|
||||
unitPriceText: string
|
||||
specText: string
|
||||
}
|
||||
30
src/components/page-hero/index.vue
Normal file
30
src/components/page-hero/index.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<view class="page-hero">
|
||||
<view class="page-hero__head">
|
||||
<view class="page-hero__copy">
|
||||
<text class="page-hero__title">{{ props.title }}</text>
|
||||
<text class="page-hero__subtitle">{{ props.subtitle }}</text>
|
||||
</view>
|
||||
<view v-if="props.badge" class="page-hero__badge">{{ props.badge }}</view>
|
||||
</view>
|
||||
<view v-if="hasExtra" class="page-hero__extra">
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageHero } from './usePageHero'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
badge?: string
|
||||
}>()
|
||||
|
||||
const { hasExtra } = usePageHero()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles.scss';
|
||||
</style>
|
||||
46
src/components/page-hero/styles.scss
Normal file
46
src/components/page-hero/styles.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
.page-hero {
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(17, 24, 39, 0.96), rgba(22, 163, 74, 0.92));
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.page-hero__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-hero__copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-hero__title {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.page-hero__subtitle {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.page-hero__badge {
|
||||
flex: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.page-hero__extra {
|
||||
margin-top: 16px;
|
||||
}
|
||||
10
src/components/page-hero/usePageHero.ts
Normal file
10
src/components/page-hero/usePageHero.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
export function usePageHero () {
|
||||
const slots = useSlots()
|
||||
const hasExtra = computed(() => Boolean(slots.default))
|
||||
|
||||
return {
|
||||
hasExtra
|
||||
}
|
||||
}
|
||||
66
src/components/product-card/index.vue
Normal file
66
src/components/product-card/index.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<view class="product-card" @click="emit('select', props.product)">
|
||||
<view class="product-card__img-wrap">
|
||||
<image
|
||||
class="product-card__img"
|
||||
:src="props.product.coverImageUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text
|
||||
v-if="props.product.tagTexts?.length"
|
||||
class="product-card__img-tag"
|
||||
:class="tagClass"
|
||||
>
|
||||
{{ props.product.tagTexts[0] }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="product-card__body">
|
||||
<view>
|
||||
<text class="product-card__name">{{ props.product.name }}</text>
|
||||
<text class="product-card__desc">{{ props.product.description }}</text>
|
||||
<view class="product-card__meta">
|
||||
<text class="product-card__sales">{{ props.product.salesText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="product-card__bottom">
|
||||
<view class="product-card__price">
|
||||
<text class="product-card__price-unit">¥</text>
|
||||
<text class="product-card__price-value">{{ props.product.price }}</text>
|
||||
<text
|
||||
v-if="props.product.originalPriceText"
|
||||
class="product-card__price-origin"
|
||||
>
|
||||
¥{{ props.product.originalPriceText }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="product-card__btn"
|
||||
:class="{ 'product-card__btn--disabled': props.product.soldOut }"
|
||||
@click.stop="emit('action', props.product)"
|
||||
>
|
||||
<text>{{ props.product.hasOptions ? '选规格' : '加入购物车' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MiniProductCard } from '@/shared'
|
||||
import { useProductCard } from './useProductCard'
|
||||
|
||||
const props = defineProps<{
|
||||
product: MiniProductCard
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [product: MiniProductCard]
|
||||
action: [product: MiniProductCard]
|
||||
}>()
|
||||
|
||||
const { tagClass } = useProductCard(props)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles.scss';
|
||||
</style>
|
||||
132
src/components/product-card/styles.scss
Normal file
132
src/components/product-card/styles.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
@import '../../styles/variables';
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-card__img-wrap {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: $r-md;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.product-card__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #FED7AA, #FDBA74);
|
||||
}
|
||||
|
||||
.product-card__img-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
|
||||
&--hot { background: rgba(239, 68, 68, 0.85); }
|
||||
&--new { background: rgba(22, 163, 74, 0.85); }
|
||||
&--sale { background: rgba(245, 158, 11, 0.85); }
|
||||
}
|
||||
|
||||
.product-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-card__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.product-card__desc {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.product-card__sales {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.product-card__bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.product-card__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-card__price-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.product-card__price-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.product-card__price-origin {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
text-decoration: line-through;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.product-card__btn {
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
16
src/components/product-card/useProductCard.ts
Normal file
16
src/components/product-card/useProductCard.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { computed } from 'vue'
|
||||
import type { MiniProductCard } from '@/shared'
|
||||
|
||||
export function useProductCard (props: { product: MiniProductCard }) {
|
||||
const tagClass = computed(() => {
|
||||
const tag = props.product.tagTexts?.[0] || ''
|
||||
if (['招牌', '热卖', '热销'].includes(tag)) return 'product-card__img-tag--hot'
|
||||
if (['新品'].includes(tag)) return 'product-card__img-tag--new'
|
||||
if (['超值', '加购王'].includes(tag)) return 'product-card__img-tag--sale'
|
||||
return 'product-card__img-tag--hot'
|
||||
})
|
||||
|
||||
return {
|
||||
tagClass
|
||||
}
|
||||
}
|
||||
46
src/components/scene-switcher/index.vue
Normal file
46
src/components/scene-switcher/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<view class="scene-switcher">
|
||||
<view
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
class="scene-switcher__item"
|
||||
>
|
||||
<NutButton
|
||||
size="small"
|
||||
:type="option.value === props.modelValue ? 'primary' : 'default'"
|
||||
:plain="option.value !== props.modelValue"
|
||||
@click="handleSelect(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NutButton>
|
||||
<text class="scene-switcher__desc">{{ option.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import { useSceneSwitcher } from './useSceneSwitcher'
|
||||
|
||||
interface SceneOption {
|
||||
label: string
|
||||
value: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
options: SceneOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const { handleSelect } = useSceneSwitcher(emit)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles.scss';
|
||||
</style>
|
||||
17
src/components/scene-switcher/styles.scss
Normal file
17
src/components/scene-switcher/styles.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
.scene-switcher {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scene-switcher__item {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.scene-switcher__desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
15
src/components/scene-switcher/useSceneSwitcher.ts
Normal file
15
src/components/scene-switcher/useSceneSwitcher.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
interface SceneSwitcherEmits {
|
||||
(event: 'change', value: string): void
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
export function useSceneSwitcher (emit: SceneSwitcherEmits) {
|
||||
function handleSelect (value: string) {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
|
||||
return {
|
||||
handleSelect
|
||||
}
|
||||
}
|
||||
153
src/components/spec-popup/index.vue
Normal file
153
src/components/spec-popup/index.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<view v-if="props.visible" class="overlay" @click="emit('close')" />
|
||||
<view v-if="props.visible" class="spec-popup">
|
||||
<view class="spec-popup__handle" />
|
||||
|
||||
<scroll-view scroll-y class="spec-popup__scroll">
|
||||
<view class="spec-popup__header">
|
||||
<image
|
||||
class="spec-popup__img"
|
||||
:src="props.product.coverImageUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="spec-popup__info">
|
||||
<text class="spec-popup__name">{{ props.product.name }}</text>
|
||||
<text class="spec-popup__desc">
|
||||
{{ props.product.subtitle || props.product.description }}
|
||||
</text>
|
||||
<view class="spec-popup__tags">
|
||||
<text
|
||||
v-for="tag in props.product.tagTexts"
|
||||
:key="tag"
|
||||
class="spec-popup__tag"
|
||||
:class="tagClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="spec-popup__price-row">
|
||||
<text class="spec-popup__price-unit">¥</text>
|
||||
<text class="spec-popup__price-current">{{ props.displayUnitPrice }}</text>
|
||||
<text
|
||||
v-if="props.product.originalPriceText"
|
||||
class="spec-popup__price-origin"
|
||||
>
|
||||
¥{{ props.product.originalPriceText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="spec-popup__close" @click="emit('close')">
|
||||
<text>✕</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="group in props.product.optionGroups"
|
||||
:key="group.id"
|
||||
class="spec-popup__group"
|
||||
>
|
||||
<view class="spec-popup__group-header">
|
||||
<text class="spec-popup__group-title">{{ group.name }}</text>
|
||||
<text
|
||||
v-if="group.required"
|
||||
class="spec-popup__group-tag spec-popup__group-tag--required"
|
||||
>
|
||||
必选
|
||||
</text>
|
||||
<text
|
||||
v-else-if="group.selectionType === 'multiple'"
|
||||
class="spec-popup__group-tag spec-popup__group-tag--multi"
|
||||
>
|
||||
可多选
|
||||
</text>
|
||||
</view>
|
||||
<view class="spec-popup__pills">
|
||||
<view
|
||||
v-for="option in group.options"
|
||||
:key="option.id"
|
||||
class="spec-popup__pill"
|
||||
:class="{
|
||||
'spec-popup__pill--selected': props.isSelected(group.id, option.id),
|
||||
'spec-popup__pill--disabled': option.soldOut
|
||||
}"
|
||||
@click="emit('toggleOption', group, option)"
|
||||
>
|
||||
<text>{{ option.name }}</text>
|
||||
<text v-if="option.extraPrice" class="spec-popup__pill-extra">
|
||||
+¥{{ option.extraPriceText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="spec-popup__stepper-row">
|
||||
<text class="spec-popup__stepper-label">数量</text>
|
||||
<view class="spec-popup__stepper">
|
||||
<view
|
||||
class="spec-popup__stepper-btn"
|
||||
:class="{ 'spec-popup__stepper-btn--disabled': props.quantity <= 1 }"
|
||||
@click="emit('changeQty', -1)"
|
||||
>
|
||||
<text>-</text>
|
||||
</view>
|
||||
<text class="spec-popup__stepper-value">{{ props.quantity }}</text>
|
||||
<view class="spec-popup__stepper-btn" @click="emit('changeQty', 1)">
|
||||
<text>+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="spec-popup__footer">
|
||||
<view class="spec-popup__footer-price">
|
||||
<text class="spec-popup__footer-label">合计</text>
|
||||
<view class="spec-popup__footer-total">
|
||||
<text class="spec-popup__footer-unit">¥</text>
|
||||
<text class="spec-popup__footer-value">{{ props.totalPriceText }}</text>
|
||||
</view>
|
||||
<text class="spec-popup__footer-summary">{{ props.summaryText }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="spec-popup__footer-btn"
|
||||
:class="{ 'spec-popup__footer-btn--disabled': !props.canAdd }"
|
||||
@click="props.canAdd && emit('addToCart')"
|
||||
>
|
||||
<text>{{ props.canAdd ? '加入购物车' : props.disabledText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
MiniProductDetail,
|
||||
MiniProductOption,
|
||||
MiniProductOptionGroup
|
||||
} from '@/shared'
|
||||
import { useSpecPopup } from './useSpecPopup'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
product: MiniProductDetail
|
||||
quantity: number
|
||||
displayUnitPrice: number
|
||||
totalPriceText: string
|
||||
summaryText: string
|
||||
canAdd: boolean
|
||||
disabledText: string
|
||||
isSelected: (groupId: string, optionId: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
addToCart: []
|
||||
toggleOption: [group: MiniProductOptionGroup, option: MiniProductOption]
|
||||
changeQty: [delta: number]
|
||||
}>()
|
||||
|
||||
const { tagClass } = useSpecPopup()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
31
src/components/spec-popup/styles/base.scss
Normal file
31
src/components/spec-popup/styles/base.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 82%;
|
||||
background: $card;
|
||||
border-radius: 28px 28px 0 0;
|
||||
z-index: 210;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.10);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spec-popup__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: $text-5;
|
||||
margin: 10px auto 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spec-popup__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
74
src/components/spec-popup/styles/footer.scss
Normal file
74
src/components/spec-popup/styles/footer.scss
Normal file
@@ -0,0 +1,74 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__footer {
|
||||
padding: 14px 20px;
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
background: $card;
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spec-popup__footer-price {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spec-popup__footer-label {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.spec-popup__footer-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.spec-popup__footer-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.spec-popup__footer-value {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spec-popup__footer-summary {
|
||||
font-size: 11px;
|
||||
color: $text-4;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.spec-popup__footer-btn {
|
||||
height: 48px;
|
||||
min-width: 140px;
|
||||
padding: 0 28px;
|
||||
border-radius: 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 4px 14px rgba(22, 163, 74, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&--disabled {
|
||||
background: $text-5;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
104
src/components/spec-popup/styles/header.scss
Normal file
104
src/components/spec-popup/styles/header.scss
Normal file
@@ -0,0 +1,104 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 6px 20px 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spec-popup__img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: $r-md;
|
||||
flex-shrink: 0;
|
||||
box-shadow: $shadow-sm;
|
||||
background: linear-gradient(135deg, #FED7AA, #FDBA74);
|
||||
}
|
||||
|
||||
.spec-popup__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spec-popup__name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.spec-popup__desc {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spec-popup__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spec-popup__tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.5;
|
||||
|
||||
&--hot { background: $red-light; color: #DC2626; }
|
||||
&--new { background: $primary-light; color: $primary; }
|
||||
&--sales { background: $border; color: $text-3; }
|
||||
}
|
||||
|
||||
.spec-popup__price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.spec-popup__price-unit {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.spec-popup__price-current {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spec-popup__price-origin {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.spec-popup__close {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: $border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-4;
|
||||
font-size: 14px;
|
||||
}
|
||||
5
src/components/spec-popup/styles/index.scss
Normal file
5
src/components/spec-popup/styles/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import './base.scss';
|
||||
@import './header.scss';
|
||||
@import './options.scss';
|
||||
@import './stepper.scss';
|
||||
@import './footer.scss';
|
||||
73
src/components/spec-popup/styles/options.scss
Normal file
73
src/components/spec-popup/styles/options.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__group {
|
||||
padding: 16px 20px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
|
||||
.spec-popup__group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.spec-popup__group-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.spec-popup__group-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.6;
|
||||
|
||||
&--required { background: $red-light; color: $red; }
|
||||
&--multi { background: $primary-light; color: $primary; }
|
||||
}
|
||||
|
||||
.spec-popup__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spec-popup__pill {
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-2;
|
||||
background: $bg;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 14px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--selected {
|
||||
background: $primary-lighter;
|
||||
border-color: $primary;
|
||||
color: $primary-darker;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spec-popup__pill-extra {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
font-weight: 400;
|
||||
|
||||
.spec-popup__pill--selected & {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
46
src/components/spec-popup/styles/stepper.scss
Normal file
46
src/components/spec-popup/styles/stepper.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.spec-popup__stepper-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
|
||||
.spec-popup__stepper-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.spec-popup__stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spec-popup__stepper-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid $text-5;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-2;
|
||||
font-size: 16px;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spec-popup__stepper-value {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
11
src/components/spec-popup/useSpecPopup.ts
Normal file
11
src/components/spec-popup/useSpecPopup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function useSpecPopup () {
|
||||
function tagClass (tag: string) {
|
||||
if (['招牌', '热卖', '热销'].includes(tag)) return 'spec-popup__tag--hot'
|
||||
if (['新品'].includes(tag)) return 'spec-popup__tag--new'
|
||||
return 'spec-popup__tag--sales'
|
||||
}
|
||||
|
||||
return {
|
||||
tagClass
|
||||
}
|
||||
}
|
||||
17
src/index.html
Normal file
17
src/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-touch-fullscreen" content="yes">
|
||||
<meta name="format-detection" content="telephone=no,address=no">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
||||
<title>TakeoutSaaS.C-Side-Mini-Program-Taro</title>
|
||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
70
src/pages/address/composables/useAddressPage.ts
Normal file
70
src/pages/address/composables/useAddressPage.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import { navigateBack, showToast } from '@tarojs/taro'
|
||||
import { pinia, useCustomerStore, useFulfillmentStore } from '@/stores'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
function readInputValue (event: InputLikeEvent) {
|
||||
return event.detail?.value || ''
|
||||
}
|
||||
|
||||
export function useAddressPage () {
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||
const name = ref(fulfillmentStore.address?.name || customerStore.name)
|
||||
const phone = ref(fulfillmentStore.address?.phone || customerStore.phone)
|
||||
const address = ref(fulfillmentStore.address?.address || '')
|
||||
const detail = ref(fulfillmentStore.address?.detail || '')
|
||||
|
||||
function handleNameInput (event: InputLikeEvent) {
|
||||
name.value = readInputValue(event)
|
||||
}
|
||||
|
||||
function handlePhoneInput (event: InputLikeEvent) {
|
||||
phone.value = readInputValue(event)
|
||||
}
|
||||
|
||||
function handleAddressInput (event: InputLikeEvent) {
|
||||
address.value = readInputValue(event)
|
||||
}
|
||||
|
||||
function handleDetailInput (event: InputLikeEvent) {
|
||||
detail.value = readInputValue(event)
|
||||
}
|
||||
|
||||
async function handleSave () {
|
||||
if (!name.value.trim() || !phone.value.trim() || !address.value.trim()) {
|
||||
await showToast({ title: '请完善地址信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
fulfillmentStore.setAddress({
|
||||
id: 'local-default',
|
||||
name: name.value.trim(),
|
||||
phone: phone.value.trim(),
|
||||
address: address.value.trim(),
|
||||
detail: detail.value.trim()
|
||||
})
|
||||
|
||||
await showToast({ title: '地址已保存', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
void navigateBack()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
detail,
|
||||
handleAddressInput,
|
||||
handleDetailInput,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
handleSave,
|
||||
name,
|
||||
phone
|
||||
}
|
||||
}
|
||||
3
src/pages/address/index.config.ts
Normal file
3
src/pages/address/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '配送地址'
|
||||
})
|
||||
42
src/pages/address/index.vue
Normal file
42
src/pages/address/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<view class="page-shell address-page">
|
||||
<PageHero title="配送地址" subtitle="填写收货人和配送地址,方便下单时直接使用" />
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">联系人</text>
|
||||
<input class="field-input" :value="name" placeholder="请输入收货人姓名" @input="handleNameInput" />
|
||||
<text class="section-title address-page__title-gap">联系电话</text>
|
||||
<input class="field-input" :value="phone" type="number" placeholder="请输入联系电话" @input="handlePhoneInput" />
|
||||
<text class="section-title address-page__title-gap">地址</text>
|
||||
<input class="field-input" :value="address" placeholder="请输入配送地址" @input="handleAddressInput" />
|
||||
<text class="section-title address-page__title-gap">门牌号 / 详细地址</text>
|
||||
<input class="field-input" :value="detail" placeholder="例如 3号楼 501" @input="handleDetailInput" />
|
||||
<view class="address-page__actions">
|
||||
<NutButton type="primary" block @click="handleSave">保存地址</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useAddressPage } from './composables/useAddressPage'
|
||||
|
||||
const {
|
||||
address,
|
||||
detail,
|
||||
handleAddressInput,
|
||||
handleDetailInput,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
handleSave,
|
||||
name,
|
||||
phone
|
||||
} = useAddressPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
|
||||
7
src/pages/address/styles/index.scss
Normal file
7
src/pages/address/styles/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.address-page__title-gap {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.address-page__actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
37
src/pages/dinein/confirm/composables/useDineInConfirmPage.ts
Normal file
37
src/pages/dinein/confirm/composables/useDineInConfirmPage.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue'
|
||||
import { navigateBack, showToast } from '@tarojs/taro'
|
||||
import { pinia, useFulfillmentStore } from '@/stores'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useDineInConfirmPage () {
|
||||
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||
const tableNo = ref(fulfillmentStore.tableNo)
|
||||
|
||||
function handleInput (event: InputLikeEvent) {
|
||||
tableNo.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
async function handleSave () {
|
||||
if (!tableNo.value.trim()) {
|
||||
await showToast({ title: '请先填写桌号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
fulfillmentStore.setTableNo(tableNo.value)
|
||||
await showToast({ title: '桌号已保存', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
void navigateBack()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
return {
|
||||
handleInput,
|
||||
handleSave,
|
||||
tableNo
|
||||
}
|
||||
}
|
||||
3
src/pages/dinein/confirm/index.config.ts
Normal file
3
src/pages/dinein/confirm/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '确认桌号'
|
||||
})
|
||||
26
src/pages/dinein/confirm/index.vue
Normal file
26
src/pages/dinein/confirm/index.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<view class="page-shell dinein-page">
|
||||
<PageHero title="确认桌号" subtitle="请先确认桌号,方便商家及时送餐" />
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">桌号</text>
|
||||
<input class="field-input" :value="tableNo" placeholder="请输入桌号,例如 A08" @input="handleInput" />
|
||||
<view class="dinein-page__actions">
|
||||
<NutButton type="primary" block @click="handleSave">保存桌号</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useDineInConfirmPage } from './composables/useDineInConfirmPage'
|
||||
|
||||
const { handleInput, handleSave, tableNo } = useDineInConfirmPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
|
||||
3
src/pages/dinein/confirm/styles/index.scss
Normal file
3
src/pages/dinein/confirm/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.dinein-page__actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
62
src/pages/home/composables/useHomePage.ts
Normal file
62
src/pages/home/composables/useHomePage.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useAppStore, useCartStore } from '@/stores'
|
||||
import {
|
||||
FulfillmentScenes,
|
||||
demoHotProducts,
|
||||
type MiniProductCard
|
||||
} from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
const categoryCards = [
|
||||
{ key: 'recommend', icon: '⭐', label: '推荐', toneClass: 'home-page__cat-icon--orange' },
|
||||
{ key: 'meal', icon: '🍚', label: '主食', toneClass: 'home-page__cat-icon--green-soft' },
|
||||
{ key: 'snack', icon: '🍜', label: '小吃', toneClass: 'home-page__cat-icon--yellow', badge: 'HOT' },
|
||||
{ key: 'drink', icon: '🧋', label: '饮品', toneClass: 'home-page__cat-icon--green-light' },
|
||||
{ key: 'set', icon: '📦', label: '套餐', toneClass: 'home-page__cat-icon--mint' },
|
||||
{ key: 'dessert', icon: '🍰', label: '甜品', toneClass: 'home-page__cat-icon--amber' }
|
||||
] as const
|
||||
|
||||
const trustItems = [
|
||||
{ key: 'fresh', icon: '⚡', label: '现炒现做' },
|
||||
{ key: 'fast', icon: '🕐', label: '30分钟送达' },
|
||||
{ key: 'pickup', icon: '🛍', label: '自提更快' },
|
||||
{ key: 'quality', icon: '🛡', label: '品质保证' }
|
||||
] as const
|
||||
|
||||
export function useHomePage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const cartStore = useCartStore(pinia)
|
||||
const currentStore = computed(() => appStore.currentStore)
|
||||
const cartCount = computed(() => cartStore.itemCount)
|
||||
const isDineIn = computed(() => appStore.scene === FulfillmentScenes.DineIn)
|
||||
const recommendedProducts = ref<MiniProductCard[]>(demoHotProducts)
|
||||
|
||||
async function refreshPage () {
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
}
|
||||
|
||||
function goStoreSelect () {
|
||||
void openRoute('/pages/store/select/index')
|
||||
}
|
||||
|
||||
function goMenu () {
|
||||
void openRoute('/pages/menu/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void refreshPage()
|
||||
})
|
||||
|
||||
return {
|
||||
cartCount,
|
||||
categoryCards,
|
||||
currentStore,
|
||||
goMenu,
|
||||
goStoreSelect,
|
||||
isDineIn,
|
||||
recommendedProducts,
|
||||
trustItems
|
||||
}
|
||||
}
|
||||
3
src/pages/home/index.config.ts
Normal file
3
src/pages/home/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
134
src/pages/home/index.vue
Normal file
134
src/pages/home/index.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<!-- Store Card -->
|
||||
<view class="home-page__store-card">
|
||||
<view class="home-page__store-icon-wrap">
|
||||
<text class="home-page__store-icon-emoji">📍</text>
|
||||
</view>
|
||||
<view class="home-page__store-text">
|
||||
<view class="home-page__store-name">
|
||||
<text class="home-page__store-name-text">{{ currentStore.name }}</text>
|
||||
<view class="home-page__store-status">
|
||||
<view class="home-page__store-status-dot" />
|
||||
<text>营业中</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="home-page__store-addr">{{ currentStore.address }}</text>
|
||||
</view>
|
||||
<view class="home-page__store-switch" @click="goStoreSelect">
|
||||
<text>切换</text>
|
||||
<text class="home-page__chevron">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Dine-in Scan Button -->
|
||||
<view v-if="isDineIn" class="home-page__scan-tab" @click="goMenu">
|
||||
<text>🔲</text>
|
||||
<text>堂食扫码点餐</text>
|
||||
</view>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<view class="home-page__search" @click="goMenu">
|
||||
<text class="home-page__search-icon">🔍</text>
|
||||
<text class="home-page__search-placeholder">搜索菜品、套餐、饮品</text>
|
||||
</view>
|
||||
|
||||
<!-- Banner -->
|
||||
<view class="home-page__banner" @click="goMenu">
|
||||
<view class="home-page__banner-content">
|
||||
<view class="home-page__banner-tag">
|
||||
<text>✓ 新客专享</text>
|
||||
</view>
|
||||
<text class="home-page__banner-title">首单立减</text>
|
||||
<view class="home-page__banner-amount">
|
||||
<text class="home-page__banner-unit">¥</text>
|
||||
<text>12</text>
|
||||
</view>
|
||||
<text class="home-page__banner-desc">全场满38再减8 · 限时三天</text>
|
||||
<view class="home-page__banner-cta">
|
||||
<text>立即领取 →</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="home-page__banner-ring-outer" />
|
||||
<view class="home-page__banner-ring" />
|
||||
<image
|
||||
class="home-page__banner-food-img"
|
||||
src="https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=240&h=240&fit=crop&auto=format&q=80"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Categories -->
|
||||
<view class="home-page__categories">
|
||||
<view
|
||||
v-for="category in categoryCards"
|
||||
:key="category.key"
|
||||
class="home-page__cat-item"
|
||||
@click="goMenu"
|
||||
>
|
||||
<view class="home-page__cat-icon" :class="category.toneClass">
|
||||
<text>{{ category.icon }}</text>
|
||||
<text v-if="category.badge" class="home-page__cat-badge">{{ category.badge }}</text>
|
||||
</view>
|
||||
<text class="home-page__cat-label">{{ category.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Section Header -->
|
||||
<view class="home-page__section-head">
|
||||
<text class="home-page__section-title">热门推荐</text>
|
||||
<view class="home-page__section-more" @click="goMenu">
|
||||
<text>查看全部 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Product List -->
|
||||
<view class="home-page__product-list">
|
||||
<ProductCard
|
||||
v-for="product in recommendedProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
@select="goMenu"
|
||||
@action="goMenu"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Trust Section -->
|
||||
<view class="home-page__trust">
|
||||
<view class="home-page__trust-grid">
|
||||
<view v-for="item in trustItems" :key="item.key" class="home-page__trust-item">
|
||||
<view class="home-page__trust-icon"><text>{{ item.icon }}</text></view>
|
||||
<text class="home-page__trust-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Floating Cart FAB -->
|
||||
<view v-if="cartCount > 0" class="home-page__fab" @click="goMenu">
|
||||
<text class="home-page__fab-icon">🛒</text>
|
||||
<view class="home-page__fab-badge">
|
||||
<text>{{ cartCount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProductCard from '@/components/product-card/index.vue'
|
||||
import { useHomePage } from './composables/useHomePage'
|
||||
|
||||
const {
|
||||
cartCount,
|
||||
categoryCards,
|
||||
currentStore,
|
||||
goMenu,
|
||||
goStoreSelect,
|
||||
isDineIn,
|
||||
recommendedProducts,
|
||||
trustItems
|
||||
} = useHomePage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
121
src/pages/home/styles/banner.scss
Normal file
121
src/pages/home/styles/banner.scss
Normal file
@@ -0,0 +1,121 @@
|
||||
.home-page__banner {
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 160px;
|
||||
background: linear-gradient(135deg, #14532D 0%, #166534 35%, #15803D 70%, #1A7A42 100%);
|
||||
padding: 22px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(22, 101, 52, 0.25), 0 8px 32px rgba(22, 101, 52, 0.12);
|
||||
}
|
||||
|
||||
.home-page__banner-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.home-page__banner-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.home-page__banner-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.home-page__banner-amount {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.1;
|
||||
margin: 4px 0 2px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.home-page__banner-unit {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-page__banner-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.home-page__banner-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 32px;
|
||||
padding: 0 18px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #14532D;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
width: fit-content;
|
||||
margin-top: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.home-page__banner-food-img {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.2));
|
||||
}
|
||||
|
||||
.home-page__banner-ring {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 144px;
|
||||
height: 144px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.home-page__banner-ring-outer {
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
z-index: 0;
|
||||
}
|
||||
161
src/pages/home/styles/base.scss
Normal file
161
src/pages/home/styles/base.scss
Normal file
@@ -0,0 +1,161 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.home-page__store-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 14px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home-page__store-icon-wrap {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-page__store-icon-emoji {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.home-page__store-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-page__store-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-page__store-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-page__store-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-page__store-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $primary;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.home-page__store-addr {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-page__store-switch {
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-page__chevron {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-page__scan-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 44px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(22, 163, 74, 0.35);
|
||||
}
|
||||
|
||||
.home-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #F1F5F9;
|
||||
border-radius: $r-md;
|
||||
padding: 0 14px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.home-page__search-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.home-page__search-placeholder {
|
||||
font-size: 14px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.home-page__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.home-page__section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.home-page__section-more {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.home-page__product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
54
src/pages/home/styles/category.scss
Normal file
54
src/pages/home/styles/category.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page__categories {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.home-page__cat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 2px 8px;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
|
||||
.home-page__cat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
font-size: 18px;
|
||||
|
||||
&--orange { background: #FFF4ED; }
|
||||
&--green-soft { background: #F0FDF4; }
|
||||
&--yellow { background: #FFFBEB; }
|
||||
&--green-light { background: #ECFDF5; }
|
||||
&--mint { background: #F0FDF4; }
|
||||
&--amber { background: #FFF7ED; }
|
||||
}
|
||||
|
||||
.home-page__cat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.home-page__cat-badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -6px;
|
||||
font-size: 9px;
|
||||
background: $red;
|
||||
color: #fff;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
37
src/pages/home/styles/fab.scss
Normal file
37
src/pages/home/styles/fab.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page__fab {
|
||||
position: fixed;
|
||||
bottom: 92px;
|
||||
right: 16px;
|
||||
z-index: 90;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.home-page__fab-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.home-page__fab-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: $red;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
5
src/pages/home/styles/index.scss
Normal file
5
src/pages/home/styles/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import './base.scss';
|
||||
@import './banner.scss';
|
||||
@import './category.scss';
|
||||
@import './trust.scss';
|
||||
@import './fab.scss';
|
||||
41
src/pages/home/styles/trust.scss
Normal file
41
src/pages/home/styles/trust.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.home-page__trust {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 16px 12px;
|
||||
box-shadow: $shadow-xs;
|
||||
}
|
||||
|
||||
.home-page__trust-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.home-page__trust-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.home-page__trust-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 11px;
|
||||
background: $primary-lighter;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-page__trust-label {
|
||||
font-size: 11px;
|
||||
color: $text-2;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
52
src/pages/menu/composables/menu-page/data-actions.ts
Normal file
52
src/pages/menu/composables/menu-page/data-actions.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type {
|
||||
FulfillmentScene,
|
||||
MiniCategory,
|
||||
MiniMenuSection
|
||||
} from '@/shared'
|
||||
import { getCategories, getMenu } from '@/services'
|
||||
import type { useAppStore } from '@/stores'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
|
||||
export function createMenuDataActions (payload: {
|
||||
appStore: AppStoreInstance
|
||||
categories: Ref<MiniCategory[]>
|
||||
errorMessage: Ref<string>
|
||||
loading: Ref<boolean>
|
||||
sections: Ref<MiniMenuSection[]>
|
||||
}) {
|
||||
const { appStore, categories, errorMessage, loading, sections } = payload
|
||||
|
||||
async function loadMenu () {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
|
||||
const [nextCategories, nextSections] = await Promise.all([
|
||||
getCategories(appStore.currentStore.id, appStore.scene, appStore.channel),
|
||||
getMenu(appStore.currentStore.id, appStore.scene, appStore.channel)
|
||||
])
|
||||
|
||||
categories.value = nextCategories
|
||||
sections.value = nextSections
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '菜单加载失败,请检查接口是否可用'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSceneChange (value: string) {
|
||||
appStore.setScene(value as FulfillmentScene)
|
||||
await loadMenu()
|
||||
}
|
||||
|
||||
return {
|
||||
handleSceneChange,
|
||||
loadMenu
|
||||
}
|
||||
}
|
||||
183
src/pages/menu/composables/menu-page/detail-actions.ts
Normal file
183
src/pages/menu/composables/menu-page/detail-actions.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type {
|
||||
MiniProductCard,
|
||||
MiniProductDetail,
|
||||
MiniProductOption,
|
||||
MiniProductOptionGroup
|
||||
} from '@/shared'
|
||||
import { showToast } from '@tarojs/taro'
|
||||
import { getProductDetail } from '@/services'
|
||||
import type { useAppStore, useCartStore } from '@/stores'
|
||||
import {
|
||||
buildSelectedNames,
|
||||
resolveSelectionError,
|
||||
resolveSkuId
|
||||
} from './selection-helpers'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
type CartStoreInstance = ReturnType<typeof useCartStore>
|
||||
|
||||
export function createMenuDetailActions (payload: {
|
||||
activeDetail: Ref<MiniProductDetail | null>
|
||||
addonSelections: Ref<Record<string, string[]>>
|
||||
appStore: AppStoreInstance
|
||||
canAddCurrentDetail: ComputedRef<boolean>
|
||||
cartStore: CartStoreInstance
|
||||
closeDetail: () => void
|
||||
currentDetailPrice: ComputedRef<number>
|
||||
currentSkuId: Ref<string>
|
||||
detailQuantity: Ref<number>
|
||||
detailVisible: Ref<boolean>
|
||||
specSelections: Ref<Record<string, string[]>>
|
||||
}) {
|
||||
const {
|
||||
activeDetail,
|
||||
addonSelections,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartStore,
|
||||
closeDetail,
|
||||
currentDetailPrice,
|
||||
currentSkuId,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
specSelections
|
||||
} = payload
|
||||
|
||||
async function openProductDetail (productId: string) {
|
||||
try {
|
||||
const detail = await getProductDetail(productId, appStore.scene, appStore.channel)
|
||||
activeDetail.value = detail
|
||||
detailQuantity.value = 1
|
||||
|
||||
const nextSpecSelections: Record<string, string[]> = {}
|
||||
const nextAddonSelections: Record<string, string[]> = {}
|
||||
const defaultSku = detail.skus.find((sku) => sku.id === detail.defaultSkuId) || detail.skus[0]
|
||||
|
||||
detail.optionGroups.forEach((group) => {
|
||||
if (group.groupType === 'addon') {
|
||||
const defaults = group.required
|
||||
? group.options
|
||||
.filter((option) => !option.soldOut)
|
||||
.slice(0, Math.max(group.minSelect, 1))
|
||||
.map((option) => option.id)
|
||||
: []
|
||||
nextAddonSelections[group.id] = defaults
|
||||
return
|
||||
}
|
||||
|
||||
const matched = group.options.find((option) => defaultSku?.selectedOptionIds.includes(option.id))
|
||||
|| group.options.find((option) => !option.soldOut)
|
||||
nextSpecSelections[group.id] = matched ? [matched.id] : []
|
||||
})
|
||||
|
||||
specSelections.value = nextSpecSelections
|
||||
addonSelections.value = nextAddonSelections
|
||||
currentSkuId.value = detail.skus.length ? resolveSkuId(detail, nextSpecSelections) : ''
|
||||
detailVisible.value = true
|
||||
} catch (error: unknown) {
|
||||
await showToast({
|
||||
title: error instanceof Error ? error.message : '商品详情加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOption (group: MiniProductOptionGroup, option: MiniProductOption) {
|
||||
if (option.soldOut) return
|
||||
|
||||
if (group.groupType === 'addon') {
|
||||
const current = addonSelections.value[group.id] || []
|
||||
|
||||
if (group.selectionType === 'single') {
|
||||
addonSelections.value = { ...addonSelections.value, [group.id]: [option.id] }
|
||||
return
|
||||
}
|
||||
|
||||
const hasSelected = current.includes(option.id)
|
||||
const next = hasSelected
|
||||
? current.filter((id) => id !== option.id)
|
||||
: [...current, option.id]
|
||||
addonSelections.value = { ...addonSelections.value, [group.id]: next }
|
||||
return
|
||||
}
|
||||
|
||||
specSelections.value = { ...specSelections.value, [group.id]: [option.id] }
|
||||
if (activeDetail.value) {
|
||||
currentSkuId.value = resolveSkuId(activeDetail.value, specSelections.value)
|
||||
}
|
||||
}
|
||||
|
||||
function changeDetailQuantity (delta: number) {
|
||||
detailQuantity.value = Math.max(1, detailQuantity.value + delta)
|
||||
}
|
||||
|
||||
async function confirmAddCurrentDetail () {
|
||||
if (!activeDetail.value || !canAddCurrentDetail.value) {
|
||||
const message = resolveSelectionError({
|
||||
activeDetail: activeDetail.value,
|
||||
addonSelections: addonSelections.value,
|
||||
currentSkuId: currentSkuId.value,
|
||||
specSelections: specSelections.value
|
||||
})
|
||||
|
||||
if (message) {
|
||||
await showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const specNames = buildSelectedNames(
|
||||
activeDetail.value.optionGroups.filter((group) => group.groupType !== 'addon'),
|
||||
specSelections.value
|
||||
)
|
||||
const addonNames = buildSelectedNames(
|
||||
activeDetail.value.optionGroups.filter((group) => group.groupType === 'addon'),
|
||||
addonSelections.value
|
||||
)
|
||||
const addonIds = activeDetail.value.optionGroups
|
||||
.filter((group) => group.groupType === 'addon')
|
||||
.flatMap((group) => addonSelections.value[group.id] || [])
|
||||
|
||||
cartStore.addItem({
|
||||
productId: activeDetail.value.id,
|
||||
name: activeDetail.value.name,
|
||||
unitPrice: currentDetailPrice.value / detailQuantity.value,
|
||||
quantity: detailQuantity.value,
|
||||
skuId: currentSkuId.value || undefined,
|
||||
skuName: specNames.join('/'),
|
||||
addonItemIds: addonIds,
|
||||
addonNames,
|
||||
coverImageUrl: activeDetail.value.coverImageUrl
|
||||
})
|
||||
|
||||
await showToast({ title: '已加入购物车', icon: 'success' })
|
||||
closeDetail()
|
||||
}
|
||||
|
||||
async function handleProductAction (product: MiniProductCard) {
|
||||
if (product.hasOptions) {
|
||||
await openProductDetail(product.id)
|
||||
return
|
||||
}
|
||||
|
||||
cartStore.addItem({
|
||||
productId: product.id,
|
||||
name: product.name,
|
||||
unitPrice: product.price,
|
||||
quantity: 1,
|
||||
coverImageUrl: product.coverImageUrl,
|
||||
addonItemIds: [],
|
||||
addonNames: []
|
||||
})
|
||||
|
||||
await showToast({ title: '已加入购物车', icon: 'success' })
|
||||
}
|
||||
|
||||
return {
|
||||
changeDetailQuantity,
|
||||
confirmAddCurrentDetail,
|
||||
handleProductAction,
|
||||
toggleOption
|
||||
}
|
||||
}
|
||||
61
src/pages/menu/composables/menu-page/selection-helpers.ts
Normal file
61
src/pages/menu/composables/menu-page/selection-helpers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
MiniProductDetail,
|
||||
MiniProductOptionGroup
|
||||
} from '@/shared'
|
||||
|
||||
export function getSelectedIds (
|
||||
addonSelections: Record<string, string[]>,
|
||||
specSelections: Record<string, string[]>,
|
||||
groupId: string
|
||||
) {
|
||||
return addonSelections[groupId] || specSelections[groupId] || []
|
||||
}
|
||||
|
||||
export function resolveSkuId (
|
||||
detail: MiniProductDetail,
|
||||
nextSelections: Record<string, string[]>
|
||||
) {
|
||||
const selectedOptionIds = detail.optionGroups
|
||||
.filter((group) => group.groupType !== 'addon')
|
||||
.flatMap((group) => nextSelections[group.id] || [])
|
||||
.sort()
|
||||
|
||||
if (!detail.skus.length) return ''
|
||||
|
||||
const matchedSku = detail.skus.find((sku) =>
|
||||
[...sku.selectedOptionIds].sort().join('|') === selectedOptionIds.join('|')
|
||||
)
|
||||
|
||||
return matchedSku?.id || ''
|
||||
}
|
||||
|
||||
export function resolveSelectionError (payload: {
|
||||
activeDetail: MiniProductDetail | null
|
||||
addonSelections: Record<string, string[]>
|
||||
currentSkuId: string
|
||||
specSelections: Record<string, string[]>
|
||||
}) {
|
||||
const { activeDetail, addonSelections, currentSkuId, specSelections } = payload
|
||||
if (!activeDetail) return ''
|
||||
|
||||
for (const group of activeDetail.optionGroups) {
|
||||
const selectedCount = getSelectedIds(addonSelections, specSelections, group.id).length
|
||||
const requiredCount = group.required ? Math.max(group.minSelect, 1) : group.minSelect
|
||||
|
||||
if (requiredCount > 0 && selectedCount < requiredCount) return `请选择${group.name}`
|
||||
if (group.selectionType === 'single' && selectedCount > 1) return `${group.name}只能选择一项`
|
||||
if (group.maxSelect > 0 && selectedCount > group.maxSelect) return `${group.name}超出可选上限`
|
||||
}
|
||||
|
||||
if (activeDetail.skus.length && !currentSkuId) return '请先选择完整规格'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function buildSelectedNames (
|
||||
groups: MiniProductOptionGroup[],
|
||||
source: Record<string, string[]>
|
||||
) {
|
||||
return groups
|
||||
.flatMap((group) => group.options.filter((option) => (source[group.id] || []).includes(option.id)))
|
||||
.map((option) => option.name)
|
||||
}
|
||||
177
src/pages/menu/composables/useMenuPage.ts
Normal file
177
src/pages/menu/composables/useMenuPage.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { showToast, useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useAppStore, useCartStore } from '@/stores'
|
||||
import type {
|
||||
MiniCategory,
|
||||
MiniMenuSection,
|
||||
MiniProductDetail
|
||||
} from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
import { createMenuDataActions } from './menu-page/data-actions'
|
||||
import { createMenuDetailActions } from './menu-page/detail-actions'
|
||||
import {
|
||||
getSelectedIds,
|
||||
resolveSelectionError
|
||||
} from './menu-page/selection-helpers'
|
||||
|
||||
export function useMenuPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const cartStore = useCartStore(pinia)
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const categories = ref<MiniCategory[]>([])
|
||||
const sections = ref<MiniMenuSection[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const cartVisible = ref(false)
|
||||
const activeDetail = ref<MiniProductDetail | null>(null)
|
||||
const currentSkuId = ref('')
|
||||
const detailQuantity = ref(1)
|
||||
const specSelections = ref<Record<string, string[]>>({})
|
||||
const addonSelections = ref<Record<string, string[]>>({})
|
||||
|
||||
const currentStore = computed(() => appStore.currentStore)
|
||||
const cartCount = computed(() => cartStore.itemCount)
|
||||
const totalAmountText = computed(() => cartStore.totalAmountText)
|
||||
const lineList = computed(() => cartStore.lineList)
|
||||
const currentSku = computed(() =>
|
||||
activeDetail.value?.skus.find((sku) => sku.id === currentSkuId.value) || null
|
||||
)
|
||||
const currentDetailPrice = computed(() => {
|
||||
if (!activeDetail.value) return 0
|
||||
|
||||
const basePrice = currentSku.value?.price ?? activeDetail.value.basePrice
|
||||
const addonPrice = activeDetail.value.optionGroups
|
||||
.filter((group) => group.groupType === 'addon')
|
||||
.flatMap((group) =>
|
||||
group.options.filter((option) => (addonSelections.value[group.id] || []).includes(option.id))
|
||||
)
|
||||
.reduce((amount, option) => amount + option.extraPrice, 0)
|
||||
|
||||
return (basePrice + addonPrice) * detailQuantity.value
|
||||
})
|
||||
const displayUnitPrice = computed(() =>
|
||||
detailQuantity.value > 0 ? currentDetailPrice.value / detailQuantity.value : 0
|
||||
)
|
||||
const currentDetailPriceText = computed(() => currentDetailPrice.value.toFixed(2))
|
||||
const canAddCurrentDetail = computed(() => {
|
||||
if (!activeDetail.value) return false
|
||||
if (activeDetail.value.skus.length && !currentSkuId.value) return false
|
||||
|
||||
return !resolveSelectionError({
|
||||
activeDetail: activeDetail.value,
|
||||
addonSelections: addonSelections.value,
|
||||
currentSkuId: currentSkuId.value,
|
||||
specSelections: specSelections.value
|
||||
})
|
||||
})
|
||||
const summaryText = computed(() => {
|
||||
if (!activeDetail.value) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
activeDetail.value.optionGroups.forEach((group) => {
|
||||
const selected = getSelectedIds(addonSelections.value, specSelections.value, group.id)
|
||||
group.options
|
||||
.filter((option) => selected.includes(option.id))
|
||||
.forEach((option) => parts.push(option.name))
|
||||
})
|
||||
|
||||
return parts.join(' · ') || '请选择规格'
|
||||
})
|
||||
const disabledButtonText = computed(() =>
|
||||
resolveSelectionError({
|
||||
activeDetail: activeDetail.value,
|
||||
addonSelections: addonSelections.value,
|
||||
currentSkuId: currentSkuId.value,
|
||||
specSelections: specSelections.value
|
||||
}) || '请选择规格'
|
||||
)
|
||||
|
||||
function isOptionSelected (groupId: string, optionId: string) {
|
||||
return getSelectedIds(addonSelections.value, specSelections.value, groupId).includes(optionId)
|
||||
}
|
||||
|
||||
function closeDetail () {
|
||||
detailVisible.value = false
|
||||
activeDetail.value = null
|
||||
currentSkuId.value = ''
|
||||
detailQuantity.value = 1
|
||||
specSelections.value = {}
|
||||
addonSelections.value = {}
|
||||
}
|
||||
|
||||
const { handleSceneChange, loadMenu } = createMenuDataActions({
|
||||
appStore,
|
||||
categories,
|
||||
errorMessage,
|
||||
loading,
|
||||
sections
|
||||
})
|
||||
|
||||
const {
|
||||
changeDetailQuantity,
|
||||
confirmAddCurrentDetail,
|
||||
handleProductAction,
|
||||
toggleOption
|
||||
} = createMenuDetailActions({
|
||||
activeDetail,
|
||||
addonSelections,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartStore,
|
||||
closeDetail,
|
||||
currentDetailPrice,
|
||||
currentSkuId,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
specSelections
|
||||
})
|
||||
|
||||
function goStoreSelect () {
|
||||
void openRoute('/pages/store/select/index')
|
||||
}
|
||||
|
||||
async function goCheckout () {
|
||||
if (!cartStore.itemCount) {
|
||||
await showToast({ title: '请先加购商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
void openRoute('/pages/trade/checkout/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void loadMenu()
|
||||
})
|
||||
|
||||
return {
|
||||
activeDetail,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartCount,
|
||||
cartStore,
|
||||
cartVisible,
|
||||
categories,
|
||||
changeDetailQuantity,
|
||||
closeDetail,
|
||||
confirmAddCurrentDetail,
|
||||
currentDetailPriceText,
|
||||
currentStore,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
disabledButtonText,
|
||||
displayUnitPrice,
|
||||
errorMessage,
|
||||
goCheckout,
|
||||
goStoreSelect,
|
||||
handleProductAction,
|
||||
handleSceneChange,
|
||||
isOptionSelected,
|
||||
lineList,
|
||||
loadMenu,
|
||||
loading,
|
||||
sections,
|
||||
summaryText,
|
||||
toggleOption,
|
||||
totalAmountText
|
||||
}
|
||||
}
|
||||
3
src/pages/menu/index.config.ts
Normal file
3
src/pages/menu/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '点餐'
|
||||
})
|
||||
166
src/pages/menu/index.vue
Normal file
166
src/pages/menu/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<view class="menu-page">
|
||||
<!-- Store Header -->
|
||||
<view class="menu-page__store-card">
|
||||
<view class="menu-page__store-icon-wrap">
|
||||
<text class="menu-page__store-icon-emoji">📍</text>
|
||||
</view>
|
||||
<view class="menu-page__store-text">
|
||||
<view class="menu-page__store-name">
|
||||
<text class="menu-page__store-name-text">{{ currentStore.name }}</text>
|
||||
<view class="menu-page__store-status">
|
||||
<view class="menu-page__store-status-dot" />
|
||||
<text>营业中</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-page__store-addr">{{ currentStore.address }}</text>
|
||||
</view>
|
||||
<view class="menu-page__store-switch" @click="goStoreSelect">
|
||||
<text>切换</text>
|
||||
<text class="menu-page__chevron">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Scene Switcher -->
|
||||
<view class="menu-page__scene-tabs">
|
||||
<view
|
||||
v-for="option in appStore.sceneOptions"
|
||||
:key="option.value"
|
||||
class="menu-page__scene-tab"
|
||||
:class="{ 'menu-page__scene-tab--active': appStore.scene === option.value }"
|
||||
@click="handleSceneChange(option.value)"
|
||||
>
|
||||
<text>{{ option.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Category Strip -->
|
||||
<scroll-view v-if="categories.length" class="menu-page__category-scroll" scroll-x enhanced>
|
||||
<view class="menu-page__category-list">
|
||||
<view
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="menu-page__category-pill"
|
||||
>
|
||||
<text>{{ category.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Loading State -->
|
||||
<view v-if="loading" class="menu-page__loading">
|
||||
<text class="menu-page__loading-text">菜单加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- Error State -->
|
||||
<view v-else-if="errorMessage" class="menu-page__error">
|
||||
<text class="menu-page__error-text">{{ errorMessage }}</text>
|
||||
<view class="menu-page__error-btn" @click="loadMenu">
|
||||
<text>重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Product List -->
|
||||
<view v-else class="menu-page__sections">
|
||||
<view v-for="section in sections" :key="section.categoryId" class="menu-page__section">
|
||||
<view class="menu-page__section-head">
|
||||
<text class="menu-page__section-title">{{ section.categoryName }}</text>
|
||||
<text class="menu-page__section-count">{{ section.products.length }} 款在售</text>
|
||||
</view>
|
||||
<view class="menu-page__product-list">
|
||||
<ProductCard
|
||||
v-for="product in section.products"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
@select="handleProductAction(product)"
|
||||
@action="handleProductAction(product)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Bottom Cart Bar -->
|
||||
<view class="menu-page__cart-bar">
|
||||
<view class="menu-page__cart-main" @click="cartVisible = !cartVisible">
|
||||
<text class="menu-page__cart-label">购物车 {{ cartCount }} 件</text>
|
||||
<text class="menu-page__cart-price">¥{{ totalAmountText }}</text>
|
||||
</view>
|
||||
<view class="menu-page__cart-btn" @click="goCheckout">
|
||||
<text>去结算</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Cart Drawer -->
|
||||
<CartDrawer
|
||||
:visible="cartVisible"
|
||||
:line-list="lineList"
|
||||
:item-count="cartCount"
|
||||
:total-amount-text="totalAmountText"
|
||||
@close="cartVisible = false"
|
||||
@clear="cartStore.clear()"
|
||||
@checkout="goCheckout"
|
||||
@change-qty="(key, delta) => cartStore.changeQuantity(key, delta)"
|
||||
/>
|
||||
|
||||
<!-- Spec Popup -->
|
||||
<SpecPopup
|
||||
v-if="detailVisible && activeDetail"
|
||||
:visible="detailVisible"
|
||||
:product="activeDetail"
|
||||
:quantity="detailQuantity"
|
||||
:display-unit-price="displayUnitPrice"
|
||||
:total-price-text="currentDetailPriceText"
|
||||
:summary-text="summaryText"
|
||||
:can-add="canAddCurrentDetail"
|
||||
:disabled-text="disabledButtonText"
|
||||
:is-selected="isOptionSelected"
|
||||
@close="closeDetail"
|
||||
@add-to-cart="confirmAddCurrentDetail"
|
||||
@toggle-option="toggleOption"
|
||||
@change-qty="changeDetailQuantity"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProductCard from '@/components/product-card/index.vue'
|
||||
import CartDrawer from '@/components/cart-drawer/index.vue'
|
||||
import SpecPopup from '@/components/spec-popup/index.vue'
|
||||
import { useMenuPage } from './composables/useMenuPage'
|
||||
|
||||
const {
|
||||
activeDetail,
|
||||
appStore,
|
||||
canAddCurrentDetail,
|
||||
cartCount,
|
||||
cartStore,
|
||||
cartVisible,
|
||||
categories,
|
||||
changeDetailQuantity,
|
||||
closeDetail,
|
||||
confirmAddCurrentDetail,
|
||||
currentDetailPriceText,
|
||||
currentStore,
|
||||
detailQuantity,
|
||||
detailVisible,
|
||||
disabledButtonText,
|
||||
displayUnitPrice,
|
||||
errorMessage,
|
||||
goCheckout,
|
||||
goStoreSelect,
|
||||
handleProductAction,
|
||||
handleSceneChange,
|
||||
isOptionSelected,
|
||||
lineList,
|
||||
loadMenu,
|
||||
loading,
|
||||
sections,
|
||||
summaryText,
|
||||
toggleOption,
|
||||
totalAmountText
|
||||
} = useMenuPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
185
src/pages/menu/styles/base.scss
Normal file
185
src/pages/menu/styles/base.scss
Normal file
@@ -0,0 +1,185 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.menu-page {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.menu-page__store-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 14px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-page__store-icon-wrap {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-page__store-icon-emoji {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.menu-page__store-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-page__store-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.menu-page__store-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-page__store-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-page__store-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $primary;
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.menu-page__store-addr {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu-page__store-switch {
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-page__chevron {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu-page__scene-tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
border-radius: $r-md;
|
||||
padding: 4px;
|
||||
box-shadow: $shadow-xs;
|
||||
border: 1px solid $border;
|
||||
}
|
||||
|
||||
.menu-page__scene-tab {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-3;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--active {
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(22, 163, 74, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-page__category-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-page__category-list {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.menu-page__category-pill {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background: $card;
|
||||
border: 1px solid $border;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-page__loading,
|
||||
.menu-page__error {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.menu-page__loading-text {
|
||||
font-size: 14px;
|
||||
color: $text-3;
|
||||
}
|
||||
|
||||
.menu-page__error-text {
|
||||
font-size: 14px;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.menu-page__error-btn {
|
||||
margin-top: 16px;
|
||||
display: inline-flex;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 18px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
48
src/pages/menu/styles/cart.scss
Normal file
48
src/pages/menu/styles/cart.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.menu-page__cart-bar {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 22px;
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.menu-page__cart-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-page__cart-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.menu-page__cart-price {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.menu-page__cart-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
3
src/pages/menu/styles/index.scss
Normal file
3
src/pages/menu/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
@import './base.scss';
|
||||
@import './sections.scss';
|
||||
@import './cart.scss';
|
||||
30
src/pages/menu/styles/sections.scss
Normal file
30
src/pages/menu/styles/sections.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@import '../../../styles/variables';
|
||||
|
||||
.menu-page__section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.menu-page__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.menu-page__section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.menu-page__section-count {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.menu-page__product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { showToast } from '@tarojs/taro'
|
||||
import { getOrderDetail, mockPayOrder } from '@/services'
|
||||
import type { OrderDetail } from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
export function createOrderDetailActions (payload: {
|
||||
loading: Ref<boolean>
|
||||
order: Ref<OrderDetail | null>
|
||||
orderId: Ref<string>
|
||||
}) {
|
||||
const { loading, order, orderId } = payload
|
||||
|
||||
async function loadDetail () {
|
||||
if (!orderId.value) {
|
||||
order.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
order.value = await getOrderDetail(orderId.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePrimaryAction () {
|
||||
if (!order.value) return
|
||||
|
||||
if (order.value.actionText === '再来一单') {
|
||||
void openRoute('/pages/menu/index')
|
||||
return
|
||||
}
|
||||
|
||||
if (order.value.actionText === '去支付') {
|
||||
await mockPayOrder(order.value.id)
|
||||
await showToast({ title: '支付成功', icon: 'success' })
|
||||
await loadDetail()
|
||||
return
|
||||
}
|
||||
|
||||
await showToast({ title: '当前订单处理中', icon: 'none' })
|
||||
}
|
||||
|
||||
function handleContact () {
|
||||
void showToast({ title: '拨号功能暂不可用', icon: 'none' })
|
||||
}
|
||||
|
||||
function goOrders () {
|
||||
void openRoute('/pages/orders/index')
|
||||
}
|
||||
|
||||
return {
|
||||
goOrders,
|
||||
handleContact,
|
||||
handlePrimaryAction,
|
||||
loadDetail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FulfillmentScenes, formatPrice as formatPriceUtil, SCENE_LABEL_MAP } from '@/shared'
|
||||
import type { FulfillmentScene, OrderDetail } from '@/shared'
|
||||
|
||||
export function formatPrice (value: number) {
|
||||
return formatPriceUtil(value)
|
||||
}
|
||||
|
||||
export function resolveSceneLabel (scene: FulfillmentScene) {
|
||||
return SCENE_LABEL_MAP[scene]
|
||||
}
|
||||
|
||||
export function resolveOrderSubtitle (order: OrderDetail | null) {
|
||||
if (!order) return ''
|
||||
if (order.tableNo) return `桌号 ${order.tableNo}`
|
||||
return order.customerName
|
||||
}
|
||||
|
||||
export function resolveStatusDescription (order: OrderDetail | null) {
|
||||
if (!order) return ''
|
||||
|
||||
const statusText = order.statusText
|
||||
if (statusText === '待支付') return '请尽快完成支付'
|
||||
if (statusText === '已接单' || statusText === '制作中') return '商家已接单,正在为您精心准备餐品'
|
||||
if (statusText === '配送中') return '骑手正在为您配送'
|
||||
if (statusText === '已完成') return '订单已完成,欢迎再次光临'
|
||||
return '订单处理中'
|
||||
}
|
||||
|
||||
export function resolveFulfillmentHint (order: OrderDetail | null) {
|
||||
if (!order) return ''
|
||||
if (order.scene === FulfillmentScenes.DineIn) return '餐品制作完成后将尽快为您上桌,请留意取餐提醒'
|
||||
if (order.scene === FulfillmentScenes.Pickup) return '餐品制作完成后请到店取餐'
|
||||
return '预计 30 分钟送达'
|
||||
}
|
||||
|
||||
export function resolveTimelineNodeClass (index: number, currentTimelineIndex: number) {
|
||||
if (index < currentTimelineIndex) return 'od-page__tl-node--done'
|
||||
if (index === currentTimelineIndex) return 'od-page__tl-node--current'
|
||||
return 'od-page__tl-node--pending'
|
||||
}
|
||||
64
src/pages/order/detail/composables/useOrderDetailPage.ts
Normal file
64
src/pages/order/detail/composables/useOrderDetailPage.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDidShow, useLoad } from '@tarojs/taro'
|
||||
import { pinia, useAppStore } from '@/stores'
|
||||
import type { OrderDetail } from '@/shared'
|
||||
import { createOrderDetailActions } from './order-detail-page/actions'
|
||||
import {
|
||||
formatPrice,
|
||||
resolveFulfillmentHint,
|
||||
resolveOrderSubtitle,
|
||||
resolveSceneLabel,
|
||||
resolveStatusDescription,
|
||||
resolveTimelineNodeClass
|
||||
} from './order-detail-page/status-helpers'
|
||||
|
||||
export function useOrderDetailPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const orderId = ref('')
|
||||
const loading = ref(true)
|
||||
const order = ref<OrderDetail | null>(null)
|
||||
|
||||
const { goOrders, handleContact, handlePrimaryAction, loadDetail } = createOrderDetailActions({
|
||||
loading,
|
||||
order,
|
||||
orderId
|
||||
})
|
||||
|
||||
const sceneLabel = computed(() => order.value ? resolveSceneLabel(order.value.scene) : '')
|
||||
const orderSubtitle = computed(() => resolveOrderSubtitle(order.value))
|
||||
const statusDescription = computed(() => resolveStatusDescription(order.value))
|
||||
const storeAddress = computed(() => appStore.currentStore.address)
|
||||
const fulfillmentHint = computed(() => resolveFulfillmentHint(order.value))
|
||||
const currentTimelineIndex = computed(() => {
|
||||
if (!order.value) return 0
|
||||
return Math.max(order.value.timeline.length - 1, 0)
|
||||
})
|
||||
|
||||
function timelineNodeClass (index: number) {
|
||||
return resolveTimelineNodeClass(index, currentTimelineIndex.value)
|
||||
}
|
||||
|
||||
useLoad((options) => {
|
||||
orderId.value = options?.id || ''
|
||||
})
|
||||
|
||||
useDidShow(() => {
|
||||
void loadDetail()
|
||||
})
|
||||
|
||||
return {
|
||||
currentTimelineIndex,
|
||||
fulfillmentHint,
|
||||
formatPrice,
|
||||
goOrders,
|
||||
handleContact,
|
||||
handlePrimaryAction,
|
||||
loading,
|
||||
order,
|
||||
orderSubtitle,
|
||||
sceneLabel,
|
||||
statusDescription,
|
||||
storeAddress,
|
||||
timelineNodeClass
|
||||
}
|
||||
}
|
||||
3
src/pages/order/detail/index.config.ts
Normal file
3
src/pages/order/detail/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单详情'
|
||||
})
|
||||
212
src/pages/order/detail/index.vue
Normal file
212
src/pages/order/detail/index.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<view class="od-page">
|
||||
<!-- Loading -->
|
||||
<view v-if="loading" class="od-page__loading">
|
||||
<text class="od-page__loading-text">订单加载中...</text>
|
||||
</view>
|
||||
|
||||
<template v-else-if="order">
|
||||
<!-- Status Hero -->
|
||||
<view class="od-page__status-hero">
|
||||
<view class="od-page__status-deco1" />
|
||||
<view class="od-page__status-deco2" />
|
||||
<view class="od-page__status-row">
|
||||
<view class="od-page__status-icon">
|
||||
<text class="od-page__status-icon-text">🕐</text>
|
||||
</view>
|
||||
<view class="od-page__status-text">
|
||||
<text class="od-page__status-title">{{ order.statusText }}</text>
|
||||
<text class="od-page__status-desc">{{ statusDescription }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="od-page__status-badge">
|
||||
<text>{{ sceneLabel }} · {{ orderSubtitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Store Card -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__store">
|
||||
<view class="od-page__store-icon">
|
||||
<text>🏠</text>
|
||||
</view>
|
||||
<view class="od-page__store-info">
|
||||
<text class="od-page__store-name">{{ order.storeName }}</text>
|
||||
<text class="od-page__store-addr">{{ storeAddress }}</text>
|
||||
</view>
|
||||
<view class="od-page__store-call">
|
||||
<text>📞</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Fulfillment Info -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__fulfill">
|
||||
<view v-if="order.tableNo" class="od-page__fulfill-row">
|
||||
<text class="od-page__fulfill-label">桌号</text>
|
||||
<view class="od-page__table-badge">
|
||||
<text>{{ order.tableNo }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="od-page__fulfill-row">
|
||||
<text class="od-page__fulfill-label">联系人</text>
|
||||
<text class="od-page__fulfill-value">{{ order.customerName }}</text>
|
||||
</view>
|
||||
<view class="od-page__fulfill-row">
|
||||
<text class="od-page__fulfill-label">手机号</text>
|
||||
<text class="od-page__fulfill-value">{{ order.customerPhone }}</text>
|
||||
</view>
|
||||
<view class="od-page__fulfill-hint">
|
||||
<text>🛡</text>
|
||||
<text class="od-page__fulfill-hint-text">{{ fulfillmentHint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Timeline -->
|
||||
<view v-if="order.timeline.length" class="od-page__card">
|
||||
<view class="od-page__timeline-section">
|
||||
<text class="od-page__section-title">📋 订单进度</text>
|
||||
<view class="od-page__timeline">
|
||||
<view
|
||||
v-for="(node, index) in order.timeline"
|
||||
:key="index"
|
||||
class="od-page__tl-node"
|
||||
:class="timelineNodeClass(index)"
|
||||
>
|
||||
<view class="od-page__tl-dot">
|
||||
<text v-if="index < currentTimelineIndex" class="od-page__tl-check">✓</text>
|
||||
<text v-else-if="index === currentTimelineIndex" class="od-page__tl-pulse">●</text>
|
||||
</view>
|
||||
<view class="od-page__tl-content">
|
||||
<text class="od-page__tl-title">{{ node.statusText }}</text>
|
||||
<text class="od-page__tl-time">{{ node.occurredAt }}</text>
|
||||
<text v-if="node.notes" class="od-page__tl-sub">{{ node.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Products + Fees -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__goods-section">
|
||||
<text class="od-page__section-title">🛍 商品明细</text>
|
||||
<view class="od-page__goods-list">
|
||||
<view v-for="item in order.items" :key="item.id" class="od-page__good">
|
||||
<view class="od-page__good-img-placeholder" />
|
||||
<view class="od-page__good-info">
|
||||
<text class="od-page__good-name">{{ item.productName }}</text>
|
||||
<text v-if="item.skuName" class="od-page__good-spec">{{ item.skuName }}</text>
|
||||
</view>
|
||||
<view class="od-page__good-right">
|
||||
<text class="od-page__good-price"><text class="od-page__good-unit">¥</text>{{ item.subTotalText }}</text>
|
||||
<text class="od-page__good-qty">×{{ item.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="od-page__divider" />
|
||||
|
||||
<view class="od-page__fees">
|
||||
<view class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">商品小计</text>
|
||||
<text class="od-page__fee-value">¥{{ formatPrice(order.itemsAmount) }}</text>
|
||||
</view>
|
||||
<view class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">打包费</text>
|
||||
<text class="od-page__fee-value">¥{{ formatPrice(order.packagingFee) }}</text>
|
||||
</view>
|
||||
<view v-if="order.deliveryFee > 0" class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">配送费</text>
|
||||
<text class="od-page__fee-value">¥{{ formatPrice(order.deliveryFee) }}</text>
|
||||
</view>
|
||||
<view v-if="order.discountAmount > 0" class="od-page__fee-row">
|
||||
<text class="od-page__fee-label">优惠抵扣</text>
|
||||
<text class="od-page__fee-value od-page__fee-value--green">-¥{{ formatPrice(order.discountAmount) }}</text>
|
||||
</view>
|
||||
<view class="od-page__fee-row od-page__fee-row--total">
|
||||
<text class="od-page__fee-label">实付金额</text>
|
||||
<text class="od-page__fee-value od-page__fee-value--total">
|
||||
<text class="od-page__fee-unit">¥</text>{{ formatPrice(order.paidAmount || order.payableAmount) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Order Info -->
|
||||
<view class="od-page__card">
|
||||
<view class="od-page__info-section">
|
||||
<text class="od-page__section-title">📄 订单信息</text>
|
||||
<view class="od-page__info-rows">
|
||||
<view class="od-page__info-row">
|
||||
<text class="od-page__info-label">订单编号</text>
|
||||
<text class="od-page__info-value">{{ order.orderNo }}</text>
|
||||
</view>
|
||||
<view class="od-page__info-row">
|
||||
<text class="od-page__info-label">下单时间</text>
|
||||
<text class="od-page__info-value">{{ order.createdAt }}</text>
|
||||
</view>
|
||||
<view v-if="order.paidAt" class="od-page__info-row">
|
||||
<text class="od-page__info-label">支付时间</text>
|
||||
<text class="od-page__info-value">{{ order.paidAt }}</text>
|
||||
</view>
|
||||
<view class="od-page__info-row">
|
||||
<text class="od-page__info-label">支付状态</text>
|
||||
<text class="od-page__info-value">{{ order.paymentStatusText }}</text>
|
||||
</view>
|
||||
<view v-if="order.remark" class="od-page__info-row">
|
||||
<text class="od-page__info-label">订单备注</text>
|
||||
<view class="od-page__info-remark">
|
||||
<text>{{ order.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<view class="od-page__bottom-bar">
|
||||
<view class="od-page__btn-outline" @click="handleContact">
|
||||
<text>📞 联系门店</text>
|
||||
</view>
|
||||
<view class="od-page__btn-primary" @click="handlePrimaryAction">
|
||||
<text>{{ order.actionText || '再来一单' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- Not found -->
|
||||
<view v-else class="od-page__empty">
|
||||
<text class="od-page__empty-title">未找到对应订单</text>
|
||||
<view class="od-page__empty-btn" @click="goOrders">
|
||||
<text>返回订单列表</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOrderDetailPage } from './composables/useOrderDetailPage'
|
||||
|
||||
const {
|
||||
fulfillmentHint,
|
||||
formatPrice,
|
||||
goOrders,
|
||||
handleContact,
|
||||
handlePrimaryAction,
|
||||
loading,
|
||||
order,
|
||||
orderSubtitle,
|
||||
sceneLabel,
|
||||
statusDescription,
|
||||
storeAddress,
|
||||
timelineNodeClass
|
||||
} = useOrderDetailPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
229
src/pages/order/detail/styles/base.scss
Normal file
229
src/pages/order/detail/styles/base.scss
Normal file
@@ -0,0 +1,229 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page {
|
||||
min-height: 100vh;
|
||||
padding: 14px 16px 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.od-page__loading,
|
||||
.od-page__empty {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.od-page__loading-text,
|
||||
.od-page__empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.od-page__status-hero {
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 60%, $primary-darker 100%);
|
||||
border-radius: $r-xl;
|
||||
padding: 24px 22px 22px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(22, 163, 74, 0.2);
|
||||
}
|
||||
|
||||
.od-page__status-deco1,
|
||||
.od-page__status-deco2 {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.od-page__status-deco1 {
|
||||
top: -30px;
|
||||
right: -30px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.od-page__status-deco2 {
|
||||
bottom: -20px;
|
||||
left: 40%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.od-page__status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.od-page__status-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__status-icon-text {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.od-page__status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.od-page__status-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.od-page__status-desc {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.od-page__status-badge {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 14px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.od-page__card {
|
||||
background: $card;
|
||||
border-radius: $r-xl;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.od-page__store {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.od-page__store-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.od-page__store-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.od-page__store-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.od-page__store-addr {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.od-page__store-call {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid $border;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.od-page__fulfill {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.od-page__fulfill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.od-page__fulfill-label {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
width: 58px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__fulfill-value {
|
||||
font-size: 14px;
|
||||
color: $text-1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.od-page__table-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 14px;
|
||||
border-radius: $r-sm;
|
||||
background: $primary-lighter;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.od-page__fulfill-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: $primary-lighter;
|
||||
border-radius: $r-sm;
|
||||
font-size: 12px;
|
||||
color: $primary-dark;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.od-page__fulfill-hint-text {
|
||||
font-size: 12px;
|
||||
color: $primary-dark;
|
||||
font-weight: 500;
|
||||
}
|
||||
56
src/pages/order/detail/styles/bottom.scss
Normal file
56
src/pages/order/detail/styles/bottom.scss
Normal file
@@ -0,0 +1,56 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page__bottom-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $card;
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 10px 20px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.od-page__btn-outline,
|
||||
.od-page__btn-primary,
|
||||
.od-page__empty-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.od-page__btn-outline,
|
||||
.od-page__btn-primary {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.od-page__btn-outline {
|
||||
border: 1.5px solid $border;
|
||||
background: $card;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.od-page__btn-primary {
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
box-shadow: 0 3px 12px rgba(22, 163, 74, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.od-page__empty-btn {
|
||||
margin-top: 16px;
|
||||
height: 38px;
|
||||
padding: 0 24px;
|
||||
border-radius: 19px;
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
124
src/pages/order/detail/styles/goods.scss
Normal file
124
src/pages/order/detail/styles/goods.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page__goods-section {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.od-page__goods-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.od-page__good {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 0.5px solid $border;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.od-page__good-img-placeholder {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: $r-sm;
|
||||
background: $border;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__good-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.od-page__good-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.od-page__good-spec {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.od-page__good-right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.od-page__good-price {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.od-page__good-unit {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.od-page__good-qty {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
}
|
||||
|
||||
.od-page__divider {
|
||||
height: 0.5px;
|
||||
background: $border;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.od-page__fees {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.od-page__fee-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
|
||||
&--total {
|
||||
margin-top: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 0.5px solid $border;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__fee-label {
|
||||
font-size: 13px;
|
||||
color: $text-3;
|
||||
|
||||
.od-page__fee-row--total & {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__fee-value {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
font-weight: 500;
|
||||
|
||||
&--green { color: $primary; }
|
||||
|
||||
&--total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__fee-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
4
src/pages/order/detail/styles/index.scss
Normal file
4
src/pages/order/detail/styles/index.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import './base.scss';
|
||||
@import './timeline.scss';
|
||||
@import './goods.scss';
|
||||
@import './bottom.scss';
|
||||
148
src/pages/order/detail/styles/timeline.scss
Normal file
148
src/pages/order/detail/styles/timeline.scss
Normal file
@@ -0,0 +1,148 @@
|
||||
@import '../../../../styles/variables';
|
||||
|
||||
.od-page__timeline-section,
|
||||
.od-page__info-section {
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
|
||||
.od-page__section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.od-page__timeline {
|
||||
position: relative;
|
||||
padding-left: 28px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, $primary 0%, $primary-light 60%, $border 100%);
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__tl-node {
|
||||
position: relative;
|
||||
padding-bottom: 20px;
|
||||
|
||||
&:last-child { padding-bottom: 0; }
|
||||
}
|
||||
|
||||
.od-page__tl-dot {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
|
||||
.od-page__tl-node--done & {
|
||||
background: $primary;
|
||||
box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.12);
|
||||
}
|
||||
|
||||
.od-page__tl-node--current & {
|
||||
background: $primary;
|
||||
box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.15), 0 0 0 10px rgba(22, 163, 74, 0.06);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.od-page__tl-node--pending & {
|
||||
background: $card;
|
||||
border: 2px solid $text-5;
|
||||
}
|
||||
}
|
||||
|
||||
.od-page__tl-check,
|
||||
.od-page__tl-pulse {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.od-page__tl-check {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.od-page__tl-pulse {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.od-page__tl-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.od-page__tl-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-1;
|
||||
line-height: 1.4;
|
||||
|
||||
.od-page__tl-node--pending & { color: $text-5; font-weight: 500; }
|
||||
.od-page__tl-node--current & { color: $primary-dark; }
|
||||
}
|
||||
|
||||
.od-page__tl-time {
|
||||
font-size: 12px;
|
||||
color: $text-4;
|
||||
font-weight: 400;
|
||||
|
||||
.od-page__tl-node--pending & { color: $text-5; }
|
||||
}
|
||||
|
||||
.od-page__tl-sub {
|
||||
font-size: 12px;
|
||||
color: $text-3;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
|
||||
.od-page__tl-node--current & { color: $primary; }
|
||||
}
|
||||
|
||||
.od-page__info-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.od-page__info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.od-page__info-label {
|
||||
font-size: 13px;
|
||||
color: $text-4;
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.od-page__info-value {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.od-page__info-remark {
|
||||
font-size: 13px;
|
||||
color: $text-2;
|
||||
background: $bg;
|
||||
padding: 8px 12px;
|
||||
border-radius: $r-xs;
|
||||
line-height: 1.5;
|
||||
}
|
||||
95
src/pages/orders/composables/useOrdersPage.ts
Normal file
95
src/pages/orders/composables/useOrdersPage.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import { getOrders } from '@/services'
|
||||
import { pinia, useAppStore, useCustomerStore } from '@/stores'
|
||||
import type { OrderSummary } from '@/shared'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待支付' },
|
||||
{ key: 'processing', label: '履约中' },
|
||||
{ key: 'done', label: '已完成' }
|
||||
] as const
|
||||
|
||||
type OrderTabKey = (typeof tabs)[number]['key']
|
||||
|
||||
export function useOrdersPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
const loading = ref(true)
|
||||
const activeTab = ref<OrderTabKey>('all')
|
||||
const orders = ref<OrderSummary[]>([])
|
||||
|
||||
const visibleOrders = computed(() => orders.value.filter((order) => {
|
||||
if (activeTab.value === 'pending') {
|
||||
return order.statusText === '待支付'
|
||||
}
|
||||
|
||||
if (activeTab.value === 'processing') {
|
||||
return !['待支付', '已完成', '已取消'].includes(order.statusText)
|
||||
}
|
||||
|
||||
if (activeTab.value === 'done') {
|
||||
return order.statusText === '已完成'
|
||||
}
|
||||
|
||||
return true
|
||||
}))
|
||||
|
||||
const processingCount = computed(() =>
|
||||
orders.value.filter((order) => !['待支付', '已完成', '已取消'].includes(order.statusText)).length
|
||||
)
|
||||
|
||||
async function loadOrdersList () {
|
||||
loading.value = true
|
||||
try {
|
||||
await appStore.initBootstrap()
|
||||
await appStore.initStores()
|
||||
customerStore.ensureDefaults()
|
||||
orders.value = await getOrders()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOrderTagType (statusText: string) {
|
||||
if (statusText === '待支付') {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
if (statusText === '已完成') {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (statusText === '已取消') {
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function openOrderDetail (orderId: string) {
|
||||
void openRoute(`/pages/order/detail/index?id=${orderId}`)
|
||||
}
|
||||
|
||||
function goMenu () {
|
||||
void openRoute('/pages/menu/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void loadOrdersList()
|
||||
})
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
loading,
|
||||
openOrderDetail,
|
||||
orders,
|
||||
processingCount,
|
||||
resolveOrderTagType,
|
||||
tabs,
|
||||
visibleOrders,
|
||||
goMenu
|
||||
}
|
||||
}
|
||||
3
src/pages/orders/index.config.ts
Normal file
3
src/pages/orders/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '订单'
|
||||
})
|
||||
88
src/pages/orders/index.vue
Normal file
88
src/pages/orders/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view class="page-shell orders-page">
|
||||
<PageHero title="订单" subtitle="查看订单状态、支付情况和配送进度" badge="我的订单">
|
||||
<view class="orders-page__hero-row">
|
||||
<view>
|
||||
<text class="orders-page__hero-label">订单总数</text>
|
||||
<text class="orders-page__hero-value">{{ orders.length }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="orders-page__hero-label">履约中</text>
|
||||
<text class="orders-page__hero-value">{{ processingCount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</PageHero>
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">筛选状态</text>
|
||||
<view class="orders-page__tabs">
|
||||
<NutButton
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
size="small"
|
||||
:type="tab.key === activeTab ? 'primary' : 'default'"
|
||||
:plain="tab.key !== activeTab"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="surface-card">
|
||||
<text class="section-title">订单加载中</text>
|
||||
<view v-for="item in 3" :key="item" class="orders-page__skeleton"></view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="visibleOrders.length">
|
||||
<view v-for="order in visibleOrders" :key="order.id" class="surface-card orders-page__card">
|
||||
<view class="row-between">
|
||||
<view>
|
||||
<text class="section-title">{{ order.storeName }}</text>
|
||||
<text class="caption-text">订单号:{{ order.orderNo }}</text>
|
||||
</view>
|
||||
<Tag round :type="resolveOrderTagType(order.statusText)">{{ order.statusText }}</Tag>
|
||||
</view>
|
||||
<text class="value-text orders-page__summary">{{ order.itemSummary }}</text>
|
||||
<text class="caption-text">支付状态:{{ order.paymentStatusText }}</text>
|
||||
<view class="row-between orders-page__footer">
|
||||
<view>
|
||||
<Price :price="order.totalAmount" />
|
||||
<text class="caption-text orders-page__time">{{ order.createdAt }}</text>
|
||||
</view>
|
||||
<NutButton size="small" type="primary" @click="openOrderDetail(order.id)">{{ order.actionText }}</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="surface-card empty-wrap">
|
||||
<Empty description="当前状态下暂无订单" />
|
||||
<view class="orders-page__empty-action">
|
||||
<NutButton type="primary" block @click="goMenu">去点餐</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton, Empty, Price, Tag } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useOrdersPage } from './composables/useOrdersPage'
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
goMenu,
|
||||
loading,
|
||||
openOrderDetail,
|
||||
orders,
|
||||
processingCount,
|
||||
resolveOrderTagType,
|
||||
tabs,
|
||||
visibleOrders
|
||||
} = useOrdersPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
|
||||
52
src/pages/orders/styles/index.scss
Normal file
52
src/pages/orders/styles/index.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
.orders-page__hero-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orders-page__hero-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
.orders-page__hero-value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.orders-page__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.orders-page__skeleton {
|
||||
height: 92px;
|
||||
margin-top: 14px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.orders-page__summary {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.orders-page__footer {
|
||||
margin-top: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.orders-page__time {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.orders-page__empty-action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
58
src/pages/profile/composables/useProfilePage.ts
Normal file
58
src/pages/profile/composables/useProfilePage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ref } from 'vue'
|
||||
import { showToast, useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useCustomerStore, useFulfillmentStore } from '@/stores'
|
||||
import { openRoute } from '@/utils/router'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useProfilePage () {
|
||||
const customerStore = useCustomerStore(pinia)
|
||||
const fulfillmentStore = useFulfillmentStore(pinia)
|
||||
const draftName = ref(customerStore.name)
|
||||
const draftPhone = ref(customerStore.phone)
|
||||
|
||||
function handleNameInput (event: InputLikeEvent) {
|
||||
draftName.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
function handlePhoneInput (event: InputLikeEvent) {
|
||||
draftPhone.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
async function saveProfile () {
|
||||
customerStore.updateProfile({
|
||||
name: draftName.value,
|
||||
phone: draftPhone.value
|
||||
})
|
||||
await showToast({ title: '信息已保存', icon: 'success' })
|
||||
}
|
||||
|
||||
function goAddress () {
|
||||
void openRoute('/pages/address/index')
|
||||
}
|
||||
|
||||
function goDineIn () {
|
||||
void openRoute('/pages/dinein/confirm/index')
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
draftName.value = customerStore.name
|
||||
draftPhone.value = customerStore.phone
|
||||
})
|
||||
|
||||
return {
|
||||
customerStore,
|
||||
draftName,
|
||||
draftPhone,
|
||||
fulfillmentStore,
|
||||
goAddress,
|
||||
goDineIn,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
saveProfile
|
||||
}
|
||||
}
|
||||
3
src/pages/profile/index.config.ts
Normal file
3
src/pages/profile/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的'
|
||||
})
|
||||
70
src/pages/profile/index.vue
Normal file
70
src/pages/profile/index.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<view class="page-shell profile-page">
|
||||
<PageHero title="我的" subtitle="管理联系人、地址和常用信息">
|
||||
<view class="profile-page__hero-row">
|
||||
<view>
|
||||
<text class="profile-page__hero-label">联系人</text>
|
||||
<text class="profile-page__hero-value">{{ customerStore.name || '未填写' }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="profile-page__hero-label">手机号</text>
|
||||
<text class="profile-page__hero-value">{{ customerStore.phone || '未填写' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</PageHero>
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">顾客信息</text>
|
||||
<input class="field-input" :value="draftName" placeholder="请输入顾客姓名" @input="handleNameInput" />
|
||||
<input class="field-input profile-page__field-gap" :value="draftPhone" type="number" placeholder="请输入手机号" @input="handlePhoneInput" />
|
||||
<view class="profile-page__action-row">
|
||||
<NutButton type="primary" block @click="saveProfile">保存信息</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="surface-card">
|
||||
<view class="row-between">
|
||||
<view>
|
||||
<text class="section-title">常用信息</text>
|
||||
<text class="section-subtitle">提前填写地址或桌号,下单时更方便。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-page__kv-list">
|
||||
<view class="profile-page__kv-item">
|
||||
<text class="caption-text">配送地址</text>
|
||||
<text class="value-text profile-page__break-all">{{ fulfillmentStore.addressText || '暂未填写' }}</text>
|
||||
</view>
|
||||
<view class="profile-page__kv-item">
|
||||
<text class="caption-text">堂食桌号</text>
|
||||
<text class="value-text">{{ fulfillmentStore.tableNo || '暂未填写' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-page__action-grid">
|
||||
<NutButton block plain @click="goAddress">编辑地址</NutButton>
|
||||
<NutButton block plain @click="goDineIn">编辑桌号</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { useProfilePage } from './composables/useProfilePage'
|
||||
|
||||
const {
|
||||
customerStore,
|
||||
draftName,
|
||||
draftPhone,
|
||||
fulfillmentStore,
|
||||
goAddress,
|
||||
goDineIn,
|
||||
handleNameInput,
|
||||
handlePhoneInput,
|
||||
saveProfile
|
||||
} = useProfilePage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
42
src/pages/profile/styles/index.scss
Normal file
42
src/pages/profile/styles/index.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.profile-page__hero-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-page__hero-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
.profile-page__hero-value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.profile-page__field-gap,
|
||||
.profile-page__action-row,
|
||||
.profile-page__action-grid,
|
||||
.profile-page__kv-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.profile-page__kv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-page__break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.profile-page__action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
52
src/pages/store/select/composables/useStoreSelectPage.ts
Normal file
52
src/pages/store/select/composables/useStoreSelectPage.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { navigateBack, showToast, useDidShow } from '@tarojs/taro'
|
||||
import { pinia, useAppStore } from '@/stores'
|
||||
|
||||
interface InputLikeEvent {
|
||||
detail?: {
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useStoreSelectPage () {
|
||||
const appStore = useAppStore(pinia)
|
||||
const keyword = ref('')
|
||||
const filteredStores = computed(() => appStore.stores.filter((store) => {
|
||||
const search = keyword.value.trim().toLowerCase()
|
||||
if (!search) {
|
||||
return true
|
||||
}
|
||||
|
||||
return [store.name, store.address].some((text) => text.toLowerCase().includes(search))
|
||||
}))
|
||||
|
||||
function handleInput (event: InputLikeEvent) {
|
||||
keyword.value = event.detail?.value || ''
|
||||
}
|
||||
|
||||
async function selectStore (storeId: string) {
|
||||
if (storeId === appStore.currentStoreId) {
|
||||
await showToast({ title: '当前已是该门店', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
appStore.setStore(storeId)
|
||||
await showToast({ title: '门店已切换', icon: 'success' })
|
||||
|
||||
setTimeout(() => {
|
||||
void navigateBack()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
void appStore.initStores()
|
||||
})
|
||||
|
||||
return {
|
||||
appStore,
|
||||
filteredStores,
|
||||
handleInput,
|
||||
keyword,
|
||||
selectStore
|
||||
}
|
||||
}
|
||||
3
src/pages/store/select/index.config.ts
Normal file
3
src/pages/store/select/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '门店选择'
|
||||
})
|
||||
60
src/pages/store/select/index.vue
Normal file
60
src/pages/store/select/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<view class="page-shell store-select-page">
|
||||
<PageHero title="门店选择" subtitle="优先进入正确门店,再衔接菜单、配送规则和结算上下文" badge="选店优先">
|
||||
<view class="store-select-page__hero-meta">
|
||||
<text class="store-select-page__hero-text">当前门店:{{ appStore.currentStore.name }}</text>
|
||||
</view>
|
||||
</PageHero>
|
||||
|
||||
<view class="surface-card">
|
||||
<text class="section-title">搜索门店</text>
|
||||
<input class="field-input" :value="keyword" placeholder="输入门店名称或地址" @input="handleInput" />
|
||||
</view>
|
||||
|
||||
<view v-for="store in filteredStores" :key="store.id" :class="['surface-card', 'store-select-page__card', { 'store-select-page__card--active': store.id === appStore.currentStoreId }]">
|
||||
<view class="row-between">
|
||||
<view>
|
||||
<text class="section-title">{{ store.name }}</text>
|
||||
<text class="section-subtitle">{{ store.address }}</text>
|
||||
</view>
|
||||
<Tag round :type="store.id === appStore.currentStoreId ? 'success' : 'primary'">
|
||||
{{ store.id === appStore.currentStoreId ? '当前门店' : '可切换' }}
|
||||
</Tag>
|
||||
</view>
|
||||
|
||||
<view class="store-select-page__content">
|
||||
<text class="caption-text">营业时间:{{ store.businessHours || '商家营业中' }}</text>
|
||||
<view class="inline-tags store-select-page__tag-area">
|
||||
<Tag v-for="tag in store.tagTexts" :key="tag" round plain>{{ tag }}</Tag>
|
||||
</view>
|
||||
<view class="inline-tags store-select-page__tag-area">
|
||||
<Tag v-for="scene in store.supports" :key="scene" round plain type="primary">{{ SCENE_LABEL_MAP[scene] }}</Tag>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="store-select-page__action">
|
||||
<NutButton
|
||||
block
|
||||
:type="store.id === appStore.currentStoreId ? 'default' : 'primary'"
|
||||
:plain="store.id === appStore.currentStoreId"
|
||||
@click="selectStore(store.id)"
|
||||
>
|
||||
{{ store.id === appStore.currentStoreId ? '已在当前门店' : '切换到此门店' }}
|
||||
</NutButton>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button as NutButton, Tag } from '@nutui/nutui-taro'
|
||||
import PageHero from '@/components/page-hero/index.vue'
|
||||
import { SCENE_LABEL_MAP } from '@/shared'
|
||||
import { useStoreSelectPage } from './composables/useStoreSelectPage'
|
||||
|
||||
const { appStore, filteredStores, handleInput, keyword, selectStore } = useStoreSelectPage()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/index.scss';
|
||||
</style>
|
||||
22
src/pages/store/select/styles/index.scss
Normal file
22
src/pages/store/select/styles/index.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.store-select-page__hero-text {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.store-select-page__card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.store-select-page__card--active {
|
||||
border-color: rgba(22, 163, 74, 0.34);
|
||||
}
|
||||
|
||||
.store-select-page__content,
|
||||
.store-select-page__action {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.store-select-page__tag-area {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { PriceEstimateResult } from '@/shared'
|
||||
import { checkoutValidate, estimatePrice } from '@/services'
|
||||
import { buildCheckoutPayload } from './payload'
|
||||
import type { useAppStore, useCartStore } from '@/stores'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
type CartStoreInstance = ReturnType<typeof useCartStore>
|
||||
|
||||
export const emptyEstimate: PriceEstimateResult = {
|
||||
storeId: '',
|
||||
scene: 'Delivery',
|
||||
totalCount: 0,
|
||||
originalAmount: 0,
|
||||
originalAmountText: '0.00',
|
||||
packagingFee: 0,
|
||||
packagingFeeText: '0.00',
|
||||
deliveryFee: 0,
|
||||
deliveryFeeText: '0.00',
|
||||
discountAmount: 0,
|
||||
discountAmountText: '0.00',
|
||||
payableAmount: 0,
|
||||
payableAmountText: '0.00'
|
||||
}
|
||||
|
||||
export function createEstimateActions (payload: {
|
||||
appStore: AppStoreInstance
|
||||
cartStore: CartStoreInstance
|
||||
estimate: Ref<PriceEstimateResult>
|
||||
isEstimating: Ref<boolean>
|
||||
}) {
|
||||
const { appStore, cartStore, estimate, isEstimating } = payload
|
||||
|
||||
async function handleEstimate () {
|
||||
if (!cartStore.itemCount) {
|
||||
estimate.value = emptyEstimate
|
||||
return
|
||||
}
|
||||
|
||||
isEstimating.value = true
|
||||
|
||||
try {
|
||||
const requestPayload = buildCheckoutPayload(appStore, cartStore)
|
||||
const [nextEstimate] = await Promise.all([
|
||||
estimatePrice(requestPayload),
|
||||
checkoutValidate(requestPayload)
|
||||
])
|
||||
estimate.value = nextEstimate
|
||||
} catch {
|
||||
estimate.value = emptyEstimate
|
||||
} finally {
|
||||
isEstimating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleEstimate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MiniChannels } from '@/shared'
|
||||
import type { useAppStore, useCartStore } from '@/stores'
|
||||
|
||||
type AppStoreInstance = ReturnType<typeof useAppStore>
|
||||
type CartStoreInstance = ReturnType<typeof useCartStore>
|
||||
|
||||
export function buildCheckoutPayload (
|
||||
appStore: AppStoreInstance,
|
||||
cartStore: CartStoreInstance
|
||||
) {
|
||||
return {
|
||||
storeId: appStore.currentStore.id,
|
||||
scene: appStore.scene,
|
||||
channel: appStore.channel || MiniChannels.WeChatMiniProgram,
|
||||
items: cartStore.lineList.map((line) => ({
|
||||
productId: line.productId,
|
||||
skuId: line.skuId,
|
||||
quantity: line.quantity,
|
||||
addonItemIds: line.addonItemIds
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FulfillmentScenes, SCENE_LABEL_MAP } from '@/shared'
|
||||
import type { FulfillmentScene } from '@/shared'
|
||||
|
||||
export function resolveSceneLabel (scene: FulfillmentScene) {
|
||||
return SCENE_LABEL_MAP[scene]
|
||||
}
|
||||
|
||||
export function resolveSceneSuffix (scene: FulfillmentScene) {
|
||||
if (scene === FulfillmentScenes.Delivery) return '配送'
|
||||
if (scene === FulfillmentScenes.Pickup) return '自提'
|
||||
return '用餐'
|
||||
}
|
||||
|
||||
export function resolveSceneHint (scene: FulfillmentScene) {
|
||||
if (scene === FulfillmentScenes.Delivery) return '预计 30 分钟送达'
|
||||
if (scene === FulfillmentScenes.Pickup) return '下单后可到店取餐'
|
||||
return '下单后商家将尽快制作'
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user