初始化
缺陷:主题配色需要优化整体的同风格
This commit is contained in:
40
.cursor/rules/lgp-pitfalls.mdc
Normal file
40
.cursor/rules/lgp-pitfalls.mdc
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: LGP 新后端开发约定与全项目群已知陷阱
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 开发约定与已知陷阱
|
||||
|
||||
## 新后端 lgp-admin-plus-api
|
||||
|
||||
- 路由由 `UtilsService::autoRouteRegister()` **反射注册**,继承自 `BaseController` 的方法也会被注册成路由。不需要暴露的继承方法必须用 `@Method NO` 覆盖,否则每个控制器白送一套 `create/update/delete`
|
||||
- 方法名 camelCase 会转成 kebab-case 路径:`myInfo` → `my-info`
|
||||
- 缺 `@Method` 注解会注册成 `ANY`,务必显式写 `@Method GET` 或 `@Method POST`
|
||||
- 响应统一走 `jok` / `jerr`,结构固定 `{code, message, result}`,成功 `code=0`。前端拦截器按 `result` 取数据,**不要用 `data`**
|
||||
- 业务表 Model 覆盖 `$connection = 'business'`;系统表沿用默认 `mysql`
|
||||
- 免鉴权 Service 继承 `BaseNotAuthService`,不要像老项目那样硬编码路径白名单
|
||||
- 鉴权真正发生在 `BaseService::__construct`,绕过 BaseService 的方法就没有鉴权。新代码一律走 BaseService
|
||||
- `nl_menu` 的 `keep_alive` 与 `affix_tab` 输出到 meta 时是**取反**的,插数据别弄反
|
||||
- 第三方凭据用 `FieldEncryptService` 加密入库,不回显明文
|
||||
- **库表变更禁止新建 PHP Migration**:写 `database/sql/<YYYY-MM-DD>/01_名字.sql`(当天序号递增、尽量幂等);系统表同步 `public/nl_admin.sql`,业务表 `cc_*` 只放 sql 目录
|
||||
|
||||
## 老项目只读
|
||||
|
||||
`lgp-api` / `lgp-wx-api` / `lgp-vben` / `lgp-vben-new` 处于迁移期,除安全修复外不要改动。
|
||||
查业务逻辑可以读,新功能一律写在 `lgp-admin-plus-api` + `lgp-admin-plus`。
|
||||
|
||||
## 已知陷阱
|
||||
|
||||
- 老 `lgp-wx-api` 把微信 `session_key` 当 token 直接返回客户端,且 `AuthMiddleware` 先执行控制器再检查 token、只判空不验签。归并时必须改成服务端自签 token
|
||||
- 老 `lgp-api` 密码是**无盐 sha1**,新后端是 bcrypt,靠 `nl_admin.legacy_password` 惰性升级,遗留列有过期时间
|
||||
- 老 `cc_role.id=1` 不是超管,而新 `nl_role.id=1` 是超管且代码硬判断全量放行。角色迁移必须做 ID 偏移
|
||||
- 老前端 10 个模块的批量删除在静默 404(后端只有 `template` 注册了 `delete-batch`)
|
||||
- `cc_price_sheet` 的 6 个材质列是死 schema,`create()` 从不赋值、前端已注释,实际只有 `routine` 在用
|
||||
- 图片水印不是后端接口,是前端拼 OSS URL 参数 `?watermark/2/text/...`
|
||||
- 小程序 `baseURL` 硬编码在 `api/interceptor.js` 且靠注释切换,极易把测试地址发上线
|
||||
- 小程序无分包配置,uview-plus 全量进主包
|
||||
|
||||
## 安全红线
|
||||
|
||||
不要把任何密钥写进源码。老项目里七牛 AK/SK(`UploadService.php`)、微信 AppSecret(`WeChatService.php`)明文硬编码,
|
||||
`.env.example` 里还提交了真实数据库密码——这些是待清理的历史欠账,不要照抄这个做法。
|
||||
46
.cursor/rules/lgp-project-map.mdc
Normal file
46
.cursor/rules/lgp-project-map.mdc
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: LGP 项目群架构、各仓库角色与双品牌对应关系
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# LGP 项目地图
|
||||
|
||||
两个家具品牌共用一套代码,正从老架构迁往新架构。
|
||||
|
||||
## 新架构(开发主战场)
|
||||
|
||||
- `lgp-admin-plus` — 后台前端,vben **5.7.0** monorepo,主应用 `apps/web-antd`,上游是自研 `nl-admin-view`
|
||||
- `lgp-admin-plus-api` — 后端,**Laravel 13**,位于 `sites/lgp-api/index/lgp-admin-plus-api`
|
||||
|
||||
## 老架构(迁移期只读,迁完归档)
|
||||
|
||||
- `lgp-vben` — 老后台前端,vben 2.11.5
|
||||
- `lgp-api` — 老后台后端,Laravel 10
|
||||
- `lgp-wx-api` — 老小程序后端,Laravel 12,本期归并进新后端的 `wx` 路由组
|
||||
- `lgp-vben-new` — **已弃用**,与 `lgp-vben` 同源克隆,不要在里面做任何改动
|
||||
|
||||
## 小程序(uni-app + Vue3 + uview-plus)
|
||||
|
||||
- `lgp-wx` — 佛山铂尔曼,AppID `wx679d36842570cea7`,API `brm.wx.api.borman.top`
|
||||
- `lgp-wx-new` — 维伦家具,AppID `wx57b54060a579c31a`,API `wx.api.borman.top`
|
||||
|
||||
两者页面与业务代码基本一致,差异只有 AppID、`baseURL` 与首页视觉。改一边通常要同步另一边。
|
||||
|
||||
## 双品牌
|
||||
|
||||
同一套后台代码部署两份,靠构建模式区分:`build:brm`(佛山铂尔曼)/ `build:wl`(维伦家具)。
|
||||
两个品牌共用一套代码与数据库,任何业务改动会同时影响两家客户,上线前按品牌分别回归。
|
||||
|
||||
## 数据库
|
||||
|
||||
同一个库、两个连接(`config/database.php`):
|
||||
|
||||
- `mysql`,前缀 `nl_` — 脚手架系统表(admin / role / menu / oss_config / file / api_endpoint 等)
|
||||
- `business`,前缀 `cc_` — 业务表(catalogue / category / image / price_sheet / list / wx_user / enterprise 等)
|
||||
|
||||
业务数据零迁移,只迁账号与角色。业务 Model 必须覆盖 `protected $connection = 'business'`。
|
||||
|
||||
## 迁移范围
|
||||
|
||||
老后端注册 171 条路由但前端只调用 **79** 条;老小程序后端注册 35 条但小程序只调用 **14** 条。
|
||||
迁移目标是这 93 条,其余是反射自动注册出来的死路由,不要跟着迁。
|
||||
6
App.vue
6
App.vue
@@ -1,11 +1,17 @@
|
||||
<script>
|
||||
import { loginApi } from "@/api/page/auth";
|
||||
import { getCache, setCache } from "@/utils/cache";
|
||||
import { loadAndApplyTheme } from "@/utils/theme";
|
||||
|
||||
export default {
|
||||
onLaunch: function () {
|
||||
console.log("App Launch");
|
||||
|
||||
// 启动即拉主题;失败只打日志,不阻塞登录
|
||||
loadAndApplyTheme().catch((e) => {
|
||||
console.warn("[theme] onLaunch failed", e);
|
||||
});
|
||||
|
||||
// 检查更新
|
||||
if (uni.canIUse('getUpdateManager')) {
|
||||
const updateManager = uni.getUpdateManager();
|
||||
|
||||
92
api/env.js
Normal file
92
api/env.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 小程序运行环境与品牌配置(统一入口)
|
||||
*
|
||||
* 老代码在 interceptor.js 里把 5 个 baseURL 用注释切换,极易把测试地址发上线。
|
||||
* 这里把所有环境相关的值收口到一个文件,interceptor.js 只读这里的常量。
|
||||
*
|
||||
* 【品牌区分】
|
||||
* lgp-wx 和 lgp-wx-new 是同一套代码的两个目录,差异只有品牌(brm=铂尔曼 / wl=维伦)。
|
||||
* 因为 uni-app 条件编译(#ifdef)只支持平台维度(MP-WEIXIN 等),不支持自定义变量,
|
||||
* 而本项目又是纯 HBuilderX 工程(没有 CLI 环境变量注入),唯一可靠的品牌区分点
|
||||
* 是 manifest.json 的 mp-weixin.appid。这里用 BRAND 常量收敛:
|
||||
* - 铂尔曼项目(lgp-wx):BRAND = 'brm'
|
||||
* - 维伦项目(lgp-wx-new):BRAND = 'wl'
|
||||
* 同步到另一个项目时只改这一行 BRAND,baseURL / appid / 名称会自动跟着切。
|
||||
*
|
||||
* 【环境区分】
|
||||
* ENV 用 process.env.NODE_ENV 区分开发/生产(uni-app 编译时注入),
|
||||
* 默认生产,开发环境用 LOCAL_BASE。
|
||||
*/
|
||||
|
||||
/** 当前品牌:'brm' 铂尔曼 / 'wl' 维伦 —— 同步项目时改这一行 */
|
||||
export const BRAND = 'wl';
|
||||
|
||||
/**
|
||||
* 品牌维度的配置表。
|
||||
* appid 必须与 manifest.json 的 mp-weixin.appid 保持一致(构建时校验,防止串包)。
|
||||
* baseURL 是该品牌的线上 API 地址。
|
||||
* name 是该品牌的展示名。
|
||||
*/
|
||||
export const BRAND_CONFIG = {
|
||||
brm: {
|
||||
appid: 'wx679d36842570cea7',
|
||||
baseURL: 'https://brm.wx.api.borman.top/api/wx/',
|
||||
name: '佛山铂尔曼',
|
||||
},
|
||||
wl: {
|
||||
appid: 'wx57b54060a579c31a',
|
||||
baseURL: 'https://wx.api.borman.top/api/wx/',
|
||||
name: '维伦家具',
|
||||
},
|
||||
};
|
||||
|
||||
/** 本地联调地址(开发环境用,按需指向本机或测试服) */
|
||||
export const LOCAL_BASE = 'http://127.0.0.1:18004/api/wx/';
|
||||
|
||||
/**
|
||||
* 当前环境:开发模式下走本地地址,否则走品牌线上地址。
|
||||
* process.env.NODE_ENV 由 uni-app 编译时注入(HBuilderX 运行=development,发行=production)。
|
||||
*/
|
||||
const ENV = process.env.NODE_ENV === 'development' ? 'dev' : 'prod';
|
||||
|
||||
/** 当前生效的 API baseURL,interceptor.js 只读这一个值 */
|
||||
export const API_BASE =
|
||||
ENV === 'dev' ? LOCAL_BASE : BRAND_CONFIG[BRAND].baseURL;
|
||||
|
||||
/** 当前品牌名(页面标题等展示用) */
|
||||
export const BRAND_NAME = BRAND_CONFIG[BRAND].name;
|
||||
|
||||
/** 当前品牌 appid(与 manifest.json 校验用) */
|
||||
export const BRAND_APPID = BRAND_CONFIG[BRAND].appid;
|
||||
|
||||
/**
|
||||
* 启动时校验 manifest.json 的 appid 与 BRAND 是否匹配。
|
||||
* 不匹配说明改了 BRAND 忘了改 manifest(或反之),构建出的包会串品牌,必须报错。
|
||||
*/
|
||||
function checkBrandAppid() {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序端用 wx.getAccountInfoSync 拿真实 appid 校验
|
||||
try {
|
||||
const info = wx.getAccountInfoSync();
|
||||
const realAppid = info && info.miniProgram && info.miniProgram.appId;
|
||||
if (realAppid && realAppid !== BRAND_APPID) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[env] 品牌不匹配:当前 BRAND=${BRAND}(期望 appid=${BRAND_APPID}),但小程序真实 appid=${realAppid}。请检查 manifest.json 或 api/env.js 的 BRAND。`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// 获取失败不阻塞,仅打日志
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
checkBrandAppid();
|
||||
|
||||
export default {
|
||||
BRAND,
|
||||
API_BASE,
|
||||
BRAND_NAME,
|
||||
BRAND_APPID,
|
||||
ENV,
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
// 定义一个全局请求配置(可被多个拦截器修改)
|
||||
import {setCache} from "@/utils/cache";
|
||||
import {API_BASE, BRAND} from './env.js';
|
||||
|
||||
export let globalConfig = {
|
||||
baseURL: 'https://wx.api.borman.top/api/',
|
||||
// baseURL: 'http://brm.wx.api.borman.top/api/',
|
||||
// baseURL: 'https://wx.boerman.top/api/',
|
||||
// baseURL: 'http://127.0.0.1:18004/api/', // 本地测试地址
|
||||
// baseURL: 'http://127.0.0.1:18006/api/', // 本地测试地址
|
||||
// baseURL 由 api/env.js 统一注入:开发环境走本地,生产环境按 BRAND(brm/wl)切换
|
||||
baseURL: API_BASE,
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
// 双品牌共用后端时靠这个分流 AppID / 默认装修模板
|
||||
'X-App-Code': BRAND,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,10 +48,8 @@ export async function runResponseInterceptors(response) {
|
||||
|
||||
// 初始化默认拦截器(可选)
|
||||
addRequestInterceptor(config => {
|
||||
// 自动携带token示例
|
||||
// 自动携带token:从本地缓存读,不再有硬编码测试 token
|
||||
const token = uni.getStorageSync('token');
|
||||
// 临时token(测试用)
|
||||
// const token = 'JG7hKhTQoANh/hyL67BzIg==';
|
||||
if (token) {
|
||||
config.header.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ import {get, post} from '../request';
|
||||
|
||||
const prefix = 'list/'
|
||||
|
||||
export const getCartListApi = async () => {
|
||||
return await get(`${prefix}list`)
|
||||
/**
|
||||
* 我的清单列表
|
||||
* - 不传参数:返回全量数组(老调用方兼容)
|
||||
* - 传 { page, pageSize }:后端返回 { items, total, page, pageSize, has_more }
|
||||
* @param {{page?: number, pageSize?: number}} params
|
||||
*/
|
||||
export const getCartListApi = async (params) => {
|
||||
return await get(`${prefix}list`, params)
|
||||
}
|
||||
export const getCartItemApi = async (id) => {
|
||||
return await get(`${prefix}detail`, {id})
|
||||
@@ -21,10 +27,24 @@ export const deleteCartItemApi = async (id) => {
|
||||
ids: [id]
|
||||
})
|
||||
}
|
||||
export const toCartApi = async (listId, productId) => {
|
||||
export const toCartApi = async (listId, productId, extra = {}) => {
|
||||
return await post(`${prefix}to-cart`, {
|
||||
list_id: listId,
|
||||
product_id: productId
|
||||
product_id: productId,
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 改清单明细的规格 / 数量 / 备注。
|
||||
* 对齐后端 wx/list/update-item(WxListService::updateItem),
|
||||
* 只传需要改的字段即可,后端按 array_key_exists 判断。
|
||||
* @param {number} id 明细 id(list_item.id,不是清单 id)
|
||||
* @param {{price_sheet_id?: number, quantity?: number, material_key?: string, remark?: string}} params
|
||||
*/
|
||||
export const updateItemApi = async (id, params = {}) => {
|
||||
return await post(`${prefix}update-item`, {
|
||||
id,
|
||||
...params
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import {get, post} from '../request';
|
||||
|
||||
const prefix = 'w-oss/'
|
||||
|
||||
export const getImageApi = async (hash = '') => {
|
||||
return await get(`${prefix}getImage`, {
|
||||
hash
|
||||
})
|
||||
}
|
||||
export const uploadImageWatermarkApi = async (imageUrl = '', watermark = 'BRM') => {
|
||||
return await post(`${prefix}watermark`, {
|
||||
image_url: imageUrl,
|
||||
watermark
|
||||
})
|
||||
}
|
||||
96
api/page/order.js
Normal file
96
api/page/order.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import {get, post} from '../request';
|
||||
import {API_BASE} from '../env';
|
||||
|
||||
/**
|
||||
* 订单 API(对齐后端 lgp-admin-plus-api/app/Service/wx/WxOrderService.php)
|
||||
* 前缀 wx/order,走鉴权组(nl.wx 中间件)。
|
||||
* 金额字段后端是整数分,详情接口会带 *_text 元字段,这里直接用。
|
||||
*/
|
||||
const prefix = 'order/'
|
||||
|
||||
/** 我的订单列表,可按状态筛,透传分页参数 {status, page, pageSize} */
|
||||
export const getOrderListApi = async (params = {}) => {
|
||||
return await get(`${prefix}list`, params)
|
||||
}
|
||||
|
||||
/** 订单详情(含明细、支付记录、发货记录) */
|
||||
export const getOrderDetailApi = async (id) => {
|
||||
return await get(`${prefix}detail`, {id})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单转订单。
|
||||
* @param listId 清单 id
|
||||
* @param params 收货信息:receiver_name / receiver_phone / receiver_address / delivery_type / remark
|
||||
*/
|
||||
export const createOrderApi = async (listId, params = {}) => {
|
||||
return await post(`${prefix}create`, {list_id: listId, ...params})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传转账凭证。
|
||||
* @param orderId 订单 id
|
||||
* @param voucher 凭证图 URL(多张逗号分隔或数组)
|
||||
* @param amount 可选,本次转账金额(分),不传则按订单未付金额
|
||||
*/
|
||||
export const submitVoucherApi = async (orderId, voucher, amount) => {
|
||||
const body = {order_id: orderId, voucher}
|
||||
if (amount !== undefined && amount !== null) {
|
||||
body.amount = amount
|
||||
}
|
||||
return await post(`${prefix}voucher`, body)
|
||||
}
|
||||
|
||||
/** 调起微信 JSAPI 支付,后端返回 uni.requestPayment 所需参数 */
|
||||
export const wechatPayApi = async (orderId) => {
|
||||
return await post(`${prefix}wechat-pay`, {order_id: orderId})
|
||||
}
|
||||
|
||||
/** 取消订单(仅未付款/已付款未发货可用) */
|
||||
export const cancelOrderApi = async (id) => {
|
||||
return await post(`${prefix}cancel`, {id})
|
||||
}
|
||||
|
||||
/** 确认收货(已发货状态可用) */
|
||||
export const completeOrderApi = async (id) => {
|
||||
return await post(`${prefix}complete`, {id})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传转账凭证图片到 OSS(wx/upload/image)。
|
||||
*
|
||||
* uni.uploadFile 不走 request.js 拦截器,所以这里手动拼 baseURL + token + 解包 result。
|
||||
* 返回后端 result.url(可直接访问的图片地址),供 submitVoucher 拼成逗号串提交。
|
||||
*
|
||||
* @param filePath chooseImage 返回的本地 tempFilePath
|
||||
* @returns Promise<string> OSS 图片 URL
|
||||
*/
|
||||
export const uploadVoucherImageApi = (filePath) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token')
|
||||
uni.uploadFile({
|
||||
url: `${API_BASE}upload/image`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: token ? {Authorization: `Bearer ${token}`} : {},
|
||||
success: (res) => {
|
||||
try {
|
||||
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
if (body.code === 0 && body.result && body.result.url) {
|
||||
resolve(body.result.url)
|
||||
} else {
|
||||
uni.showToast({title: body.message || '上传失败', icon: 'none'})
|
||||
reject(new Error(body.message || '上传失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({title: '上传失败', icon: 'none'})
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({title: '上传失败', icon: 'none'})
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
19
api/page/theme.js
Normal file
19
api/page/theme.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 主题 API(免鉴权)
|
||||
* baseURL 已含 /api/wx/,这里请求 theme → GET .../api/wx/theme
|
||||
*/
|
||||
import { get } from '../request'
|
||||
|
||||
/** 当前生效主题(含 tokens / layout / css_vars) */
|
||||
export const getThemeApi = async (code = '') => {
|
||||
const params = {}
|
||||
if (code) {
|
||||
params.code = code
|
||||
}
|
||||
return await get('theme', params)
|
||||
}
|
||||
|
||||
/** 可选风格列表 */
|
||||
export const getThemeGalleryApi = async () => {
|
||||
return await get('theme-gallery')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
100
mixins/productWaterfall.js
Normal file
100
mixins/productWaterfall.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 商品瀑布流通用 mixin
|
||||
*
|
||||
* product.vue / search.vue 的瀑布流高度同构:data 字段、左右两列 computed、
|
||||
* 触底加载更多、下拉刷新这些样板代码两边一模一样。这里抽公共,避免改一处忘改另一处。
|
||||
*
|
||||
* 约定(引入本 mixin 的页面必须遵守):
|
||||
* - 页面自己实现 getProductList():负责调接口、按 this.page 把结果赋值/追加到 this.products、
|
||||
* 维护 this.noMoreData 与 this.loading / this.loadingMore。
|
||||
* - getProductList 应返回 Promise(供 refreshProductList 关闭下拉动画)。
|
||||
* - 页面的 onReachBottom / onPullDownRefresh 不用再写,mixin 已接管。
|
||||
*
|
||||
* 故意只抽 data + computed + 两个生命周期,不把 getProductList 收进来,
|
||||
* 因为两个页面的请求参数不同(product 带 category_id,search 只带 title),硬塞进来反而更乱。
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 当前页码,从 1 开始
|
||||
page: 1,
|
||||
// 每页条数,与后端约定 10
|
||||
pageSize: 10,
|
||||
// 商品列表(扁平数组),computed 会拆成左右两列
|
||||
products: [],
|
||||
// 首屏 loading(控制骨架屏显隐)
|
||||
loading: false,
|
||||
// 触底加载更多 loading
|
||||
loadingMore: false,
|
||||
// 是否没有更多数据
|
||||
noMoreData: false,
|
||||
// 后端总页数(部分接口会带 last_page)
|
||||
totalPages: 1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 偶数下标 → 左列
|
||||
leftProducts() {
|
||||
return this.products.filter((_, index) => index % 2 === 0)
|
||||
},
|
||||
// 奇数下标 → 右列
|
||||
rightProducts() {
|
||||
return this.products.filter((_, index) => index % 2 === 1)
|
||||
},
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refreshProductList()
|
||||
},
|
||||
onReachBottom() {
|
||||
this.loadMoreProducts()
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 下拉刷新:回到第一页重新拉,结束后关闭下拉动画 + toast
|
||||
* 依赖页面 getProductList 返回 Promise
|
||||
*/
|
||||
refreshProductList() {
|
||||
this.page = 1
|
||||
this.products = []
|
||||
this.noMoreData = false
|
||||
const p = this.getProductList()
|
||||
Promise.resolve(p)
|
||||
.then(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
uni.showToast({
|
||||
title: '刷新成功',
|
||||
icon: 'success',
|
||||
duration: 1500,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
uni.showToast({
|
||||
title: '刷新失败',
|
||||
icon: 'none',
|
||||
duration: 1500,
|
||||
})
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 触底加载更多:加锁 + 页码 +1 后交页面 getProductList 处理
|
||||
* 失败时回退页码,避免下次再触底跳页
|
||||
*/
|
||||
loadMoreProducts() {
|
||||
if (this.loadingMore || this.noMoreData) return
|
||||
this.page++
|
||||
this.loadingMore = true
|
||||
Promise.resolve(this.getProductList())
|
||||
.catch(() => {
|
||||
this.page--
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingMore = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
50
mixins/theme.js
Normal file
50
mixins/theme.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getActiveVars, loadAndApplyTheme } from '@/utils/theme'
|
||||
|
||||
/**
|
||||
* 页面主题 mixin:把后端 css_vars 绑到页面可读的计算属性
|
||||
* 小程序无法动态改全局 scss,必须在页面 :style 里消费这些值
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
themeVars: getActiveVars(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/** 页面根节点背景/文字 */
|
||||
themePageStyle() {
|
||||
const v = this.themeVars || {}
|
||||
return {
|
||||
backgroundColor: v['--color-bg'] || '#F7F8FA',
|
||||
color: v['--color-text'] || '#1a1a1a',
|
||||
}
|
||||
},
|
||||
themePrimary() {
|
||||
return (this.themeVars && this.themeVars['--color-primary']) || '#B08D57'
|
||||
},
|
||||
themeSurface() {
|
||||
return (this.themeVars && this.themeVars['--color-surface']) || '#ffffff'
|
||||
},
|
||||
themeText() {
|
||||
return (this.themeVars && this.themeVars['--color-text']) || '#1a1a1a'
|
||||
},
|
||||
themeTextSoft() {
|
||||
return (this.themeVars && this.themeVars['--color-text-soft']) || '#666666'
|
||||
},
|
||||
themeBorder() {
|
||||
return (this.themeVars && this.themeVars['--color-border']) || '#eeeeee'
|
||||
},
|
||||
themeRadius() {
|
||||
return (this.themeVars && this.themeVars['--radius-md']) || '16rpx'
|
||||
},
|
||||
},
|
||||
async onShow() {
|
||||
// 每次进页刷新:后台改默认模板后无需杀掉小程序
|
||||
try {
|
||||
const theme = await loadAndApplyTheme()
|
||||
this.themeVars = (theme && theme.css_vars) || getActiveVars()
|
||||
} catch (e) {
|
||||
this.themeVars = getActiveVars()
|
||||
}
|
||||
},
|
||||
}
|
||||
31
pages.json
31
pages.json
@@ -69,18 +69,33 @@
|
||||
"navigationBarTitleText": "搜索",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的订单",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundColor": "#f8f8f8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单详情",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "uni-app",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F8F8F8"
|
||||
"backgroundColor": "#f6f6f8"
|
||||
},
|
||||
"uniIdRouter": {},
|
||||
"tabBar": {
|
||||
"color": "#7A7E83",
|
||||
"selectedColor": "#3cc51f",
|
||||
"selectedColor": "#b8945e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"list": [
|
||||
{
|
||||
@@ -95,12 +110,12 @@
|
||||
// "iconPath": "static/tabbar/classify.png",
|
||||
// "selectedIconPath": "static/tabbar/classify-fill.png"
|
||||
// },
|
||||
// {
|
||||
// "pagePath": "pages/order/order",
|
||||
// "text": "我的订单",
|
||||
// "iconPath": "static/icon/order.png",
|
||||
// "selectedIconPath": "static/icon/order.png"
|
||||
// },
|
||||
{
|
||||
"pagePath": "pages/order/list",
|
||||
"text": "订单",
|
||||
"iconPath": "static/tabbar/v2/home.png",
|
||||
"selectedIconPath": "static/tabbar/v2/home-fill.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mine/mine",
|
||||
"text": "我",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<text class="header-subtitle">My COLLECTIONS</text>
|
||||
</view>
|
||||
<view class="header-badges">
|
||||
<text class="count-badge">共计: {{ lists.length }}</text>
|
||||
<text class="count-badge">共计: {{ total }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -18,7 +18,7 @@
|
||||
<view v-if="lists.length > 0" class="list-wrapper">
|
||||
<view
|
||||
v-for="(item, index) in lists"
|
||||
:key="index"
|
||||
:key="item.id"
|
||||
class="list-card animate-slide-in"
|
||||
:style="{ animationDelay: index * 0.05 + 's' }"
|
||||
@tap="goToDetail(item)"
|
||||
@@ -64,12 +64,16 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-else>
|
||||
<view class="empty-state" v-else-if="!loading">
|
||||
<image src="/static/images/empty.png" mode="widthFix" class="empty-img"></image>
|
||||
<text class="empty-text">您还没有创建任何清单</text>
|
||||
<text class="empty-sub">点击下方按钮开始创建</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<view v-if="loading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="noMore && lists.length > 0" class="load-more-tip">没有更多了</view>
|
||||
|
||||
<!-- 底部垫高 -->
|
||||
<view style="height: 180rpx;"></view>
|
||||
</scroll-view>
|
||||
@@ -138,12 +142,17 @@ export default {
|
||||
name: '',
|
||||
remark: ''
|
||||
},
|
||||
// swipeOptions 已移除,不再需要
|
||||
lists: []
|
||||
// 分页相关:page 从 1 开始,pageSize 与后端约定 20
|
||||
lists: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
loading: false,
|
||||
noMore: false
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.getList();
|
||||
this.getList(true);
|
||||
},
|
||||
methods: {
|
||||
navigateBack() {
|
||||
@@ -156,10 +165,37 @@ export default {
|
||||
this.showModal = false
|
||||
this.formData = { name: '', remark: '' }
|
||||
},
|
||||
getList() {
|
||||
getCartListApi().then((res) => {
|
||||
this.lists = res
|
||||
})
|
||||
/**
|
||||
* 拉取清单
|
||||
* @param {boolean} reset 是否重置到第一页(下拉刷新/创建后/进页面)
|
||||
*/
|
||||
getList(reset = false) {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
if (reset) {
|
||||
this.page = 1
|
||||
this.noMore = false
|
||||
}
|
||||
getCartListApi({page: this.page, pageSize: this.pageSize})
|
||||
.then((res) => {
|
||||
// 后端分页模式返回 { items, total, has_more };兼容老的全量数组返回
|
||||
const items = Array.isArray(res) ? res : (res.items || [])
|
||||
if (reset) {
|
||||
this.lists = items
|
||||
} else {
|
||||
this.lists = this.lists.concat(items)
|
||||
}
|
||||
if (!Array.isArray(res)) {
|
||||
this.total = res.total || 0
|
||||
this.noMore = res.has_more === false
|
||||
} else {
|
||||
this.total = items.length
|
||||
this.noMore = true
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
createList() {
|
||||
if (!this.formData.name) {
|
||||
@@ -177,7 +213,7 @@ export default {
|
||||
title: '已创建',
|
||||
icon: 'success'
|
||||
})
|
||||
this.getList();
|
||||
this.getList(true);
|
||||
})
|
||||
},
|
||||
goToDetail(item) {
|
||||
@@ -185,8 +221,13 @@ export default {
|
||||
url: `/pages/cart/detail?id=${item.id}`
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 触底加载更多:仅在还有数据且未在加载中时翻页
|
||||
*/
|
||||
loadMore() {
|
||||
// 暂无更多逻辑
|
||||
if (this.noMore || this.loading) return
|
||||
this.page += 1
|
||||
this.getList(false)
|
||||
},
|
||||
handleDelete(index, id) {
|
||||
uni.showModal({
|
||||
@@ -199,6 +240,7 @@ export default {
|
||||
if (res.confirm) {
|
||||
deleteCartApi(id).then(() => {
|
||||
this.lists.splice(index, 1)
|
||||
this.total = Math.max(0, this.total - 1)
|
||||
uni.showToast({
|
||||
title: '已删除',
|
||||
icon: 'none'
|
||||
@@ -504,4 +546,12 @@ view, scroll-view, text, image {
|
||||
.custom-input {
|
||||
padding: 20rpx 0 !important;
|
||||
}
|
||||
|
||||
/* 加载更多 / 没有更多 提示 */
|
||||
.load-more-tip {
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
color: #bbb;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,10 @@
|
||||
</view>
|
||||
|
||||
<view class="top-right">
|
||||
<!-- 修改规格/数量:转订单前补齐 price_sheet_id -->
|
||||
<view class="action-btn edit" @tap.stop="openItemEditor(item, index)">
|
||||
<u-icon name="edit-pen" color="#B4854D" size="16"></u-icon>
|
||||
</view>
|
||||
<!-- 删除按钮 -->
|
||||
<view class="action-btn delete" @tap.stop="handleDelete(index, item.id)">
|
||||
<u-icon name="trash" color="#999" size="18"></u-icon>
|
||||
@@ -91,12 +95,57 @@
|
||||
<view class="safe-bottom"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 修改规格/数量抽屉:用 page-container 防误退,v-if 控制 -->
|
||||
<page-container
|
||||
v-if="editor.visible"
|
||||
:show="editor.visible"
|
||||
:overlay="true"
|
||||
position="right"
|
||||
@afterleave="editor.visible = false"
|
||||
>
|
||||
<view class="editor-wrap">
|
||||
<view class="editor-head">
|
||||
<text class="editor-title">修改规格 / 数量</text>
|
||||
<text class="editor-close" @click="editor.visible = false">关闭</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="editor-body">
|
||||
<view
|
||||
v-for="(spec, sIdx) in editor.sheets"
|
||||
:key="sIdx"
|
||||
class="editor-spec-row"
|
||||
:class="{ active: editor.priceSheetId === spec.id }"
|
||||
@click="editor.priceSheetId = spec.id"
|
||||
>
|
||||
<view class="editor-spec-info">
|
||||
<text class="editor-spec-name">{{ spec.specification }}</text>
|
||||
<text class="editor-spec-dim" v-if="spec.dimension">{{ spec.dimension }}</text>
|
||||
</view>
|
||||
<view class="editor-spec-price" v-if="spec.routine && spec.routine.length">
|
||||
<text>¥{{ spec.routine[0] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="editor-empty" v-if="editor.sheets.length === 0">
|
||||
该商品暂无规格可选
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="editor-qty">
|
||||
<text class="editor-qty-label">数量</text>
|
||||
<view class="editor-qty-ctrl">
|
||||
<view class="editor-qty-btn" @click="changeEditorQty(-1)">-</view>
|
||||
<text class="editor-qty-num">{{ editor.quantity }}</text>
|
||||
<view class="editor-qty-btn" @click="changeEditorQty(1)">+</view>
|
||||
</view>
|
||||
</view>
|
||||
<button class="editor-save" :disabled="editor.saving" @click="saveItem">保存</button>
|
||||
</view>
|
||||
</page-container>
|
||||
|
||||
<u-toast ref="uToast"></u-toast>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {deleteCartItemApi, getCartItemApi} from "@/api/page/cart";
|
||||
import {deleteCartItemApi, getCartItemApi, updateItemApi} from "@/api/page/cart";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -105,6 +154,16 @@ export default {
|
||||
listName: '清单详情',
|
||||
info: { items: [] },
|
||||
// swipeOptions 已彻底移除
|
||||
// 明细编辑器:转订单前补齐 price_sheet_id / 数量
|
||||
editor: {
|
||||
visible: false,
|
||||
itemId: 0,
|
||||
index: -1,
|
||||
sheets: [],
|
||||
priceSheetId: 0,
|
||||
quantity: 1,
|
||||
saving: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
@@ -139,6 +198,44 @@ export default {
|
||||
uni.hideLoading()
|
||||
})
|
||||
},
|
||||
// 打开明细编辑器:从 item.product.price_sheet 取可选规格,预选当前 item 的 price_sheet_id
|
||||
openItemEditor(item, index) {
|
||||
const sheets = (item.product && item.product.price_sheet) || [];
|
||||
this.editor.itemId = item.id;
|
||||
this.editor.index = index;
|
||||
this.editor.sheets = sheets;
|
||||
this.editor.priceSheetId = item.price_sheet_id || (sheets.length > 0 ? sheets[0].id : 0);
|
||||
this.editor.quantity = item.quantity || 1;
|
||||
this.editor.saving = false;
|
||||
this.editor.visible = true;
|
||||
},
|
||||
changeEditorQty(delta) {
|
||||
const next = this.editor.quantity + delta;
|
||||
if (next < 1) return;
|
||||
this.editor.quantity = next;
|
||||
},
|
||||
// 保存:调 update-item,成功后回写本地 item 字段并关闭
|
||||
saveItem() {
|
||||
if (this.editor.saving) return;
|
||||
const { itemId, priceSheetId, quantity, index } = this.editor;
|
||||
this.editor.saving = true;
|
||||
updateItemApi(itemId, {
|
||||
price_sheet_id: priceSheetId,
|
||||
quantity,
|
||||
}).then(() => {
|
||||
// 回写本地数据,避免整页刷新
|
||||
if (index >= 0 && this.info.items[index]) {
|
||||
this.$set(this.info.items[index], 'price_sheet_id', priceSheetId);
|
||||
this.$set(this.info.items[index], 'quantity', quantity);
|
||||
}
|
||||
this.editor.visible = false;
|
||||
this.$refs.uToast.show({ type: 'success', message: '已保存' });
|
||||
}).catch(() => {
|
||||
// 拦截器已 toast
|
||||
}).finally(() => {
|
||||
this.editor.saving = false;
|
||||
});
|
||||
},
|
||||
navigateToDetail(id) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/product/detail?id=${id}`,
|
||||
@@ -425,4 +522,119 @@ view, scroll-view, text, image {
|
||||
.safe-bottom {
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
/* 明细编辑器(page-container 抽屉) */
|
||||
.editor-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #F7F8FA;
|
||||
}
|
||||
.editor-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 40rpx 30rpx 20rpx;
|
||||
background: #fff;
|
||||
}
|
||||
.editor-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1A1A1A;
|
||||
}
|
||||
.editor-close {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
padding: 24rpx 30rpx;
|
||||
}
|
||||
.editor-spec-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 20rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #EEE;
|
||||
border-radius: 10rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.editor-spec-row.active {
|
||||
border-color: #B4854D;
|
||||
background: rgba(180, 133, 77, 0.06);
|
||||
}
|
||||
.editor-spec-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-spec-name {
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
.editor-spec-dim {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.editor-spec-price {
|
||||
font-size: 28rpx;
|
||||
color: #B4854D;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
.editor-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 60rpx 0;
|
||||
}
|
||||
.editor-qty {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 30rpx;
|
||||
background: #fff;
|
||||
}
|
||||
.editor-qty-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
.editor-qty-ctrl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30rpx;
|
||||
}
|
||||
.editor-qty-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #F2F2F2;
|
||||
color: #333;
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.editor-qty-num {
|
||||
font-size: 30rpx;
|
||||
color: #222;
|
||||
min-width: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.editor-save {
|
||||
margin: 24rpx 30rpx calc(env(safe-area-inset-bottom) + 24rpx);
|
||||
background: #B4854D;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
border-radius: 48rpx;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
}
|
||||
.editor-save[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="container" :style="themePageStyle">
|
||||
|
||||
<!-- 优化2:轮播图 UI 升级,置于顶部 -->
|
||||
<view class="hero-banner animate-fade-in-up">
|
||||
@@ -11,7 +11,7 @@
|
||||
duration="800"
|
||||
indicator-dots
|
||||
indicator-color="rgba(255,255,255,0.4)"
|
||||
indicator-active-color="#FFFFFF"
|
||||
:indicator-active-color="themePrimary"
|
||||
>
|
||||
<swiper-item v-for="(item, index) in carousel" :key="index" class="swiper-item-box">
|
||||
<view class="image-wrapper">
|
||||
@@ -23,7 +23,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 优化1:Search Bar 移动到轮播图下方,且上滑时具备吸顶效果 -->
|
||||
<view class="search-wrapper">
|
||||
<view class="search-wrapper" :style="{ background: themeSurface }">
|
||||
<view class="search-box">
|
||||
<up-search
|
||||
type="text"
|
||||
@@ -34,8 +34,8 @@
|
||||
:animation="true"
|
||||
actionText="搜索"
|
||||
:disabled="true"
|
||||
bgColor="#FFFFFF"
|
||||
searchIconColor="#B4854D"
|
||||
:bgColor="themeSurface"
|
||||
:searchIconColor="themePrimary"
|
||||
@search="handleSearch"
|
||||
@click="handleSearch"
|
||||
/>
|
||||
@@ -46,9 +46,9 @@
|
||||
<view class="category-wrapper">
|
||||
<!-- 全新的标题排版:居中对齐,中英文堆叠 -->
|
||||
<view class="section-header animate-fade-in-up" style="animation-delay: 0.1s;">
|
||||
<text class="title-en">- CATEGORIES -</text>
|
||||
<text class="title-cn">产品分类</text>
|
||||
<text class="header-desc">探索您的理想生活方式</text>
|
||||
<text class="title-en" :style="{ color: themePrimary }">- CATEGORIES -</text>
|
||||
<text class="title-cn" :style="{ color: themeText }">产品分类</text>
|
||||
<text class="header-desc" :style="{ color: themeTextSoft }">探索您的理想生活方式</text>
|
||||
</view>
|
||||
|
||||
<!-- 保持 3 列布局,优化卡片细节质感 -->
|
||||
@@ -57,7 +57,11 @@
|
||||
v-for="(item, index) in categories"
|
||||
:key="index"
|
||||
class="category-card animate-fade-in-up"
|
||||
:style="{ animationDelay: (index * 0.08 + 0.2) + 's' }"
|
||||
:style="{
|
||||
animationDelay: (index * 0.08 + 0.2) + 's',
|
||||
background: themeSurface,
|
||||
borderRadius: themeRadius,
|
||||
}"
|
||||
@tap="navigateToList(item.id)"
|
||||
>
|
||||
<!-- 画框式图片容器 -->
|
||||
@@ -66,9 +70,9 @@
|
||||
<view class="cat-mask"></view>
|
||||
</view>
|
||||
<!-- 更精致的文字区域 -->
|
||||
<view class="category-info">
|
||||
<text class="category-name">{{ item.name }}</text>
|
||||
<view class="category-indicator"></view>
|
||||
<view class="category-info" :style="{ background: themeSurface }">
|
||||
<text class="category-name" :style="{ color: themeText }">{{ item.name }}</text>
|
||||
<view class="category-indicator" :style="{ background: themePrimary }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -80,8 +84,10 @@
|
||||
// JS 逻辑完全保留
|
||||
import {getCarouselApi, getCategoryListApi} from "@/api/page/home";
|
||||
import {getCache} from "@/utils/cache";
|
||||
import themeMixin from "@/mixins/theme";
|
||||
|
||||
export default {
|
||||
mixins: [themeMixin],
|
||||
data() {
|
||||
return {
|
||||
categoriesList: [],
|
||||
|
||||
@@ -54,7 +54,6 @@ export default {
|
||||
'',
|
||||
'微信用户' + Math.random().toString(36).substring(2)
|
||||
).then((result) => {
|
||||
console.log('登录成功', result);
|
||||
setCache('token', result.token)
|
||||
setCache('user_info', result.user_info)
|
||||
uni.showToast({
|
||||
|
||||
@@ -130,7 +130,6 @@ export default {
|
||||
},
|
||||
getMyInfo() {
|
||||
getUserInfoApi().then((res) => {
|
||||
console.log("UserInfo API Response:", res);
|
||||
// 强制确保 count 赋值
|
||||
this.count = res.count;
|
||||
this.userInfo = res.user;
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
|
||||
<view class="divider"></view>
|
||||
|
||||
<!-- 手机号 -->
|
||||
<!-- 识别号 -->
|
||||
<view class="info-item" @tap="openPhoneModal">
|
||||
<view class="item-left">
|
||||
<text class="label">手机号码</text>
|
||||
<text class="label">识别号码</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<text class="value">{{ phone || '未绑定' }}</text>
|
||||
@@ -64,7 +64,7 @@
|
||||
<!-- Modal: 绑定手机 (手动输入) -->
|
||||
<u-modal
|
||||
:show="showPhoneModal"
|
||||
title="绑定手机号"
|
||||
title="绑定识别号"
|
||||
:showCancelButton="true"
|
||||
cancelText="取消"
|
||||
confirmText="保存"
|
||||
@@ -75,12 +75,12 @@
|
||||
<view class="modal-input-wrapper">
|
||||
<u-input
|
||||
v-model="tempPhone"
|
||||
placeholder="请输入手机号码"
|
||||
placeholder="请输入识别号码"
|
||||
type="number"
|
||||
border="surround"
|
||||
clearable
|
||||
/>
|
||||
<view class="tip-text">请确保输入真实的手机号码</view>
|
||||
<view class="tip-text">请确保输入管理员给出的识别号码</view>
|
||||
</view>
|
||||
</u-modal>
|
||||
|
||||
@@ -150,11 +150,11 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 绑定手动输入的手机号
|
||||
// 绑定手动输入的识别号
|
||||
async savePhone() {
|
||||
if(!this.tempPhone || this.tempPhone.length < 11) {
|
||||
this.showPhoneModal = false;
|
||||
return this.$refs.uToast.show({ type: 'error', message: '请输入有效的手机号' });
|
||||
return this.$refs.uToast.show({ type: 'error', message: '请输入有效的识别号' });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
801
pages/order/detail.vue
Normal file
801
pages/order/detail.vue
Normal file
@@ -0,0 +1,801 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading && !order.id" class="empty-tip">加载中...</view>
|
||||
<view v-else-if="!order.id" class="empty-tip">订单不存在</view>
|
||||
|
||||
<block v-else>
|
||||
<!-- 状态横幅 -->
|
||||
<view class="status-banner" :class="'banner-' + order.status">
|
||||
<text class="status-text">{{ statusText(order.status) }}</text>
|
||||
<text class="status-desc">{{ statusDesc(order) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 收货信息 -->
|
||||
<view class="info-card" v-if="order.receiver_name">
|
||||
<view class="card-title">收货信息</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">收件人</text>
|
||||
<text class="info-value">{{ order.receiver_name }} {{ order.receiver_phone }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">地址</text>
|
||||
<text class="info-value">{{ order.receiver_address || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">配送</text>
|
||||
<text class="info-value">{{ deliveryText(order.delivery_type) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品清单 -->
|
||||
<view class="info-card">
|
||||
<view class="card-title">商品清单</view>
|
||||
<view
|
||||
v-for="item in order.items || []"
|
||||
:key="item.id"
|
||||
class="goods-row"
|
||||
>
|
||||
<image :src="item.cover" mode="aspectFill" class="goods-thumb" />
|
||||
<view class="goods-info">
|
||||
<text class="goods-title">{{ item.title }}</text>
|
||||
<text class="goods-spec">{{ item.specification }}</text>
|
||||
<view class="goods-price-row">
|
||||
<text class="goods-price">¥{{ item.unit_price_text }}</text>
|
||||
<text class="goods-qty">x{{ item.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="amount-summary">
|
||||
<view class="summary-row">
|
||||
<text>商品总额</text>
|
||||
<text>¥{{ order.total_amount_text || '0' }}</text>
|
||||
</view>
|
||||
<view class="summary-row" v-if="order.paid_amount_text">
|
||||
<text>已付金额</text>
|
||||
<text class="paid">¥{{ order.paid_amount_text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 状态轨迹 -->
|
||||
<view class="info-card">
|
||||
<view class="card-title">订单状态</view>
|
||||
<view class="timeline">
|
||||
<view
|
||||
v-for="(node, idx) in timeline"
|
||||
:key="idx"
|
||||
class="timeline-node"
|
||||
>
|
||||
<view class="timeline-dot" :class="'dot-' + node.type" />
|
||||
<view class="timeline-content">
|
||||
<text class="timeline-title">{{ node.title }}</text>
|
||||
<text class="timeline-time" v-if="node.time">{{ node.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单信息 -->
|
||||
<view class="info-card">
|
||||
<view class="card-title">订单信息</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">订单号</text>
|
||||
<text class="info-value">{{ order.order_no }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">下单时间</text>
|
||||
<text class="info-value">{{ order.created_at }}</text>
|
||||
</view>
|
||||
<view class="info-row" v-if="order.remark">
|
||||
<text class="info-label">备注</text>
|
||||
<text class="info-value">{{ order.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 底部操作栏(按状态动态) -->
|
||||
<view class="action-bar" v-if="showActions">
|
||||
<view
|
||||
class="action-btn cancel-btn"
|
||||
v-if="canCancel"
|
||||
@tap="onCancel"
|
||||
>
|
||||
取消订单
|
||||
</view>
|
||||
<view
|
||||
class="action-btn primary-btn"
|
||||
v-if="canPay"
|
||||
@tap="onPay"
|
||||
>
|
||||
去支付
|
||||
</view>
|
||||
<view
|
||||
class="action-btn outline-btn"
|
||||
v-if="canUploadVoucher"
|
||||
@tap="onUploadVoucher"
|
||||
>
|
||||
上传转账凭证
|
||||
</view>
|
||||
<view
|
||||
class="action-btn primary-btn"
|
||||
v-if="canConfirm"
|
||||
@tap="onConfirm"
|
||||
>
|
||||
确认收货
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上传凭证 page-container(防意外退出,按规则用 v-if) -->
|
||||
<page-container
|
||||
v-if="voucherVisible"
|
||||
:show="voucherVisible"
|
||||
:overlay="true"
|
||||
:close-on-overlay="false"
|
||||
position="right"
|
||||
title="上传转账凭证"
|
||||
@clickoverlay="closeVoucher"
|
||||
>
|
||||
<view class="voucher-panel">
|
||||
<view class="voucher-tip">
|
||||
请上传银行/微信转账截图,后台审核通过后订单状态自动推进。
|
||||
</view>
|
||||
<view class="voucher-images">
|
||||
<view
|
||||
v-for="(img, idx) in voucherImages"
|
||||
:key="idx"
|
||||
class="voucher-img-item"
|
||||
>
|
||||
<image :src="img" mode="aspectFill" class="voucher-img" />
|
||||
<view class="voucher-img-del" @tap="removeVoucherImage(idx)">×</view>
|
||||
</view>
|
||||
<view
|
||||
v-if="voucherImages.length < 3"
|
||||
class="voucher-upload-btn"
|
||||
@tap="chooseVoucherImage"
|
||||
>
|
||||
<text class="upload-icon">+</text>
|
||||
<text class="upload-text">上传凭证</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="voucher-amount">
|
||||
<text class="amount-label">本次转账金额(元,选填)</text>
|
||||
<input
|
||||
v-model="voucherAmount"
|
||||
type="digit"
|
||||
placeholder="不填则按订单未付金额"
|
||||
class="amount-input"
|
||||
/>
|
||||
</view>
|
||||
<view class="voucher-actions">
|
||||
<button class="voucher-cancel" @tap="closeVoucher">取消</button>
|
||||
<button
|
||||
class="voucher-submit"
|
||||
:disabled="voucherImages.length === 0 || submitting"
|
||||
@tap="submitVoucher"
|
||||
>
|
||||
{{ submitting ? '提交中...' : '提交' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</page-container>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getOrderDetailApi,
|
||||
cancelOrderApi,
|
||||
completeOrderApi,
|
||||
wechatPayApi,
|
||||
submitVoucherApi,
|
||||
uploadVoucherImageApi
|
||||
} from '@/api/page/order.js'
|
||||
|
||||
const STATUS_MAP = {
|
||||
0: '待付款',
|
||||
1: '已付款',
|
||||
2: '已发货',
|
||||
3: '已完成',
|
||||
4: '已取消'
|
||||
}
|
||||
|
||||
const DELIVERY_MAP = {
|
||||
1: '物流快递',
|
||||
2: '上门自提',
|
||||
3: '公司配送'
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
orderId: 0,
|
||||
order: {},
|
||||
loading: false,
|
||||
// 凭证上传
|
||||
voucherVisible: false,
|
||||
voucherImages: [],
|
||||
voucherAmount: '',
|
||||
submitting: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canCancel() {
|
||||
return this.order.status === 0 || this.order.status === 1
|
||||
},
|
||||
canPay() {
|
||||
// 待付款:可微信支付(也可上传凭证,二选一)
|
||||
return this.order.status === 0
|
||||
},
|
||||
canUploadVoucher() {
|
||||
return this.order.status === 0
|
||||
},
|
||||
canConfirm() {
|
||||
return this.order.status === 2
|
||||
},
|
||||
showActions() {
|
||||
return this.order.id && (this.canCancel || this.canPay || this.canUploadVoucher || this.canConfirm)
|
||||
},
|
||||
timeline() {
|
||||
if (!this.order.id) return []
|
||||
const nodes = [
|
||||
{title: '下单成功', time: this.order.created_at, type: 'done'}
|
||||
]
|
||||
if (this.order.paid_at) {
|
||||
nodes.push({title: '已付款', time: this.order.paid_at, type: 'done'})
|
||||
}
|
||||
if ((this.order.deliveries || []).length > 0) {
|
||||
const last = this.order.deliveries[0]
|
||||
nodes.push({
|
||||
title: `已发货:${last.tracking_no || last.pickup_point || ''}`,
|
||||
time: last.shipped_at,
|
||||
type: 'done'
|
||||
})
|
||||
}
|
||||
if (this.order.status === 3) {
|
||||
nodes.push({title: '已完成', time: '', type: 'done'})
|
||||
}
|
||||
if (this.order.status === 4) {
|
||||
nodes.push({title: '已取消', time: '', type: 'cancel'})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.orderId = Number(options.id || 0)
|
||||
this.fetchDetail()
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.fetchDetail().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
statusText(status) {
|
||||
return STATUS_MAP[status] || '-'
|
||||
},
|
||||
deliveryText(type) {
|
||||
return DELIVERY_MAP[type] || '-'
|
||||
},
|
||||
statusDesc(order) {
|
||||
switch (order.status) {
|
||||
case 0:
|
||||
return '请尽快完成支付,超时订单可能自动取消'
|
||||
case 1:
|
||||
return '商家正在备货,请耐心等待发货'
|
||||
case 2:
|
||||
return '商品已发出,请注意查收'
|
||||
case 3:
|
||||
return '订单已完成,感谢您的惠顾'
|
||||
case 4:
|
||||
return '订单已取消'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
},
|
||||
async fetchDetail() {
|
||||
if (!this.orderId) return
|
||||
this.loading = true
|
||||
try {
|
||||
this.order = await getOrderDetailApi(this.orderId) || {}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定取消该订单吗?',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await cancelOrderApi(this.orderId)
|
||||
uni.showToast({title: '已取消', icon: 'success'})
|
||||
this.fetchDetail()
|
||||
} catch (e) {
|
||||
// 拦截器已提示
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onConfirm() {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确认已收到商品?',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await completeOrderApi(this.orderId)
|
||||
uni.showToast({title: '已确认收货', icon: 'success'})
|
||||
this.fetchDetail()
|
||||
} catch (e) {
|
||||
// 拦截器已提示
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
async onPay() {
|
||||
uni.showLoading({title: '正在调起支付...'})
|
||||
try {
|
||||
const params = await wechatPayApi(this.orderId)
|
||||
uni.hideLoading()
|
||||
// 后端返回的 jsapi 参数直接给 uni.requestPayment
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: params.timeStamp,
|
||||
nonceStr: params.nonceStr,
|
||||
package: params.package,
|
||||
signType: params.signType || 'RSA',
|
||||
paySign: params.paySign,
|
||||
success: () => {
|
||||
uni.showToast({title: '支付成功', icon: 'success'})
|
||||
this.fetchDetail()
|
||||
},
|
||||
fail: (err) => {
|
||||
// 用户取消支付不报错,只提示
|
||||
if (err && err.errMsg && err.errMsg.includes('cancel')) {
|
||||
uni.showToast({title: '已取消支付', icon: 'none'})
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
// 拦截器已提示
|
||||
}
|
||||
},
|
||||
onUploadVoucher() {
|
||||
this.voucherImages = []
|
||||
this.voucherAmount = ''
|
||||
this.voucherVisible = true
|
||||
},
|
||||
closeVoucher() {
|
||||
if (this.submitting) return
|
||||
this.voucherVisible = false
|
||||
},
|
||||
async chooseVoucherImage() {
|
||||
const remain = 3 - this.voucherImages.length
|
||||
if (remain <= 0) {
|
||||
uni.showToast({title: '最多 3 张', icon: 'none'})
|
||||
return
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: remain,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const paths = res.tempFilePaths || []
|
||||
if (paths.length === 0) return
|
||||
uni.showLoading({title: '上传中...', mask: true})
|
||||
try {
|
||||
// 逐张上传到 wx/upload/image,拿到可访问 URL 后再展示
|
||||
const urls = []
|
||||
for (const p of paths) {
|
||||
const url = await uploadVoucherImageApi(p)
|
||||
urls.push(url)
|
||||
}
|
||||
this.voucherImages = this.voucherImages.concat(urls)
|
||||
} catch (e) {
|
||||
// 拦截器 / uploadVoucherImageApi 已 toast
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
removeVoucherImage(idx) {
|
||||
this.voucherImages.splice(idx, 1)
|
||||
},
|
||||
async submitVoucher() {
|
||||
if (this.voucherImages.length === 0) {
|
||||
uni.showToast({title: '请上传凭证', icon: 'none'})
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
try {
|
||||
// voucher 后端接受逗号分隔字符串
|
||||
const voucher = this.voucherImages.join(',')
|
||||
// amount 用户输入元,后端期望分:× 100 取整
|
||||
let amountCents = null
|
||||
if (this.voucherAmount && Number(this.voucherAmount) > 0) {
|
||||
amountCents = Math.round(Number(this.voucherAmount) * 100)
|
||||
}
|
||||
await submitVoucherApi(this.orderId, voucher, amountCents)
|
||||
uni.showToast({title: '已提交,等待审核', icon: 'success'})
|
||||
this.voucherVisible = false
|
||||
this.fetchDetail()
|
||||
} catch (e) {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f6f8;
|
||||
padding-bottom: 140rpx;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
padding: 48rpx 32rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #3a3a3a 100%);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.banner-0 {
|
||||
background: linear-gradient(135deg, #e6a23c 0%, #f0b85e 100%);
|
||||
}
|
||||
|
||||
.banner-4 {
|
||||
background: linear-gradient(135deg, #909399 0%, #b0b3b8 100%);
|
||||
}
|
||||
|
||||
.info-card {
|
||||
margin: 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 24rpx;
|
||||
padding-bottom: 16rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
padding: 12rpx 0;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
width: 140rpx;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.goods-row {
|
||||
display: flex;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f8f8f8;
|
||||
}
|
||||
|
||||
.goods-thumb {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #f5f5f5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.goods-info {
|
||||
flex: 1;
|
||||
margin-left: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.goods-title {
|
||||
font-size: 28rpx;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.goods-spec {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.goods-price-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.goods-price {
|
||||
font-size: 28rpx;
|
||||
color: #1a1a1a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.goods-qty {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.amount-summary {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.summary-row .paid {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-left: 8rpx;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12rpx 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11rpx;
|
||||
top: 28rpx;
|
||||
bottom: -12rpx;
|
||||
width: 2rpx;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.timeline-node:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
margin-right: 20rpx;
|
||||
margin-top: 6rpx;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dot-done {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.dot-cancel {
|
||||
background: #f56c6c;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #bbb;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 200rpx 0;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 24rpx env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
color: #666;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
.outline-btn {
|
||||
color: #1a1a1a;
|
||||
border: 1rpx solid #1a1a1a;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
color: #fff;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* 凭证上传 page-container 内容 */
|
||||
.voucher-panel {
|
||||
padding: 32rpx;
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.voucher-tip {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.voucher-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.voucher-img-item {
|
||||
position: relative;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
.voucher-img {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.voucher-img-del {
|
||||
position: absolute;
|
||||
top: -12rpx;
|
||||
right: -12rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 36rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.voucher-upload-btn {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border: 2rpx dashed #ddd;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 56rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.voucher-amount {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.voucher-amount .amount-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.voucher-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
position: fixed;
|
||||
bottom: env(safe-area-inset-bottom);
|
||||
left: 32rpx;
|
||||
right: 32rpx;
|
||||
}
|
||||
|
||||
.voucher-actions button {
|
||||
flex: 1;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.voucher-cancel {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.voucher-submit {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.voucher-submit[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
350
pages/order/list.vue
Normal file
350
pages/order/list.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<view class="order-page">
|
||||
<!-- 顶部状态 Tab -->
|
||||
<view class="status-tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="status-tab"
|
||||
:class="{active: currentStatus === tab.value}"
|
||||
@tap="switchTab(tab.value)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<scroll-view
|
||||
class="order-list"
|
||||
scroll-y
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<view v-if="loading && list.length === 0" class="empty-tip">加载中...</view>
|
||||
<view v-else-if="list.length === 0" class="empty-tip">
|
||||
<text class="empty-icon">📭</text>
|
||||
<text>暂无订单</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="order in list"
|
||||
:key="order.id"
|
||||
class="order-card"
|
||||
@tap="goDetail(order.id)"
|
||||
>
|
||||
<view class="order-card-header">
|
||||
<text class="order-no">订单号:{{ order.order_no }}</text>
|
||||
<text class="order-status" :class="'status-' + order.status">
|
||||
{{ statusText(order.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="order-card-body">
|
||||
<view class="order-item-thumb" v-if="order.items && order.items.length">
|
||||
<image
|
||||
v-for="(item, idx) in order.items.slice(0, 4)"
|
||||
:key="idx"
|
||||
:src="item.cover"
|
||||
mode="aspectFill"
|
||||
class="thumb"
|
||||
/>
|
||||
<view v-if="order.items.length > 4" class="thumb-more">
|
||||
+{{ order.items.length - 4 }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="order-item-empty">共 {{ order.item_count || 0 }} 件商品</view>
|
||||
</view>
|
||||
|
||||
<view class="order-card-footer">
|
||||
<view class="order-amount">
|
||||
<text class="amount-label">合计</text>
|
||||
<text class="amount-value">¥{{ order.total_amount_text || '0' }}</text>
|
||||
</view>
|
||||
<view class="order-time">{{ order.created_at }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && list.length > 0" class="load-more-tip">加载中...</view>
|
||||
<view v-if="noMore && list.length > 0" class="load-more-tip">没有更多了</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {getOrderListApi} from '@/api/page/order.js'
|
||||
|
||||
/**
|
||||
* 订单状态枚举(与后端 OrderModel 一致)
|
||||
* 0=待付款 1=已付款 2=已发货 3=已完成 4=已取消
|
||||
*/
|
||||
const STATUS_MAP = {
|
||||
0: '待付款',
|
||||
1: '已付款',
|
||||
2: '已发货',
|
||||
3: '已完成',
|
||||
4: '已取消'
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 顶部 Tab:undefined 表示全部
|
||||
tabs: [
|
||||
{label: '全部', value: undefined},
|
||||
{label: '待付款', value: 0},
|
||||
{label: '待发货', value: 1},
|
||||
{label: '待收货', value: 2},
|
||||
{label: '已完成', value: 3}
|
||||
],
|
||||
currentStatus: undefined,
|
||||
list: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
loading: false,
|
||||
noMore: false
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
// 支持从外部带 status 跳进来(如「待付款」入口)
|
||||
if (options.status !== undefined && options.status !== '') {
|
||||
this.currentStatus = Number(options.status)
|
||||
}
|
||||
this.fetchList(true)
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.fetchList(true).finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
statusText(status) {
|
||||
return STATUS_MAP[status] || '-'
|
||||
},
|
||||
switchTab(value) {
|
||||
if (this.currentStatus === value) return
|
||||
this.currentStatus = value
|
||||
this.fetchList(true)
|
||||
},
|
||||
async fetchList(reset = false) {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
if (reset) {
|
||||
this.page = 1
|
||||
this.noMore = false
|
||||
}
|
||||
try {
|
||||
const res = await getOrderListApi({
|
||||
status: this.currentStatus,
|
||||
page: this.page,
|
||||
pageSize: this.pageSize
|
||||
})
|
||||
// 后端返回 { items, total } 或数组(兼容两种结构)
|
||||
const items = Array.isArray(res) ? res : (res.items || res.list || [])
|
||||
if (reset) {
|
||||
this.list = items
|
||||
} else {
|
||||
this.list = this.list.concat(items)
|
||||
}
|
||||
if (items.length < this.pageSize) {
|
||||
this.noMore = true
|
||||
}
|
||||
} catch (e) {
|
||||
// 拦截器已统一 toast,这里只兜底
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
loadMore() {
|
||||
if (this.noMore || this.loading) return
|
||||
this.page += 1
|
||||
this.fetchList(false)
|
||||
},
|
||||
goDetail(id) {
|
||||
uni.navigateTo({url: `/pages/order/detail?id=${id}`})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.order-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #f6f6f8;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.status-tab.active {
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48rpx;
|
||||
height: 4rpx;
|
||||
background: #1a1a1a;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.order-list {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.order-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-0 {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.status-1 {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.status-2 {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.status-3 {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.status-4 {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.order-card-body {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.order-item-thumb {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.thumb-more {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.order-item-empty {
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.order-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
font-size: 22rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.load-more-tip {
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
color: #bbb;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -78,13 +78,14 @@
|
||||
bgColor="#F7F8FA"
|
||||
>
|
||||
<view class="popup-content">
|
||||
<scroll-view scroll-y style="max-height: 600rpx;">
|
||||
<!-- 第一步:选清单 -->
|
||||
<scroll-view v-if="!specPicker.visible" scroll-y style="max-height: 600rpx;">
|
||||
<view class="list-options">
|
||||
<view
|
||||
v-for="(item, index) in listOption"
|
||||
:key="index"
|
||||
class="option-item"
|
||||
@click="addToCart(item.id)"
|
||||
@click="onPickList(item.id)"
|
||||
>
|
||||
<view class="option-info">
|
||||
<text class="option-name">{{ item.name }}</text>
|
||||
@@ -96,6 +97,42 @@
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<!-- 第二步:选规格 + 数量(同一商品同一清单可重复,后端按规格去重累加) -->
|
||||
<view v-else class="spec-picker">
|
||||
<view class="spec-picker-head">
|
||||
<text class="spec-picker-title">选择规格</text>
|
||||
<text class="spec-picker-back" @click="specPicker.visible = false">返回清单</text>
|
||||
</view>
|
||||
<scroll-view scroll-y style="max-height: 520rpx;">
|
||||
<view
|
||||
v-for="(spec, sIdx) in specPicker.sheets"
|
||||
:key="sIdx"
|
||||
class="spec-row"
|
||||
:class="{ active: specPicker.priceSheetId === spec.id }"
|
||||
@click="specPicker.priceSheetId = spec.id"
|
||||
>
|
||||
<view class="spec-row-info">
|
||||
<text class="spec-row-name">{{ spec.specification }}</text>
|
||||
<text class="spec-row-dim" v-if="spec.dimension">{{ spec.dimension }}</text>
|
||||
</view>
|
||||
<view class="spec-row-price" v-if="spec.routine && spec.routine.length">
|
||||
<text class="spec-row-curr">¥{{ spec.routine[0] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="spec-empty" v-if="specPicker.sheets.length === 0">
|
||||
该商品暂无规格,将按默认规格加入
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="qty-row">
|
||||
<text class="qty-label">数量</text>
|
||||
<view class="qty-ctrl">
|
||||
<view class="qty-btn" @click="changeQty(-1)">-</view>
|
||||
<text class="qty-num">{{ specPicker.quantity }}</text>
|
||||
<view class="qty-btn" @click="changeQty(1)">+</view>
|
||||
</view>
|
||||
</view>
|
||||
<button class="spec-confirm-btn" @click="confirmAddToCart">确定加入</button>
|
||||
</view>
|
||||
</view>
|
||||
</up-popup>
|
||||
|
||||
@@ -131,6 +168,14 @@ export default {
|
||||
p_user_id: 0,
|
||||
listModal: false,
|
||||
listOption: [],
|
||||
// 规格/数量选择:加入清单时必须带上 price_sheet_id,否则转订单会被 OrderCoreService 拒
|
||||
specPicker: {
|
||||
visible: false,
|
||||
listId: 0,
|
||||
sheets: [],
|
||||
priceSheetId: 0,
|
||||
quantity: 1,
|
||||
},
|
||||
product: {
|
||||
title: '',
|
||||
cover: '',
|
||||
@@ -158,7 +203,6 @@ export default {
|
||||
previewImages(url) {
|
||||
let w = this.product.title;
|
||||
w = urlSafeBase64Encode(w);
|
||||
console.log(`${url}?watermark/2/text/${w}/fontsize/800`);
|
||||
uni.previewImage({
|
||||
current: 0,
|
||||
urls: [`${url}?watermark/2/text/${w}/fontsize/800`],
|
||||
@@ -169,10 +213,35 @@ export default {
|
||||
},
|
||||
showModal() {
|
||||
this.getListOption();
|
||||
this.specPicker.visible = false;
|
||||
this.listModal = true;
|
||||
},
|
||||
addToCart(id) {
|
||||
toCartApi(id, this.id).then(() => {
|
||||
// 选定某个清单后进入规格选择步骤;该商品的 price_sheet 作为可选规格
|
||||
onPickList(listId) {
|
||||
const sheets = (this.product.price_sheet || []).map((s) => ({
|
||||
id: s.id,
|
||||
specification: s.specification,
|
||||
dimension: s.dimension,
|
||||
routine: s.routine || [],
|
||||
}));
|
||||
this.specPicker.listId = listId;
|
||||
this.specPicker.sheets = sheets;
|
||||
this.specPicker.priceSheetId = sheets.length > 0 ? sheets[0].id : 0;
|
||||
this.specPicker.quantity = 1;
|
||||
this.specPicker.visible = true;
|
||||
},
|
||||
changeQty(delta) {
|
||||
const next = this.specPicker.quantity + delta;
|
||||
if (next < 1) return;
|
||||
this.specPicker.quantity = next;
|
||||
},
|
||||
// 确定加入:带 price_sheet_id 与 quantity 提交,后端按规格去重累加
|
||||
confirmAddToCart() {
|
||||
const { listId, priceSheetId, quantity } = this.specPicker;
|
||||
toCartApi(listId, this.id, {
|
||||
price_sheet_id: priceSheetId,
|
||||
quantity,
|
||||
}).then(() => {
|
||||
this.listModal = false;
|
||||
uni.showToast({
|
||||
title: '已添加到清单',
|
||||
@@ -468,4 +537,110 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 规格选择步骤 */
|
||||
.spec-picker {
|
||||
padding: 10rpx 0 20rpx;
|
||||
}
|
||||
.spec-picker-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 6rpx 20rpx;
|
||||
}
|
||||
.spec-picker-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
.spec-picker-back {
|
||||
font-size: 24rpx;
|
||||
color: #B4854D;
|
||||
}
|
||||
.spec-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 20rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #EEE;
|
||||
border-radius: 10rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.spec-row.active {
|
||||
border-color: #B4854D;
|
||||
background: rgba(180, 133, 77, 0.06);
|
||||
}
|
||||
.spec-row-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.spec-row-name {
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
.spec-row-dim {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.spec-row-price {
|
||||
flex-shrink: 0;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
.spec-row-curr {
|
||||
font-size: 28rpx;
|
||||
color: #B4854D;
|
||||
font-weight: 600;
|
||||
}
|
||||
.spec-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
.qty-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 6rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.qty-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
.qty-ctrl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30rpx;
|
||||
}
|
||||
.qty-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #F2F2F2;
|
||||
color: #333;
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.qty-num {
|
||||
font-size: 30rpx;
|
||||
color: #222;
|
||||
min-width: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.spec-confirm-btn {
|
||||
margin-top: 24rpx;
|
||||
background: #B4854D;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
border-radius: 48rpx;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -135,33 +135,19 @@
|
||||
|
||||
<script>
|
||||
import { getCategoryListByPidApi, getProductListApi } from "@/api/page/product"
|
||||
import productWaterfallMixin from "@/mixins/productWaterfall"
|
||||
|
||||
export default {
|
||||
mixins: [productWaterfallMixin],
|
||||
data() {
|
||||
return {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pid: 0,
|
||||
currentTab: 0,
|
||||
categoryId: 0,
|
||||
title: "",
|
||||
tabs: [],
|
||||
products: [],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
noMoreData: false,
|
||||
totalPages: 1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 将产品分为左右两列,实现瀑布流布局
|
||||
leftProducts() {
|
||||
return this.products.filter((_, index) => index % 2 === 0)
|
||||
},
|
||||
rightProducts() {
|
||||
return this.products.filter((_, index) => index % 2 === 1)
|
||||
},
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options && options.pid) {
|
||||
this.pid = options.pid
|
||||
@@ -174,12 +160,6 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refreshProductList()
|
||||
},
|
||||
onReachBottom() {
|
||||
this.loadMoreProducts()
|
||||
},
|
||||
mounted() {
|
||||
this.getProductList()
|
||||
},
|
||||
@@ -210,99 +190,46 @@ export default {
|
||||
})
|
||||
},
|
||||
handleSearch() {
|
||||
// 这里的搜索逻辑是正确的:重置分页和数据,重新请求
|
||||
// 搜索:重置分页和数据后重新请求
|
||||
this.page = 1
|
||||
this.products = []
|
||||
this.noMoreData = false
|
||||
this.getProductList()
|
||||
},
|
||||
refreshProductList() {
|
||||
this.page = 1
|
||||
this.products = []
|
||||
this.noMoreData = false
|
||||
this.getProductList()
|
||||
.then(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
uni.showToast({
|
||||
title: "刷新成功",
|
||||
icon: "success",
|
||||
duration: 1500,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
uni.showToast({
|
||||
title: "刷新失败",
|
||||
icon: "none",
|
||||
duration: 1500,
|
||||
})
|
||||
})
|
||||
},
|
||||
loadMoreProducts() {
|
||||
if (this.loadingMore || this.noMoreData) return
|
||||
|
||||
this.page++
|
||||
this.loadingMore = true
|
||||
|
||||
getProductListApi({
|
||||
/**
|
||||
* 拉商品列表:首屏赋值、追加拼接;维护 loading / noMoreData / totalPages
|
||||
* 返回 Promise,供 mixin 的下拉刷新/触底加载兜底
|
||||
*/
|
||||
getProductList() {
|
||||
this.loading = true
|
||||
return getProductListApi({
|
||||
page: this.page,
|
||||
size: this.pageSize,
|
||||
title: this.title,
|
||||
category_id: this.categoryId,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data && res.data.length > 0) {
|
||||
this.products = [...this.products, ...res.data]
|
||||
if (res.data.length < this.pageSize || this.page >= this.totalPages) {
|
||||
this.noMoreData = true
|
||||
}
|
||||
} else {
|
||||
const rows = res.data || []
|
||||
// page===1 替换,否则追加;触底加载由 mixin 先 page++,所以这里也走追加分支
|
||||
this.products = this.page === 1 ? rows : [...this.products, ...rows]
|
||||
if (rows.length < this.pageSize) {
|
||||
this.noMoreData = true
|
||||
}
|
||||
this.loadingMore = false
|
||||
if (res.last_page) {
|
||||
this.totalPages = res.last_page
|
||||
}
|
||||
return res
|
||||
})
|
||||
.catch(() => {
|
||||
this.page--
|
||||
this.loadingMore = false
|
||||
.catch((err) => {
|
||||
uni.showToast({
|
||||
title: "加载失败",
|
||||
icon: "none",
|
||||
})
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
getProductList() {
|
||||
this.loading = true
|
||||
return new Promise((resolve, reject) => {
|
||||
getProductListApi({
|
||||
page: this.page,
|
||||
size: this.pageSize,
|
||||
title: this.title,
|
||||
category_id: this.categoryId,
|
||||
})
|
||||
.then((res) => {
|
||||
if (this.page === 1) {
|
||||
this.products = res.data || []
|
||||
} else {
|
||||
this.products = [...this.products, ...(res.data || [])]
|
||||
}
|
||||
if (!res.data || res.data.length < this.pageSize) {
|
||||
this.noMoreData = true
|
||||
}
|
||||
if (res.last_page) {
|
||||
this.totalPages = res.last_page
|
||||
}
|
||||
this.loading = false
|
||||
resolve(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
this.loading = false
|
||||
uni.showToast({
|
||||
title: "加载失败",
|
||||
icon: "none",
|
||||
})
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
imageLoaded(index) {
|
||||
},
|
||||
|
||||
@@ -106,40 +106,21 @@
|
||||
|
||||
<script>
|
||||
import { getProductListApi } from "@/api/page/product"
|
||||
import productWaterfallMixin from "@/mixins/productWaterfall"
|
||||
|
||||
export default {
|
||||
mixins: [productWaterfallMixin],
|
||||
data() {
|
||||
return {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
title: "",
|
||||
products: [],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
noMoreData: false,
|
||||
totalPages: 1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
leftProducts() {
|
||||
return this.products.filter((_, index) => index % 2 === 0)
|
||||
},
|
||||
rightProducts() {
|
||||
return this.products.filter((_, index) => index % 2 === 1)
|
||||
},
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options && options.title) {
|
||||
this.title = options.title
|
||||
}
|
||||
this.getProductList()
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refreshProductList()
|
||||
},
|
||||
onReachBottom() {
|
||||
this.loadMoreProducts()
|
||||
},
|
||||
methods: {
|
||||
navigateToDetail(id) {
|
||||
uni.navigateTo({
|
||||
@@ -152,93 +133,39 @@ export default {
|
||||
this.noMoreData = false
|
||||
this.getProductList()
|
||||
},
|
||||
refreshProductList() {
|
||||
this.page = 1
|
||||
this.products = []
|
||||
this.noMoreData = false
|
||||
this.getProductList()
|
||||
.then(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
uni.showToast({
|
||||
title: "刷新成功",
|
||||
icon: "success",
|
||||
duration: 1500,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
uni.showToast({
|
||||
title: "刷新失败",
|
||||
icon: "none",
|
||||
duration: 1500,
|
||||
})
|
||||
})
|
||||
},
|
||||
loadMoreProducts() {
|
||||
if (this.loadingMore || this.noMoreData) return
|
||||
|
||||
this.page++
|
||||
this.loadingMore = true
|
||||
|
||||
getProductListApi({
|
||||
/**
|
||||
* 搜索结果列表:首屏赋值、追加拼接;维护 loading / noMoreData / totalPages
|
||||
* 返回 Promise,供 mixin 的下拉刷新/触底加载兜底
|
||||
*/
|
||||
getProductList() {
|
||||
this.loading = true
|
||||
return getProductListApi({
|
||||
page: this.page,
|
||||
size: this.pageSize,
|
||||
title: this.title,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data && res.data.length > 0) {
|
||||
this.products = [...this.products, ...res.data]
|
||||
if (res.data.length < this.pageSize || this.page >= this.totalPages) {
|
||||
this.noMoreData = true
|
||||
}
|
||||
} else {
|
||||
const rows = res.data || []
|
||||
this.products = this.page === 1 ? rows : [...this.products, ...rows]
|
||||
if (rows.length < this.pageSize) {
|
||||
this.noMoreData = true
|
||||
}
|
||||
this.loadingMore = false
|
||||
if (res.last_page) {
|
||||
this.totalPages = res.last_page
|
||||
}
|
||||
return res
|
||||
})
|
||||
.catch(() => {
|
||||
this.page--
|
||||
this.loadingMore = false
|
||||
.catch((err) => {
|
||||
uni.showToast({
|
||||
title: "加载失败",
|
||||
icon: "none",
|
||||
})
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
uni.hideLoading()
|
||||
})
|
||||
},
|
||||
getProductList() {
|
||||
this.loading = true
|
||||
return new Promise((resolve, reject) => {
|
||||
getProductListApi({
|
||||
page: this.page,
|
||||
size: this.pageSize,
|
||||
title: this.title,
|
||||
})
|
||||
.then((res) => {
|
||||
if (this.page === 1) {
|
||||
this.products = res.data || []
|
||||
} else {
|
||||
this.products = [...this.products, ...(res.data || [])]
|
||||
}
|
||||
if (!res.data || res.data.length < this.pageSize) {
|
||||
this.noMoreData = true
|
||||
}
|
||||
if (res.last_page) {
|
||||
this.totalPages = res.last_page
|
||||
}
|
||||
this.loading = false
|
||||
uni.hideLoading()
|
||||
resolve(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
this.loading = false
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: "加载失败",
|
||||
icon: "none",
|
||||
})
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
imageLoaded(index) {
|
||||
},
|
||||
|
||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -13,40 +13,40 @@ importers:
|
||||
version: 2.0.11
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
version: 1.11.21
|
||||
js-base64:
|
||||
specifier: ^3.7.7
|
||||
version: 3.7.8
|
||||
uview-plus:
|
||||
specifier: ^3.3.36
|
||||
version: 3.3.36
|
||||
version: 3.8.42
|
||||
|
||||
packages:
|
||||
|
||||
clipboard@2.0.11:
|
||||
resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==, tarball: https://registry.npmmirror.com/clipboard/-/clipboard-2.0.11.tgz}
|
||||
resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==, tarball: https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz}
|
||||
dayjs@1.11.21:
|
||||
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
|
||||
|
||||
delegate@3.2.0:
|
||||
resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==, tarball: https://registry.npmmirror.com/delegate/-/delegate-3.2.0.tgz}
|
||||
resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
|
||||
|
||||
good-listener@1.2.2:
|
||||
resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==, tarball: https://registry.npmmirror.com/good-listener/-/good-listener-1.2.2.tgz}
|
||||
resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==}
|
||||
|
||||
js-base64@3.7.8:
|
||||
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
|
||||
|
||||
select@1.1.2:
|
||||
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==, tarball: https://registry.npmmirror.com/select/-/select-1.1.2.tgz}
|
||||
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
|
||||
|
||||
tiny-emitter@2.1.0:
|
||||
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==, tarball: https://registry.npmmirror.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz}
|
||||
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
|
||||
|
||||
uview-plus@3.3.36:
|
||||
resolution: {integrity: sha512-yiIIt3OkCDkBzflrr6by8qvYibvCzsFZ/Mn+Fdx9TDaTQAltWFBj7UNVN/wOeb3lou2T5S1QFy2rxw3OcAYi5g==, tarball: https://registry.npmmirror.com/uview-plus/-/uview-plus-3.3.36.tgz}
|
||||
engines: {HBuilderX: ^3.1.0}
|
||||
uview-plus@3.8.42:
|
||||
resolution: {integrity: sha512-AknhV9x01Ql3WpxAtH+VoirKLqSnmHC6HLjfsj70bm8ez+DaiQcIcHPS7gdA/JxfKpWx4GWcaz/RtzKX7OsriA==}
|
||||
engines: {HBuilderX: ^3.1.0, uni-app: ^4.66, uni-app-x: ''}
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -56,7 +56,7 @@ snapshots:
|
||||
select: 1.1.2
|
||||
tiny-emitter: 2.1.0
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
dayjs@1.11.21: {}
|
||||
|
||||
delegate@3.2.0: {}
|
||||
|
||||
@@ -70,7 +70,4 @@ snapshots:
|
||||
|
||||
tiny-emitter@2.1.0: {}
|
||||
|
||||
uview-plus@3.3.36:
|
||||
dependencies:
|
||||
clipboard: 2.0.11
|
||||
dayjs: 1.11.13
|
||||
uview-plus@3.8.42: {}
|
||||
|
||||
87
utils/theme.js
Normal file
87
utils/theme.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { getCache, setCache } from '@/utils/cache'
|
||||
import { getThemeApi } from '@/api/page/theme.js'
|
||||
|
||||
/**
|
||||
* 主题令牌的本地应用器
|
||||
*
|
||||
* 后端 GET wx/theme 返回 { css_vars, tokens, layout, ... }。
|
||||
* 注意:微信小程序里不要用动态 import() 拉 API——经常静默失败,看起来像「没请求主题」。
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'theme_tokens'
|
||||
const STORAGE_VARS_KEY = 'theme_css_vars'
|
||||
|
||||
/** 内置兜底主题(与后端第一套轻奢香槟金对齐) */
|
||||
export const FALLBACK_VARS = {
|
||||
'--color-primary': '#B08D57',
|
||||
'--color-primary-soft': '#d9b97f',
|
||||
'--color-bg': '#FAF7F2',
|
||||
'--color-bg-soft': '#f5f2ec',
|
||||
'--color-surface': '#ffffff',
|
||||
'--color-text': '#1C1917',
|
||||
'--color-text-soft': '#666666',
|
||||
'--color-border': '#E7DFD3',
|
||||
'--radius-md': '16rpx',
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取主题并落地;失败不抛,走缓存/兜底
|
||||
*/
|
||||
export async function loadAndApplyTheme() {
|
||||
let theme = null
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[theme] GET theme …')
|
||||
theme = await getThemeApi()
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[theme] ok', theme && theme.code, theme && theme.fallback)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[theme] request failed', e)
|
||||
}
|
||||
if (!theme || !theme.css_vars) {
|
||||
theme = {
|
||||
css_vars: getCache(STORAGE_VARS_KEY) || FALLBACK_VARS,
|
||||
tokens: getCache(STORAGE_KEY) || {},
|
||||
fallback: true,
|
||||
}
|
||||
}
|
||||
applyTheme(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
/** 写入缓存;H5 同步到 documentElement */
|
||||
export function applyTheme(theme) {
|
||||
if (!theme) return
|
||||
const vars = theme.css_vars || FALLBACK_VARS
|
||||
setCache(STORAGE_VARS_KEY, vars)
|
||||
if (theme.tokens) {
|
||||
setCache(STORAGE_KEY, theme.tokens)
|
||||
}
|
||||
applyVarsToDom(vars)
|
||||
}
|
||||
|
||||
export function applyVarsToDom(vars) {
|
||||
if (!vars) return
|
||||
// #ifdef H5
|
||||
try {
|
||||
const root = document.documentElement
|
||||
Object.entries(vars).forEach(([k, v]) => {
|
||||
root.style.setProperty(k, String(v))
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
export function getActiveVars() {
|
||||
return getCache(STORAGE_VARS_KEY) || FALLBACK_VARS
|
||||
}
|
||||
|
||||
export function varsToStyleString(vars) {
|
||||
const obj = vars || getActiveVars()
|
||||
return Object.entries(obj)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('; ')
|
||||
}
|
||||
Reference in New Issue
Block a user