初始化

缺陷:主题配色需要优化整体的同风格
This commit is contained in:
2026-08-14 23:17:19 +08:00
parent 6da262b134
commit 516f077568
26 changed files with 2280 additions and 1370 deletions

View 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` 里还提交了真实数据库密码——这些是待清理的历史欠账,不要照抄这个做法。

View 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 条,其余是反射自动注册出来的死路由,不要跟着迁。

View File

@@ -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
View 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-wxBRAND = 'brm'
* - 维伦项目lgp-wx-newBRAND = 'wl'
* 同步到另一个项目时只改这一行 BRANDbaseURL / 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 baseURLinterceptor.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,
};

View File

@@ -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 统一注入:开发环境走本地,生产环境按 BRANDbrm/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}`;
}

View File

@@ -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-itemWxListService::updateItem
* 只传需要改的字段即可,后端按 array_key_exists 判断。
* @param {number} id 明细 idlist_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
})
}

View File

@@ -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
View 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})
}
/**
* 上传转账凭证图片到 OSSwx/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
View 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
View 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_idsearch 只带 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
View 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()
}
},
}

View File

@@ -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": "我",

View File

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

View File

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

View File

@@ -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>
<!-- 优化1Search 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: [],

View File

@@ -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({

View File

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

View File

@@ -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
View 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
View 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 {
// 顶部 Tabundefined 表示全部
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>

View File

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

View File

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

View File

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

@@ -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
View 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('; ')
}