mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-10 02:49:03 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c4604a342 | ||
|
|
a094a470f7 | ||
|
|
ecbb80e24a | ||
|
|
b2981b3ad7 |
63
README.md
63
README.md
@@ -162,42 +162,43 @@ lilishop-ui
|
|||||||
|
|
||||||
## 8. 升级计划
|
## 8. 升级计划
|
||||||
|
|
||||||
### ✅ 已完成特性
|
### ✅ 已完成功能
|
||||||
|
|
||||||
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
|
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
|
||||||
|
|
||||||
| 时间 | 功能模块 | 完成内容 |
|
| 时间 | 功能模块 | 完成内容 |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| 2026-11-05 | ✅ 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
|
| 2026-11-06 | 提货卡 | 支持提货卡活动与候选商品配置、制卡发卡、兑换核销及使用记录管理。 |
|
||||||
| 2026-11-04 | ✅ 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
|
| 2026-11-05 | 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
|
||||||
| 2026-11-04 | ✅ 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
|
| 2026-11-04 | 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
|
||||||
| 2026-11-04 | ✅ 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
|
| 2026-11-04 | 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
|
||||||
| 2026-11-04 | ✅ 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
|
| 2026-11-04 | 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
|
||||||
| 2026-11-04 | ✅ 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
|
| 2026-11-04 | 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
|
||||||
| 2026-11-02 | ✅ 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
|
| 2026-11-04 | 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
|
||||||
| 2026-10-30 | ✅ PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
|
| 2026-11-02 | 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
|
||||||
| 2026-10-29 | ✅ 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
|
| 2026-10-30 | PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
|
||||||
| 2026-10-29 | ✅ 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
|
| 2026-10-29 | 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
|
||||||
| 2026-10-29 | ✅ PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
|
| 2026-10-29 | 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
|
||||||
| 2026-10-22 | ✅ 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
|
| 2026-10-29 | PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
|
||||||
| 2026-10-20 | ✅ 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
|
| 2026-10-22 | 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
|
||||||
| 2026-10-09 | ✅ 主题色管理 | 完善主题设置,支持自定义模式。 |
|
| 2026-10-20 | 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
|
||||||
| 2026-10-02 | ✅ 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
|
| 2026-10-09 | 主题色管理 | 完善主题设置,支持自定义模式。 |
|
||||||
| 2026-08-05 | ✅ 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
|
| 2026-10-02 | 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
|
||||||
| 2026-08-03 | ✅ 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
|
| 2026-08-05 | 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
|
||||||
| 2026-07-23 | ✅ 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
|
| 2026-08-03 | 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
|
||||||
| 2026-07-20 | ✅ 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
|
| 2026-07-23 | 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
|
||||||
| 2026-07-13 | ✅ 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
|
| 2026-07-20 | 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
|
||||||
| 2026-06-30 | ✅ 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
|
| 2026-07-13 | 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
|
||||||
| 2026-06-29 | ✅ 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
|
| 2026-06-30 | 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
|
||||||
| 2026-06-05 | ✅ 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
|
| 2026-06-29 | 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
|
||||||
| 2026-05-22 | ✅ 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
|
| 2026-06-05 | 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
|
||||||
| 2026-05-20 | ✅ 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
|
| 2026-05-22 | 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
|
||||||
| 2026-04-16 | ✅ 置顶评论 | 支持评论置顶及商品评价管理。 |
|
| 2026-05-20 | 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
|
||||||
| 2026-04-13 | ✅ 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
|
| 2026-04-16 | 置顶评论 | 支持评论置顶及商品评价管理。 |
|
||||||
| 2026-04-01 | ✅ 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
|
| 2026-04-13 | 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
|
||||||
| 2026-03-30 | ✅ 商品分组 | 新增商品分组管理及商品关联功能。 |
|
| 2026-04-01 | 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
|
||||||
| 2026-03-24 | ✅ 会员分组 | 新增会员分组管理。 |
|
| 2026-03-30 | 商品分组 | 新增商品分组管理及商品关联功能。 |
|
||||||
|
| 2026-03-24 | 会员分组 | 新增会员分组管理。 |
|
||||||
|
|
||||||
### 🔜 近期规划中
|
### 🔜 近期规划中
|
||||||
- 🔜 微信小店对接
|
- 🔜 微信小店对接
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { setupRequestErrorHandling } from "@/utils/requestError";
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import "core-js/stable";
|
import "core-js/stable";
|
||||||
@@ -24,6 +25,7 @@ if (aMapSecurityJsCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
setupRequestErrorHandling(app);
|
||||||
|
|
||||||
setupElementPlus(app);
|
setupElementPlus(app);
|
||||||
setupLegacyMessage(app);
|
setupLegacyMessage(app);
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ function handleSubmit () { // 提交密码
|
|||||||
Message.success('修改密码成功');
|
Message.success('修改密码成功');
|
||||||
router.push('login');
|
router.push('login');
|
||||||
}
|
}
|
||||||
}).catch(() => { loading.value = false; });
|
}).catch(() => { loading1.value = false; });
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { catchHandledRequestError } from "@/utils/requestError";
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
@@ -230,6 +231,8 @@ function handleSubmit() {
|
|||||||
params.content = "";
|
params.content = "";
|
||||||
getDetail();
|
getDetail();
|
||||||
}
|
}
|
||||||
|
}).catch(catchHandledRequestError).finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- 列表 -->
|
<!-- 列表 -->
|
||||||
<empty v-if="orderList.length === 0"/>
|
<el-skeleton v-if="spinShow" :rows="5" animated />
|
||||||
|
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||||
|
<empty v-else-if="orderList.length === 0" />
|
||||||
<div class="order-content" v-else>
|
<div class="order-content" v-else>
|
||||||
<div class="order-cols">
|
<div class="order-cols">
|
||||||
<span>订单详情</span>
|
<span>订单详情</span>
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="page-size">
|
<div class="page-size">
|
||||||
@@ -134,6 +136,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { catchHandledRequestError } from "@/utils/requestError";
|
||||||
import { nextTick, onMounted, reactive, ref } from "vue";
|
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { Message, Modal } from "@/utils/message";
|
import { Message, Modal } from "@/utils/message";
|
||||||
@@ -157,6 +160,8 @@ const params = reactive({ // 请求参数
|
|||||||
order: 'desc'
|
order: 'desc'
|
||||||
});
|
});
|
||||||
const total = ref(0); // 订单总数
|
const total = ref(0); // 订单总数
|
||||||
|
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||||
|
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||||
const spinShow = ref(false); // 加载状态
|
const spinShow = ref(false); // 加载状态
|
||||||
const companyList = ref([]); // 物流公司列表
|
const companyList = ref([]); // 物流公司列表
|
||||||
const logisticsShow = ref(false); // 物流信息modal
|
const logisticsShow = ref(false); // 物流信息modal
|
||||||
@@ -230,14 +235,27 @@ function change(index) {
|
|||||||
getList()
|
getList()
|
||||||
}
|
}
|
||||||
function getList() { // 获取售后列表
|
function getList() { // 获取售后列表
|
||||||
|
const version = ++listRequestVersion;
|
||||||
|
queryFailed.value = false;
|
||||||
spinShow.value = true;
|
spinShow.value = true;
|
||||||
let paramsData = JSON.parse(JSON.stringify(params))
|
let paramsData = JSON.parse(JSON.stringify(params))
|
||||||
afterSaleList(paramsData).then(res => {
|
afterSaleList(paramsData).then(res => {
|
||||||
spinShow.value = false
|
if (version !== listRequestVersion) return;
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
orderList.value = res.result.records;
|
orderList.value = res.result.records;
|
||||||
total.value = res.result.total;
|
total.value = res.result.total;
|
||||||
}
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if (version !== listRequestVersion) {
|
||||||
|
catchHandledRequestError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryFailed.value = true;
|
||||||
|
orderList.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
catchHandledRequestError(error);
|
||||||
|
}).finally(() => {
|
||||||
|
if (version === listRequestVersion) spinShow.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function changePageNum(val) { // 修改页码
|
function changePageNum(val) { // 修改页码
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 订单列表 -->
|
<!-- 订单列表 -->
|
||||||
<empty v-if="orderList.length === 0" />
|
<el-skeleton v-if="spinShow" :rows="5" animated />
|
||||||
|
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||||
|
<empty v-else-if="orderList.length === 0" />
|
||||||
<div class="order-content" v-else>
|
<div class="order-content" v-else>
|
||||||
<div class="order-cols">
|
<div class="order-cols">
|
||||||
<span>订单详情</span>
|
<span>订单详情</span>
|
||||||
@@ -91,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
@@ -103,7 +105,9 @@
|
|||||||
</UserCenterLayout>
|
</UserCenterLayout>
|
||||||
|
|
||||||
<!-- 个人中心首页订单列表 -->
|
<!-- 个人中心首页订单列表 -->
|
||||||
<empty v-if="homePage && orderList.length === 0" />
|
<el-skeleton v-if="homePage && spinShow" :rows="5" animated />
|
||||||
|
<el-empty v-else-if="homePage && queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||||
|
<empty v-else-if="homePage && orderList.length === 0" />
|
||||||
<div class="order-content" v-else-if="homePage">
|
<div class="order-content" v-else-if="homePage">
|
||||||
<div class="order-cols">
|
<div class="order-cols">
|
||||||
<span>订单详情</span>
|
<span>订单详情</span>
|
||||||
@@ -173,7 +177,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
|
||||||
</div>
|
</div>
|
||||||
<el-dialog v-model="cancelAvail" title="请选择取消订单原因">
|
<el-dialog v-model="cancelAvail" title="请选择取消订单原因">
|
||||||
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
|
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
|
||||||
@@ -193,6 +197,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { catchHandledRequestError } from "@/utils/requestError";
|
||||||
import { onMounted, reactive, ref, watch } from "vue";
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { Message, Modal } from "@/utils/message";
|
import { Message, Modal } from "@/utils/message";
|
||||||
@@ -227,6 +232,8 @@ const cancelParams = reactive({ // 取消售后参数
|
|||||||
const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
|
const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
|
||||||
const activeOrderTab = ref(0);
|
const activeOrderTab = ref(0);
|
||||||
const total = ref(0); // 数据总数
|
const total = ref(0); // 数据总数
|
||||||
|
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||||
|
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||||
const spinShow = ref(false); // 加载状态
|
const spinShow = ref(false); // 加载状态
|
||||||
const cancelIsPickup = ref(false);
|
const cancelIsPickup = ref(false);
|
||||||
const cancelAvail = ref(false); // 取消订单modal控制
|
const cancelAvail = ref(false); // 取消订单modal控制
|
||||||
@@ -322,17 +329,30 @@ function delOrder (sn) { // 删除订单
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function getList () {
|
function getList () {
|
||||||
|
const version = ++listRequestVersion;
|
||||||
|
queryFailed.value = false;
|
||||||
spinShow.value = true;
|
spinShow.value = true;
|
||||||
let _params = JSON.parse(JSON.stringify(params))
|
let _params = JSON.parse(JSON.stringify(params))
|
||||||
if (_params.orderStatus === 'ALL') {
|
if (_params.orderStatus === 'ALL') {
|
||||||
delete _params.orderStatus
|
delete _params.orderStatus
|
||||||
}
|
}
|
||||||
getOrderList(_params).then(res => {
|
getOrderList(_params).then(res => {
|
||||||
spinShow.value = false
|
if (version !== listRequestVersion) return;
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
orderList.value = res.result.records;
|
orderList.value = res.result.records;
|
||||||
total.value = res.result.total;
|
total.value = res.result.total;
|
||||||
}
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if (version !== listRequestVersion) {
|
||||||
|
catchHandledRequestError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryFailed.value = true;
|
||||||
|
orderList.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
catchHandledRequestError(error);
|
||||||
|
}).finally(() => {
|
||||||
|
if (version === listRequestVersion) spinShow.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function mapAfterSaleOrder (item) {
|
function mapAfterSaleOrder (item) {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
<el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button>
|
<el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button>
|
||||||
</template>
|
</template>
|
||||||
<!-- 订单列表 -->
|
<!-- 订单列表 -->
|
||||||
<empty v-if="list.length === 0" />
|
<el-skeleton v-if="spinShow" :rows="5" animated />
|
||||||
|
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
|
||||||
|
<empty v-else-if="list.length === 0" />
|
||||||
<ul class="track-list" v-else>
|
<ul class="track-list" v-else>
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in list"
|
v-for="(item, index) in list"
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { catchHandledRequestError } from "@/utils/requestError";
|
||||||
import { onMounted, reactive, ref } from "vue";
|
import { onMounted, reactive, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { Message, Modal } from "@/utils/message";
|
import { Message, Modal } from "@/utils/message";
|
||||||
@@ -50,6 +53,8 @@ defineOptions({ name: "MyTrack" });
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const list = ref([]); // 我的足迹,商品列表
|
const list = ref([]); // 我的足迹,商品列表
|
||||||
|
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||||
|
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||||
const spinShow = ref(false); // 控制loading是否加载
|
const spinShow = ref(false); // 控制loading是否加载
|
||||||
const params = reactive({
|
const params = reactive({
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
@@ -118,9 +123,11 @@ function changePageSize(val) {
|
|||||||
|
|
||||||
function getList() {
|
function getList() {
|
||||||
// 获取足迹列表
|
// 获取足迹列表
|
||||||
|
const version = ++listRequestVersion;
|
||||||
|
queryFailed.value = false;
|
||||||
spinShow.value = true;
|
spinShow.value = true;
|
||||||
tracksList(params).then((res) => {
|
tracksList(params).then((res) => {
|
||||||
spinShow.value = false;
|
if (version !== listRequestVersion) return;
|
||||||
if (res.success && res.result.records.length) {
|
if (res.success && res.result.records.length) {
|
||||||
list.value = res.result.records.filter(item =>{
|
list.value = res.result.records.filter(item =>{
|
||||||
return item != null
|
return item != null
|
||||||
@@ -129,6 +136,17 @@ function getList() {
|
|||||||
} else {
|
} else {
|
||||||
list.value = [];
|
list.value = [];
|
||||||
}
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if (version !== listRequestVersion) {
|
||||||
|
catchHandledRequestError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryFailed.value = true;
|
||||||
|
list.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
catchHandledRequestError(error);
|
||||||
|
}).finally(() => {
|
||||||
|
if (version === listRequestVersion) spinShow.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ function getGoodsDetail() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
isLoading.value = false;
|
||||||
router.push("/");
|
router.push("/");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { markHandledRequestError, isHandledRequestError } from "@/utils/requestError";
|
||||||
import { ElLoading } from "element-plus";
|
import { ElLoading } from "element-plus";
|
||||||
import { Message, Modal } from "@/utils/message";
|
import { Message, Modal } from "@/utils/message";
|
||||||
import Storage from "./storage";
|
import Storage from "./storage";
|
||||||
@@ -14,7 +15,7 @@ export const buyerUrl =
|
|||||||
? BASE.API_DEV.buyer
|
? BASE.API_DEV.buyer
|
||||||
: BASE.API_PROD.buyer;
|
: BASE.API_PROD.buyer;
|
||||||
|
|
||||||
let isRefreshToken = 0;
|
let loginPrompt = null;
|
||||||
const refreshToken = getTokenDebounce();
|
const refreshToken = getTokenDebounce();
|
||||||
|
|
||||||
/** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */
|
/** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */
|
||||||
@@ -37,20 +38,22 @@ const service = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
(config) => {
|
async (config) => {
|
||||||
const { loading } = config;
|
const { loading } = config;
|
||||||
const isPutPost = config.method === "put" || config.method === "post";
|
const isPutPost = config.method === "put" || config.method === "post";
|
||||||
const isJson = config.headers["Content-Type"] === "application/json";
|
const isJson = config.headers["Content-Type"] === "application/json";
|
||||||
const isFile = config.headers["Content-Type"] === "multipart/form-data";
|
const isFile = config.headers["Content-Type"] === "multipart/form-data";
|
||||||
if (isPutPost && isJson) {
|
if (!config._dataSerialized && isPutPost && isJson) {
|
||||||
config.data = JSON.stringify(config.data);
|
config.data = JSON.stringify(config.data);
|
||||||
}
|
}
|
||||||
if (isPutPost && !isFile && !isJson) {
|
if (!config._dataSerialized && isPutPost && !isFile && !isJson) {
|
||||||
config.data = qs.stringify(config.data, {
|
config.data = qs.stringify(config.data, {
|
||||||
arrayFormat: "repeat",
|
arrayFormat: "repeat",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config._dataSerialized = true;
|
||||||
|
|
||||||
const method = (config.method || "get").toLowerCase();
|
const method = (config.method || "get").toLowerCase();
|
||||||
const isReadRequest = method === "get" || method === "head";
|
const isReadRequest = method === "get" || method === "head";
|
||||||
const shouldShowLoading =
|
const shouldShowLoading =
|
||||||
@@ -58,7 +61,7 @@ service.interceptors.request.use(
|
|||||||
(!isReadRequest && loading !== false && !isMemberCenterRoute());
|
(!isReadRequest && loading !== false && !isMemberCenterRoute());
|
||||||
|
|
||||||
if (typeof window !== "undefined" && shouldShowLoading) {
|
if (typeof window !== "undefined" && shouldShowLoading) {
|
||||||
config.loading = ElLoading.service({ fullscreen: true });
|
config._loadingInstance = ElLoading.service({ fullscreen: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
let uuid = Storage.getItem("uuid");
|
let uuid = Storage.getItem("uuid");
|
||||||
@@ -82,7 +85,7 @@ service.interceptors.request.use(
|
|||||||
const expired = jwtData.exp < Math.round(new Date() / 1000);
|
const expired = jwtData.exp < Math.round(new Date() / 1000);
|
||||||
if (expired) {
|
if (expired) {
|
||||||
if (config.needToken) {
|
if (config.needToken) {
|
||||||
return refresh({ response: { config } });
|
return refresh(config);
|
||||||
}
|
}
|
||||||
// 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录
|
// 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录
|
||||||
Storage.removeItem("accessToken");
|
Storage.removeItem("accessToken");
|
||||||
@@ -94,7 +97,7 @@ service.interceptors.request.use(
|
|||||||
Storage.removeItem("accessToken");
|
Storage.removeItem("accessToken");
|
||||||
Storage.removeItem("refreshToken");
|
Storage.removeItem("refreshToken");
|
||||||
if (config.needToken) {
|
if (config.needToken) {
|
||||||
return refresh({ response: { config } });
|
return refresh(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,59 +107,64 @@ service.interceptors.request.use(
|
|||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 保留请求配置、状态与错误码,供页面判断业务失败并定位原因。 */
|
||||||
function buildRejectError(error, errorData = {}) {
|
function buildRejectError(error, errorData = {}) {
|
||||||
const message =
|
const message =
|
||||||
errorData.message ||
|
errorData.message ||
|
||||||
(error?.code === "ECONNABORTED" ? "连接超时,请稍候再试!" : "网络错误,请稍后再试!");
|
(error?.code === "ECONNABORTED" ? "连接超时,请稍候再试!" : "网络错误,请稍后再试!");
|
||||||
return Object.assign(new Error(message), { data: errorData });
|
return Object.assign(new Error(message), { data: errorData, config: error.config, response: error.response, code: error.code });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(error) {
|
/**
|
||||||
const getTokenRes = await refreshToken();
|
* 刷新凭证并返回请求配置;请求拦截器不能返回业务响应。每个业务请求最多刷新一次。
|
||||||
if (getTokenRes === "success") {
|
* 刷新失败时保持 Promise 拒绝,阻止后续支付、订单等成功回调继续执行。
|
||||||
if (error?.response?.config) {
|
*/
|
||||||
error.response.config.headers.accessToken = Storage.getItem("accessToken");
|
async function refresh(config) {
|
||||||
return service(error.response.config);
|
if (!config._retryAfterRefresh) {
|
||||||
|
config._retryAfterRefresh = true;
|
||||||
|
const result = await refreshToken();
|
||||||
|
if (result === "success") {
|
||||||
|
config.headers.accessToken = Storage.getItem("accessToken");
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
router.go(0);
|
|
||||||
return new Promise(() => {});
|
|
||||||
}
|
}
|
||||||
Storage.removeItem("accessToken");
|
Storage.removeItem("accessToken");
|
||||||
Storage.removeItem("refreshToken");
|
Storage.removeItem("refreshToken");
|
||||||
Storage.removeItem("userInfo");
|
Storage.removeItem("userInfo");
|
||||||
Storage.setItem("cartNum", 0);
|
Storage.setItem("cartNum", 0);
|
||||||
useAppStore().setCartNum(0);
|
useAppStore().setCartNum(0);
|
||||||
|
// 多个受保护接口同时失败时共用一个弹窗,关闭后允许再次提示。
|
||||||
|
if (!loginPrompt) {
|
||||||
const currentRoute = router.currentRoute.value;
|
const currentRoute = router.currentRoute.value;
|
||||||
Modal.confirm({
|
loginPrompt = Modal.confirm({
|
||||||
title: "请登录",
|
title: "请登录",
|
||||||
content: "请登录后执行此操作",
|
content: "请登录后执行此操作",
|
||||||
okText: "立即登录",
|
okText: "立即登录",
|
||||||
cancelText: "继续浏览",
|
cancelText: "继续浏览",
|
||||||
onOk: () => {
|
onOk: () => router.push({
|
||||||
router.push({
|
|
||||||
path: "/login",
|
path: "/login",
|
||||||
query: {
|
query: { rePath: currentRoute.path, query: JSON.stringify(currentRoute.query) },
|
||||||
rePath: currentRoute.path,
|
}),
|
||||||
query: JSON.stringify(currentRoute.query),
|
onCancel: () => router.push("/"),
|
||||||
},
|
}).finally(() => { loginPrompt = null; });
|
||||||
});
|
}
|
||||||
},
|
const error = Object.assign(new Error("登录已过期"), { code: "AUTH_EXPIRED", config });
|
||||||
onCancel: () => {
|
throw markHandledRequestError(error);
|
||||||
router.push("/");
|
|
||||||
Modal.remove();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return Promise.reject(new Error("登录已过期"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
service.interceptors.response.use(
|
service.interceptors.response.use(
|
||||||
async (response) => {
|
async (response) => {
|
||||||
await closeLoading(response);
|
await closeLoading(response);
|
||||||
|
const config = response.config;
|
||||||
|
if (!config.skipAuthHandler && [401, 403, 20004].includes(response.data?.code)) {
|
||||||
|
return service(await refresh(config));
|
||||||
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (typeof window === "undefined") return Promise.reject(error);
|
if (typeof window === "undefined") return Promise.reject(error);
|
||||||
await closeLoading(error);
|
await closeLoading(error);
|
||||||
|
if (isHandledRequestError(error)) return Promise.reject(error);
|
||||||
const errorResponse = error.response || {};
|
const errorResponse = error.response || {};
|
||||||
const errorData = errorResponse.data || {};
|
const errorData = errorResponse.data || {};
|
||||||
|
|
||||||
@@ -166,32 +174,26 @@ service.interceptors.response.use(
|
|||||||
errorResponse.status === 403 ||
|
errorResponse.status === 403 ||
|
||||||
error.response?.data?.code === 20004)
|
error.response?.data?.code === 20004)
|
||||||
) {
|
) {
|
||||||
isRefreshToken++;
|
return service(await refresh(error.config || errorResponse.config));
|
||||||
if (isRefreshToken === 1) {
|
|
||||||
isRefreshToken = 0;
|
|
||||||
return refresh(error);
|
|
||||||
}
|
}
|
||||||
return Promise.reject(buildRejectError(error, errorData));
|
if (!axios.isAxiosError(error) || axios.isCancel(error)) return Promise.reject(error);
|
||||||
|
const rejected = buildRejectError(error, errorResponse.status === 404
|
||||||
|
? { ...errorData, message: "请求的资源不存在" }
|
||||||
|
: errorData);
|
||||||
|
if (!error.config?.skipMessage && axios.isAxiosError(error) && !axios.isCancel(error)) {
|
||||||
|
Message.error(rejected.message);
|
||||||
|
markHandledRequestError(rejected);
|
||||||
}
|
}
|
||||||
if (errorResponse.status === 404) {
|
return Promise.reject(rejected);
|
||||||
return Promise.reject(buildRejectError(error, { message: "请求的资源不存在" }));
|
|
||||||
}
|
|
||||||
if (error.message && !error.config?.skipMessage) {
|
|
||||||
const _message =
|
|
||||||
error.code === "ECONNABORTED"
|
|
||||||
? "连接超时,请稍候再试!"
|
|
||||||
: "网络错误,请稍后再试!";
|
|
||||||
Message.error(errorData.message || _message);
|
|
||||||
}
|
|
||||||
return Promise.reject(buildRejectError(error, errorData));
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeLoading = (target) => {
|
const closeLoading = (target) => {
|
||||||
if (!target.config || !target.config.loading) return true;
|
if (typeof target.config?._loadingInstance?.close !== "function") return true;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
target.config.loading.close();
|
target.config._loadingInstance.close();
|
||||||
|
delete target.config._loadingInstance;
|
||||||
resolve();
|
resolve();
|
||||||
}, 200);
|
}, 200);
|
||||||
});
|
});
|
||||||
@@ -208,42 +210,22 @@ export default function request(options) {
|
|||||||
return service(options);
|
return service(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 并发刷新共用同一个 Promise;不轮询,不保留上次刷新结果,失败后允许下次重试。 */
|
||||||
function getTokenDebounce() {
|
function getTokenDebounce() {
|
||||||
let lock = false;
|
let pending = null;
|
||||||
let success = false;
|
|
||||||
return function () {
|
return function () {
|
||||||
if (!lock) {
|
if (pending) return pending;
|
||||||
const oldRefreshToken = Storage.getItem("refreshToken");
|
const token = Storage.getItem("refreshToken");
|
||||||
// 无 refreshToken(未登录)时不发起刷新请求,直接判失败,避免请求 /refresh/undefined 造成拦截器死锁
|
if (!token) return Promise.resolve("fail");
|
||||||
if (!oldRefreshToken) {
|
pending = handleRefreshToken(token)
|
||||||
return Promise.resolve("fail");
|
|
||||||
}
|
|
||||||
lock = true;
|
|
||||||
handleRefreshToken(oldRefreshToken)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.success) {
|
if (!res.success || !res.result?.accessToken || !res.result?.refreshToken) return "fail";
|
||||||
const { accessToken, refreshToken: newRefreshToken } = res.result;
|
Storage.setItem("accessToken", res.result.accessToken);
|
||||||
Storage.setItem("accessToken", accessToken);
|
Storage.setItem("refreshToken", res.result.refreshToken);
|
||||||
Storage.setItem("refreshToken", newRefreshToken);
|
return "success";
|
||||||
success = true;
|
|
||||||
lock = false;
|
|
||||||
} else {
|
|
||||||
success = false;
|
|
||||||
lock = false;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => "fail")
|
||||||
success = false;
|
.finally(() => { pending = null; });
|
||||||
lock = false;
|
return pending;
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (!lock) {
|
|
||||||
clearInterval(timer);
|
|
||||||
resolve(success ? "success" : "fail");
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
40
buyer/src/utils/requestError.js
Normal file
40
buyer/src/utils/requestError.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 已向用户提示的请求异常兜底;只识别本模块登记的错误,不屏蔽代码异常。
|
||||||
|
* @author Bulbasaur
|
||||||
|
* @since 2026-11-09
|
||||||
|
*/
|
||||||
|
const handledErrors = new WeakSet();
|
||||||
|
|
||||||
|
/** 登记已由请求层提示的错误,仍保持原请求的拒绝语义。 */
|
||||||
|
export function markHandledRequestError(error) {
|
||||||
|
handledErrors.add(error);
|
||||||
|
// 开发服务器的错误遮罩有独立监听器,通过同一 Symbol 识别已提示的异常。
|
||||||
|
Object.defineProperty(error, Symbol.for("lilishop.buyer.handledRequestError"), { value: true });
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断错误是否已由请求层向用户展示。 */
|
||||||
|
export function isHandledRequestError(error) {
|
||||||
|
return !!error && typeof error === "object" && handledErrors.has(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面处理预期请求失败;程序错误继续向上抛出,避免被静默吞掉。 */
|
||||||
|
export function catchHandledRequestError(error) {
|
||||||
|
if (!isHandledRequestError(error)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 覆盖未返回 Promise 的事件回调及 Vue 异步钩子;只有已提示的请求异常免于重复报错。
|
||||||
|
* 未登记异常沿用已有 Vue 错误处理,无处理器时重新抛出供开发环境定位。
|
||||||
|
*/
|
||||||
|
export function setupRequestErrorHandling(app, target = window) {
|
||||||
|
target.addEventListener("unhandledrejection", (event) => {
|
||||||
|
if (isHandledRequestError(event.reason)) event.preventDefault();
|
||||||
|
});
|
||||||
|
const previous = app.config.errorHandler;
|
||||||
|
app.config.errorHandler = (error, instance, info) => {
|
||||||
|
if (isHandledRequestError(error)) return;
|
||||||
|
if (previous) return previous(error, instance, info);
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
}
|
||||||
194
buyer/tests/request-auth.test.cjs
Normal file
194
buyer/tests/request-auth.test.cjs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 买家端请求认证回归:验证真实 Axios 拦截链、并发刷新和已提示异常边界。
|
||||||
|
* @author Bulbasaur
|
||||||
|
* @since 2026-11-09
|
||||||
|
*/
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
/** 加载业务源码,替换浏览器与界面依赖;请求拦截和重试仍使用真实 Axios。 */
|
||||||
|
function harness({ expired = false, refresh = async () => ({ success: false }), adapter } = {}) {
|
||||||
|
const values = new Map();
|
||||||
|
const jwt = (exp) => 'header.' + Buffer.from(JSON.stringify({ exp })).toString('base64url') + '.sig';
|
||||||
|
if (expired) values.set('accessToken', jwt(1));
|
||||||
|
values.set('refreshToken', 'refresh-old');
|
||||||
|
const Storage = { getItem: k => values.get(k), setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) };
|
||||||
|
const helperSource = fs.readFileSync(path.join(__dirname, '../src/utils/requestError.js'), 'utf8').replace(/export /g, '');
|
||||||
|
const helper = new Function(helperSource + '; return { markHandledRequestError, isHandledRequestError, catchHandledRequestError, setupRequestErrorHandling };')();
|
||||||
|
let prompts = 0, refreshCalls = 0, closes = 0, opens = 0;
|
||||||
|
const messages = [], sent = [];
|
||||||
|
let dismiss, promptOptions;
|
||||||
|
const Modal = { confirm(options) { prompts++; promptOptions = options; return new Promise(resolve => { dismiss = resolve; }); } };
|
||||||
|
const router = { currentRoute: { value: { path: '/home/MyOrder', query: { tag: 'ALL' } } }, push: async () => {} };
|
||||||
|
const axiosProxy = { ...axios, create(options) { return axios.create({ ...options, adapter: async config => {
|
||||||
|
sent.push(config);
|
||||||
|
return adapter ? adapter(config, sent.length) : { data: { success: true }, status: 200, config, headers: {} };
|
||||||
|
} }); } };
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../src/plugins/request.js'), 'utf8')
|
||||||
|
.replace(/^import .*;\r?\n/gm, '').replace(/export default function request/, 'function request').replace(/export const /g, 'const ');
|
||||||
|
const names = ['axios','markHandledRequestError','isHandledRequestError','ElLoading','Message','Modal','Storage','router','useAppStore','handleRefreshToken','uuidv4','require','process','BASE','window','setTimeout'];
|
||||||
|
const request = new Function(...names, source + '; return request;')(
|
||||||
|
axiosProxy, helper.markHandledRequestError, helper.isHandledRequestError,
|
||||||
|
{ service() { opens++; return { close() { closes++; } }; } }, { error: m => messages.push(m) }, Modal, Storage, router,
|
||||||
|
() => ({ setCartNum() {} }), async () => { refreshCalls++; return refresh(jwt); }, () => 'uuid', require,
|
||||||
|
{ env: { NODE_ENV: 'development' } }, { API_DEV: { buyer: '/api' } }, { atob: v => Buffer.from(v,'base64').toString('binary') }, fn => fn()
|
||||||
|
);
|
||||||
|
return { request, values, helper, sent, messages, get prompts() { return prompts; }, get refreshCalls() { return refreshCalls; }, get opens() { return opens; }, get closes() { return closes; }, dismiss: () => dismiss(), get promptOptions() { return promptOptions; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造后端拒绝登录的 HTTP 响应。 */
|
||||||
|
function unauthorized(config) {
|
||||||
|
throw new axios.AxiosError('unauthorized', 'ERR_BAD_REQUEST', config, null, { status: 401, data: {}, config });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('并发过期请求只刷新一次,返回配置并各发送一次业务请求', async () => {
|
||||||
|
const h = harness({ expired: true, refresh: async jwt => {
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new-refresh' } };
|
||||||
|
} });
|
||||||
|
const results = await Promise.all([1,2,3].map(() => h.request({ url: '/orders', needToken: true })));
|
||||||
|
assert.equal(h.refreshCalls, 1);
|
||||||
|
assert.equal(h.sent.length, 3);
|
||||||
|
assert.ok(results.every(r => r.success));
|
||||||
|
assert.equal(h.prompts, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刷新失败保持拒绝、只弹一次登录框并清理身份与加载', async () => {
|
||||||
|
const h = harness({ expired: true });
|
||||||
|
let successes = 0;
|
||||||
|
const results = await Promise.allSettled([1,2,3].map(() => h.request({ url: '/orders', needToken: true, loading: true }).then(() => { successes++; })));
|
||||||
|
assert.equal(successes, 0);
|
||||||
|
assert.equal(h.sent.length, 0);
|
||||||
|
assert.equal(h.prompts, 1);
|
||||||
|
assert.equal(h.closes, h.opens);
|
||||||
|
assert.equal(h.values.get('accessToken'), undefined);
|
||||||
|
assert.ok(results.every(r => r.status === 'rejected' && h.helper.isHandledRequestError(r.reason)));
|
||||||
|
assert.equal(h.messages.length, 0);
|
||||||
|
h.dismiss();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
await assert.rejects(h.request({ url: '/orders', needToken: true, adapter: unauthorized }));
|
||||||
|
assert.equal(h.prompts, 2);
|
||||||
|
assert.equal(h.refreshCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('401 重试上限防止刷新死循环,POST 请求体保持一致', async () => {
|
||||||
|
const h = harness({ adapter: unauthorized, refresh: async jwt => ({ success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } }) });
|
||||||
|
await assert.rejects(h.request({ url: '/orders', method: 'post', data: { amount: 12 }, headers: { 'Content-Type': 'application/json' }, needToken: true, loading: true }), e => e.code === 'AUTH_EXPIRED');
|
||||||
|
assert.equal(h.refreshCalls, 1);
|
||||||
|
assert.equal(h.sent.length, 2);
|
||||||
|
assert.equal(h.sent[0].data, '{"amount":12}');
|
||||||
|
assert.equal(h.sent[1].data, h.sent[0].data);
|
||||||
|
assert.equal(h.opens, h.closes);
|
||||||
|
assert.equal(h.prompts, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 200 内认证失败进入同一处理,刷新接口不递归', async () => {
|
||||||
|
const h = harness({ adapter: async config => ({ config, status: 200, data: { code: 20004 }, headers: {} }) });
|
||||||
|
await assert.rejects(h.request({ url: '/orders' }), e => e.code === 'AUTH_EXPIRED');
|
||||||
|
const skipped = await h.request({ url: '/refresh', skipAuthHandler: true, skipMessage: true });
|
||||||
|
assert.equal(skipped.code, 20004);
|
||||||
|
assert.equal(h.refreshCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('网络失败只提示一次,skipMessage 与程序错误不登记为已处理', async () => {
|
||||||
|
const h = harness({ adapter: config => { throw new axios.AxiosError('timeout', 'ECONNABORTED', config); } });
|
||||||
|
await assert.rejects(h.request({ url: '/orders' }), e => h.helper.isHandledRequestError(e));
|
||||||
|
assert.deepEqual(h.messages, ['连接超时,请稍候再试!']);
|
||||||
|
await assert.rejects(h.request({ url: '/refresh', skipMessage: true }), e => !h.helper.isHandledRequestError(e));
|
||||||
|
const bug = new TypeError('bug');
|
||||||
|
const b = harness({ adapter: () => { throw bug; } });
|
||||||
|
await assert.rejects(b.request({ url: '/orders' }), e => e === bug);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全局兜底和开发遮罩仅过滤已提示错误,未知错误仍上报', () => {
|
||||||
|
const h = harness();
|
||||||
|
const handled = h.helper.markHandledRequestError(new Error('登录已过期'));
|
||||||
|
const bug = new TypeError('bug');
|
||||||
|
const listeners = {};
|
||||||
|
const app = { config: {} };
|
||||||
|
h.helper.setupRequestErrorHandling(app, { addEventListener: (name, fn) => { listeners[name] = fn; } });
|
||||||
|
let prevented = 0;
|
||||||
|
listeners.unhandledrejection({ reason: handled, preventDefault: () => prevented++ });
|
||||||
|
listeners.unhandledrejection({ reason: bug, preventDefault: () => prevented++ });
|
||||||
|
assert.equal(prevented, 1);
|
||||||
|
assert.doesNotThrow(() => app.config.errorHandler(handled));
|
||||||
|
assert.throws(() => app.config.errorHandler(bug), e => e === bug);
|
||||||
|
assert.throws(() => h.helper.catchHandledRequestError(bug), e => e === bug);
|
||||||
|
const filter = require('../vue.config.js').devServer.client.overlay.runtimeErrors;
|
||||||
|
assert.equal(filter(handled), false);
|
||||||
|
assert.equal(filter(bug), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('未登录不刷新 undefined,公开接口保持匿名可访问', async () => {
|
||||||
|
const h = harness({ adapter: config => {
|
||||||
|
if (config.needToken) return unauthorized(config);
|
||||||
|
return { config, status: 200, data: { success: true }, headers: {} };
|
||||||
|
} });
|
||||||
|
h.values.delete('refreshToken');
|
||||||
|
await assert.rejects(h.request({ url: '/orders', needToken: true }), e => e.code === 'AUTH_EXPIRED');
|
||||||
|
const result = await h.request({ url: '/goods', optionalToken: true });
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
assert.equal(h.refreshCalls, 0);
|
||||||
|
assert.equal(h.prompts, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 401 并发共用刷新,表单 POST 重试不重复编码', async () => {
|
||||||
|
const h = harness({
|
||||||
|
adapter: config => {
|
||||||
|
if (!config.headers.accessToken) return unauthorized(config);
|
||||||
|
return { config, status: 200, data: { success: true }, headers: {} };
|
||||||
|
},
|
||||||
|
refresh: async jwt => {
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.all([1,2].map(() => h.request({ url: '/orders', method: 'post', data: { name: 'a b' }, needToken: true })));
|
||||||
|
assert.equal(h.refreshCalls, 1);
|
||||||
|
assert.equal(h.sent.length, 4);
|
||||||
|
assert.ok(h.sent.every(c => c.data === 'name=a%20b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/** 从真实 Vue 页面提取查询函数,验证失败收尾与请求乱序,界面依赖用响应式状态桩替换。 */
|
||||||
|
function listHarness(file, apiName) {
|
||||||
|
const { parse } = require('@vue/compiler-sfc');
|
||||||
|
const parser = require('@babel/parser');
|
||||||
|
const source = parse(fs.readFileSync(path.join(__dirname, '../src/pages/home', file), 'utf8')).descriptor.scriptSetup.content;
|
||||||
|
const declaration = parser.parse(source, { sourceType: 'module' }).program.body.find(n => n.type === 'FunctionDeclaration' && n.id.name === 'getList');
|
||||||
|
const state = { spinShow: { value: false }, queryFailed: { value: false }, orderList: { value: [] }, list: { value: [] }, total: { value: 0 } };
|
||||||
|
const calls = [];
|
||||||
|
const api = () => new Promise((resolve, reject) => calls.push({ resolve, reject }));
|
||||||
|
const getList = new Function(...Object.keys(state), 'params', apiName, 'catchHandledRequestError',
|
||||||
|
'let listRequestVersion = 0; ' + source.slice(declaration.start, declaration.end) + '; return getList;')(
|
||||||
|
...Object.values(state), { pageNumber: 1 }, api, () => {}
|
||||||
|
);
|
||||||
|
return { ...state, calls, getList };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [file, api] of [['orderCenter/MyOrder.vue','getOrderList'], ['orderCenter/AfterSale.vue','afterSaleList'], ['userCenter/MyTracks.vue','tracksList']]) {
|
||||||
|
test(file + ' 失败结束加载并可重试,旧查询不能覆盖新列表', async () => {
|
||||||
|
const h = listHarness(file, api);
|
||||||
|
h.getList();
|
||||||
|
assert.equal(h.spinShow.value, true);
|
||||||
|
h.calls[0].reject(new Error('network'));
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(h.spinShow.value, false);
|
||||||
|
assert.equal(h.queryFailed.value, true);
|
||||||
|
h.getList();
|
||||||
|
h.getList();
|
||||||
|
h.calls[2].resolve({ success: true, result: { records: [{ id: 'new' }], total: 1 } });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
h.calls[1].resolve({ success: true, result: { records: [{ id: 'old' }], total: 20 } });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(h.spinShow.value, false);
|
||||||
|
assert.equal(h.queryFailed.value, false);
|
||||||
|
assert.equal(h.total.value, 1);
|
||||||
|
const list = file.includes('MyTracks') ? h.list : h.orderList;
|
||||||
|
assert.deepEqual(list.value, [{ id: 'new' }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,12 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
port: configs.port,
|
port: configs.port,
|
||||||
|
client: {
|
||||||
|
overlay: {
|
||||||
|
// 保留编译错误和程序异常遮罩,仅跳过请求层已向用户提示的错误。
|
||||||
|
runtimeErrors: (error) => error?.[Symbol.for("lilishop.buyer.handledRequestError")] !== true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
productionSourceMap: false,
|
productionSourceMap: false,
|
||||||
configureWebpack: {
|
configureWebpack: {
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ class Talk extends Base {
|
|||||||
super();
|
super();
|
||||||
this.sender_id = resource.fromUser; //发送
|
this.sender_id = resource.fromUser; //发送
|
||||||
this.receiver_id = resource.toUser; //接收
|
this.receiver_id = resource.toUser; //接收
|
||||||
this.talk_type = resource.messageType; //类型
|
// 后端推送的 messageType 是消息内容类型(MESSAGE/PICTURE/GOODS/ORDERS),私聊会话类型固定为 1
|
||||||
|
this.talk_type = Number(resource.talk_type) || 1;
|
||||||
|
|
||||||
this.resource = resource;
|
this.resource = resource;
|
||||||
|
|
||||||
@@ -93,12 +94,22 @@ class Talk extends Base {
|
|||||||
return userId == this.getAccountId() ? "right" : "left";
|
return userId == this.getAccountId() ? "right" : "left";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息内容类型(MESSAGE/PICTURE/GOODS/ORDERS)
|
||||||
|
*/
|
||||||
|
getMessageType () {
|
||||||
|
return this.resource.messageType || this.resource.msg_type || "MESSAGE";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聊天列表左侧的对话信息
|
* 获取聊天列表左侧的对话信息
|
||||||
*/
|
*/
|
||||||
getTalkText () {
|
getTalkText () {
|
||||||
let text = this.resource.content || this.resource.text;
|
let text = this.resource.content || this.resource.text;
|
||||||
switch (this.resource.msg_type) {
|
switch (this.getMessageType()) {
|
||||||
|
case 'PICTURE':
|
||||||
|
text = "[图片]";
|
||||||
|
break;
|
||||||
case 'GOODS':
|
case 'GOODS':
|
||||||
text = "[商品链接]";
|
text = "[商品链接]";
|
||||||
break;
|
break;
|
||||||
@@ -224,10 +235,15 @@ class Talk extends Base {
|
|||||||
}
|
}
|
||||||
// console.log("%c 准备更新...UPDATE_TALK_ITEM ", "color:red");
|
// console.log("%c 准备更新...UPDATE_TALK_ITEM ", "color:red");
|
||||||
|
|
||||||
|
const nowTime = parseTime(new Date());
|
||||||
|
const msgText = this.getTalkText() || record.text;
|
||||||
talkStore.UPDATE_TALK_ITEM({
|
talkStore.UPDATE_TALK_ITEM({
|
||||||
index_name: this.getIndexName(),
|
index_name: this.getIndexName(),
|
||||||
msg_text: this.getTalkText() || record.text,
|
msg_text: msgText,
|
||||||
updated_at: parseTime(new Date()),
|
lastTalkMessage: msgText,
|
||||||
|
lastTalkTime: nowTime,
|
||||||
|
lastMessageType: this.getMessageType(),
|
||||||
|
updated_at: nowTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.talk_type == 1 && this.getAccountId() !== this.sender_id) {
|
if (this.talk_type == 1 && this.getAccountId() !== this.sender_id) {
|
||||||
@@ -248,6 +264,7 @@ class Talk extends Base {
|
|||||||
useTalkStore().UPDATE_TALK_MESSAGE({
|
useTalkStore().UPDATE_TALK_MESSAGE({
|
||||||
index_name: this.getIndexName(),
|
index_name: this.getIndexName(),
|
||||||
msg_text: this.getTalkText(),
|
msg_text: this.getTalkText(),
|
||||||
|
lastMessageType: this.getMessageType(),
|
||||||
updated_at: parseTime(new Date()),
|
updated_at: parseTime(new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export default {
|
|||||||
await this.createTalk(this.$route.query.id);
|
await this.createTalk(this.$route.query.id);
|
||||||
} else if (typeof this.loadChatList === "function") {
|
} else if (typeof this.loadChatList === "function") {
|
||||||
await this.loadChatList();
|
await this.loadChatList();
|
||||||
|
} else if (typeof this.loadChatListInJs === "function") {
|
||||||
|
await this.loadChatListInJs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(this.$route.query.goodsId && this.$route.query.skuId && this.goodsParams){
|
if(this.$route.query.goodsId && this.$route.query.skuId && this.goodsParams){
|
||||||
@@ -104,8 +106,8 @@ export default {
|
|||||||
items: result.map((item) => formatTalkItem(item)),
|
items: result.map((item) => formatTalkItem(item)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 判断
|
// 判断(仅在挂载了 clickTab 的页面组件实例上自动切换会话)
|
||||||
if (this.$route.query.id) {
|
if (this.$route.query.id && typeof this.clickTab === "function") {
|
||||||
let takeData, takeIndex;
|
let takeData, takeIndex;
|
||||||
result.forEach((take, index) => {
|
result.forEach((take, index) => {
|
||||||
if (take.id == this.$route.query.id) {
|
if (take.id == this.$route.query.id) {
|
||||||
@@ -113,9 +115,13 @@ export default {
|
|||||||
takeIndex = index;
|
takeIndex = index;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (typeof this.$nextTick === "function") {
|
||||||
this.$nextTick(() =>
|
this.$nextTick(() =>
|
||||||
this.clickTab(this.$route.query.id, takeData, takeIndex)
|
this.clickTab(this.$route.query.id, takeData, takeIndex)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
this.clickTab(this.$route.query.id, takeData, takeIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.loadStatus = 1;
|
this.loadStatus = 1;
|
||||||
|
|||||||
@@ -71,11 +71,17 @@ export const useTalkStore = defineStore("talks", {
|
|||||||
UPDATE_TALK_MESSAGE(resource) {
|
UPDATE_TALK_MESSAGE(resource) {
|
||||||
let enableGetTalkList = true;
|
let enableGetTalkList = true;
|
||||||
this.items.forEach((item) => {
|
this.items.forEach((item) => {
|
||||||
if (item.userId == resource.index_name.split("_")[1]) {
|
if (
|
||||||
|
item.index_name === resource.index_name ||
|
||||||
|
item.userId == resource.index_name.split("_")[1]
|
||||||
|
) {
|
||||||
item.unread++;
|
item.unread++;
|
||||||
item.msg_text = resource.msg_text;
|
item.msg_text = resource.msg_text;
|
||||||
item.lastTalkTime = resource.updated_at;
|
item.lastTalkTime = resource.updated_at;
|
||||||
item.lastTalkMessage = resource.msg_text;
|
item.lastTalkMessage = resource.msg_text;
|
||||||
|
if (resource.lastMessageType) {
|
||||||
|
item.lastMessageType = resource.lastMessageType;
|
||||||
|
}
|
||||||
item.updated_at = resource.updated_at;
|
item.updated_at = resource.updated_at;
|
||||||
enableGetTalkList = false;
|
enableGetTalkList = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const mixinCtx = {
|
|||||||
return router.currentRoute.value;
|
return router.currentRoute.value;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
mixinCtx.loadChatListInJs = MainMixin.methods.loadChatListInJs.bind(mixinCtx);
|
||||||
|
mixinCtx.loadStoreChatList = MainMixin.methods.loadStoreChatList.bind(mixinCtx);
|
||||||
|
|
||||||
export function setRefreshView(fn) {
|
export function setRefreshView(fn) {
|
||||||
refreshViewFn = fn;
|
refreshViewFn = fn;
|
||||||
|
|||||||
@@ -113,8 +113,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="talk-message">
|
<div class="talk-message">
|
||||||
<span v-if="item.lastMessageType === 'MESSAGE'" v-html="textReplaceEmoji(item.lastTalkMessage)"></span>
|
<span v-if="item.lastMessageType === 'MESSAGE'" v-html="textReplaceEmoji(item.lastTalkMessage)"></span>
|
||||||
<span v-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
|
<span v-else-if="item.lastMessageType === 'PICTURE'">[图片]</span>
|
||||||
<span v-if="item.lastMessageType === 'ORDER'">[订单链接]</span>
|
<span v-else-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
|
||||||
|
<span v-else-if="item.lastMessageType === 'ORDERS' || item.lastMessageType === 'ORDER'">[订单链接]</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<template v-if="index_name != item.index_name && item.draft_text">
|
<template v-if="index_name != item.index_name && item.draft_text">
|
||||||
@@ -428,7 +429,7 @@ function clickTab(id, val, index) {
|
|||||||
if (id == index_name.value.split("_")[1]) {
|
if (id == index_name.value.split("_")[1]) {
|
||||||
talkStore.UPDATE_TALK_ITEM({
|
talkStore.UPDATE_TALK_ITEM({
|
||||||
userId: id,
|
userId: id,
|
||||||
index_name: "MESSAGE_" + index_name.value.split("_")[1],
|
index_name: index_name.value,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ export const queryExportStock = (params) => {
|
|||||||
return getRequest(`/goods/goods/queryExportStock`, params,'blob')
|
return getRequest(`/goods/goods/queryExportStock`, params,'blob')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下载商品库存导入模板
|
||||||
|
export const downloadStockTemplate = () => {
|
||||||
|
return getRequest(`/goods/goods/downloadStockTemplate`, {}, 'blob');
|
||||||
|
};
|
||||||
|
|
||||||
// 上传商品库存列表
|
// 上传商品库存列表
|
||||||
export const importStockExcel = params => {
|
export const importStockExcel = params => {
|
||||||
return postRequestWithNoForm(`/goods/goods/importStockExcel`, params );
|
return postRequestWithNoForm(`/goods/goods/importStockExcel`, params );
|
||||||
|
|||||||
@@ -62,6 +62,18 @@
|
|||||||
<el-table-column label="库存" width="100" align="right">
|
<el-table-column label="库存" width="100" align="right">
|
||||||
<template #default="{ row }">{{ row.quantity || 0 }}</template>
|
<template #default="{ row }">{{ row.quantity || 0 }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" align="right" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.goodsType === 'E_COUPON'"
|
||||||
|
content="电子卡券库存由卡密池自动同步,不可手动修改"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="disabled-link-text">修改库存</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<a v-else class="link-text" @click="openUpdateStockModal(row)">修改库存</a>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,30 +91,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="importModal" title="导入商品信息" :close-on-click-modal="false">
|
<el-dialog v-model="importModal" title="导入商品信息" :close-on-click-modal="false" width="520px">
|
||||||
|
<div class="template-tip-bar">
|
||||||
|
<span class="template-tip-text">请按标准模板格式整理库存数据后再上传</span>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:loading="templateLoading"
|
||||||
|
@click="handleDownloadTemplate"
|
||||||
|
>
|
||||||
|
<el-icon class="mr_5"><Download /></el-icon>下载导入模板
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
<div v-loading="spinShow" style="text-align: center">
|
<div v-loading="spinShow" style="text-align: center">
|
||||||
<el-upload drag :before-upload="handleUpload" :show-file-list="false" accept=".xlsx,.xls">
|
<el-upload drag :before-upload="handleUpload" :show-file-list="false" accept=".xlsx,.xls">
|
||||||
<div style="padding: 50px 0">
|
<div style="padding: 40px 0">
|
||||||
<div style="font-size: 48px; color: #3399ff">↑</div>
|
<el-icon :size="48" style="color: #3399ff"><UploadFilled /></el-icon>
|
||||||
<h2>选择或拖拽文件上传</h2>
|
<h2 style="font-size: 16px; margin-top: 10px">选择或拖拽文件上传</h2>
|
||||||
|
<p style="font-size: 12px; color: #909399; margin-top: 8px">支持 .xlsx / .xls 格式文件</p>
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button type="primary" @click="importModal = false">确定</el-button>
|
<el-button @click="importModal = false">取消</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="updateStockModalVisible"
|
||||||
|
title="修改商品库存"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="stockFormRef"
|
||||||
|
:model="stockForm"
|
||||||
|
:rules="stockRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="商品名称">
|
||||||
|
<div class="dialog-value-text">{{ selectedSku.goodsName || "-" }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="SKU信息">
|
||||||
|
<div class="dialog-value-text">{{ selectedSku.simpleSpecs || "-" }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="当前库存">
|
||||||
|
<div class="dialog-value-text">{{ selectedSku.quantity || 0 }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="修改方向" prop="type">
|
||||||
|
<el-radio-group v-model="stockForm.type" @change="handleStockTypeChange">
|
||||||
|
<el-radio value="ADD">增</el-radio>
|
||||||
|
<el-radio value="SUB">减</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="变更数量" prop="quantity">
|
||||||
|
<el-input-number
|
||||||
|
v-model="stockForm.quantity"
|
||||||
|
:min="1"
|
||||||
|
:max="99999999"
|
||||||
|
:step="1"
|
||||||
|
:precision="0"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="变更后库存">
|
||||||
|
<span :class="['preview-stock', { 'is-insufficient': previewStock < 0 }]">
|
||||||
|
{{ previewStock }}
|
||||||
|
</span>
|
||||||
|
<span v-if="previewStock < 0" class="insufficient-tip">(库存不足以扣减)</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="updateStockModalVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="updateStockLoading" @click="submitUpdateStock">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useTable } from "@/utils/useTable";
|
import { useTable } from "@/utils/useTable";
|
||||||
|
import { Download, UploadFilled } from "@element-plus/icons-vue";
|
||||||
|
import { downloadBlob } from "@/utils/downloadBlob";
|
||||||
import {
|
import {
|
||||||
|
downloadStockTemplate,
|
||||||
getGoodsCategoryAll,
|
getGoodsCategoryAll,
|
||||||
getGoodsSkuListDataSeller,
|
getGoodsSkuListDataSeller,
|
||||||
importStockExcel,
|
importStockExcel,
|
||||||
queryExportStock,
|
queryExportStock,
|
||||||
|
updateGoodsSkuStocks,
|
||||||
} from "@/api/goods";
|
} from "@/api/goods";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
|
|
||||||
@@ -116,6 +195,43 @@ const spinShow = ref(false);
|
|||||||
const categoryList = ref([]);
|
const categoryList = ref([]);
|
||||||
const category = ref([]);
|
const category = ref([]);
|
||||||
const file = ref(null);
|
const file = ref(null);
|
||||||
|
const templateLoading = ref(false);
|
||||||
|
const updateStockModalVisible = ref(false);
|
||||||
|
const updateStockLoading = ref(false);
|
||||||
|
const selectedSku = ref({});
|
||||||
|
const stockFormRef = ref(null);
|
||||||
|
const stockForm = ref({
|
||||||
|
type: "ADD",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewStock = computed(() => {
|
||||||
|
const current = Number(selectedSku.value.quantity) || 0;
|
||||||
|
const change = Number(stockForm.value.quantity) || 0;
|
||||||
|
return stockForm.value.type === "ADD" ? current + change : current - change;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stockRules = {
|
||||||
|
type: [{ required: true, message: "请选择库存修改方向", trigger: "change" }],
|
||||||
|
quantity: [
|
||||||
|
{ required: true, message: "请输入变更数量", trigger: "blur" },
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isInteger(num) || num <= 0) {
|
||||||
|
callback(new Error("变更数量必须为正整数"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stockForm.value.type === "SUB" && num > (Number(selectedSku.value.quantity) || 0)) {
|
||||||
|
callback(new Error("扣减数量不能大于当前库存"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: ["blur", "change"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const { loading, data: stockAllData, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
|
const { loading, data: stockAllData, total, searchForm, getDataList, handleSearch, resetSearch } = useTable(
|
||||||
getGoodsSkuListDataSeller,
|
getGoodsSkuListDataSeller,
|
||||||
@@ -193,6 +309,64 @@ function openImportStock() {
|
|||||||
importModal.value = true;
|
importModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开单条商品库存增减弹窗 */
|
||||||
|
function openUpdateStockModal(row) {
|
||||||
|
selectedSku.value = { ...row };
|
||||||
|
stockForm.value = {
|
||||||
|
type: "ADD",
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
updateStockModalVisible.value = true;
|
||||||
|
stockFormRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换库存修改方向时重新校验数量 */
|
||||||
|
function handleStockTypeChange() {
|
||||||
|
stockFormRef.value?.validateField("quantity", () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交商品库存增减修改 */
|
||||||
|
function submitUpdateStock() {
|
||||||
|
stockFormRef.value?.validate((valid) => {
|
||||||
|
if (!valid) return;
|
||||||
|
updateStockLoading.value = true;
|
||||||
|
updateGoodsSkuStocks([
|
||||||
|
{
|
||||||
|
goodsId: selectedSku.value.goodsId,
|
||||||
|
skuId: selectedSku.value.id,
|
||||||
|
type: stockForm.value.type,
|
||||||
|
quantity: Number(stockForm.value.quantity),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
Message.success("修改库存成功");
|
||||||
|
updateStockModalVisible.value = false;
|
||||||
|
getDataList();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
updateStockLoading.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载商品库存导入空白模板 */
|
||||||
|
function handleDownloadTemplate() {
|
||||||
|
templateLoading.value = true;
|
||||||
|
downloadStockTemplate()
|
||||||
|
.then((res) => {
|
||||||
|
downloadBlob(res, "商品库存导入模板.xlsx");
|
||||||
|
Message.success("模板下载成功");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Message.error("模板下载失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
templateLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleUpload(f) {
|
function handleUpload(f) {
|
||||||
file.value = f;
|
file.value = f;
|
||||||
upload();
|
upload();
|
||||||
@@ -250,4 +424,47 @@ onMounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-tip-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-tip-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr_5 {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-link-text {
|
||||||
|
color: #c0c4cc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-value-text {
|
||||||
|
color: #303133;
|
||||||
|
line-height: 22px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stock {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
|
||||||
|
&.is-insufficient {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.insufficient-tip {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user