mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-12 03:48:55 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d77c58e63 | ||
|
|
c398ff4717 | ||
|
|
60de941749 | ||
|
|
402c9c9ebb | ||
|
|
2252bcb8af | ||
|
|
b4735c89f5 | ||
|
|
eb017244d3 | ||
|
|
2be2227644 | ||
|
|
9761479c82 | ||
|
|
f33c969d1e | ||
|
|
3c4604a342 | ||
|
|
a094a470f7 | ||
|
|
ecbb80e24a | ||
|
|
b2981b3ad7 |
63
README.md
63
README.md
@@ -162,42 +162,43 @@ lilishop-ui
|
||||
|
||||
## 8. 升级计划
|
||||
|
||||
### ✅ 已完成特性
|
||||
### ✅ 已完成功能
|
||||
|
||||
按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
|
||||
|
||||
| 时间 | 功能模块 | 完成内容 |
|
||||
| :--- | :--- | :--- |
|
||||
| 2026-11-05 | ✅ 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
|
||||
| 2026-11-04 | ✅ 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
|
||||
| 2026-11-04 | ✅ 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
|
||||
| 2026-11-04 | ✅ 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
|
||||
| 2026-11-04 | ✅ 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
|
||||
| 2026-11-04 | ✅ 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
|
||||
| 2026-11-02 | ✅ 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
|
||||
| 2026-10-30 | ✅ PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
|
||||
| 2026-10-29 | ✅ 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
|
||||
| 2026-10-29 | ✅ 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
|
||||
| 2026-10-29 | ✅ PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
|
||||
| 2026-10-22 | ✅ 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
|
||||
| 2026-10-20 | ✅ 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
|
||||
| 2026-10-09 | ✅ 主题色管理 | 完善主题设置,支持自定义模式。 |
|
||||
| 2026-10-02 | ✅ 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
|
||||
| 2026-08-05 | ✅ 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
|
||||
| 2026-08-03 | ✅ 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
|
||||
| 2026-07-23 | ✅ 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
|
||||
| 2026-07-20 | ✅ 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
|
||||
| 2026-07-13 | ✅ 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
|
||||
| 2026-06-30 | ✅ 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
|
||||
| 2026-06-29 | ✅ 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
|
||||
| 2026-06-05 | ✅ 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
|
||||
| 2026-05-22 | ✅ 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
|
||||
| 2026-05-20 | ✅ 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
|
||||
| 2026-04-16 | ✅ 置顶评论 | 支持评论置顶及商品评价管理。 |
|
||||
| 2026-04-13 | ✅ 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
|
||||
| 2026-04-01 | ✅ 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
|
||||
| 2026-03-30 | ✅ 商品分组 | 新增商品分组管理及商品关联功能。 |
|
||||
| 2026-03-24 | ✅ 会员分组 | 新增会员分组管理。 |
|
||||
| 2026-11-06 | 提货卡 | 支持提货卡活动与候选商品配置、制卡发卡、兑换核销及使用记录管理。 |
|
||||
| 2026-11-05 | 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
|
||||
| 2026-11-04 | 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
|
||||
| 2026-11-04 | 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
|
||||
| 2026-11-04 | 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
|
||||
| 2026-11-04 | 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
|
||||
| 2026-11-04 | 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
|
||||
| 2026-11-02 | 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
|
||||
| 2026-10-30 | PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
|
||||
| 2026-10-29 | 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
|
||||
| 2026-10-29 | 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
|
||||
| 2026-10-29 | PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
|
||||
| 2026-10-22 | 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
|
||||
| 2026-10-20 | 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
|
||||
| 2026-10-09 | 主题色管理 | 完善主题设置,支持自定义模式。 |
|
||||
| 2026-10-02 | 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
|
||||
| 2026-08-05 | 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
|
||||
| 2026-08-03 | 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
|
||||
| 2026-07-23 | 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
|
||||
| 2026-07-20 | 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
|
||||
| 2026-07-13 | 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
|
||||
| 2026-06-30 | 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
|
||||
| 2026-06-29 | 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
|
||||
| 2026-06-05 | 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
|
||||
| 2026-05-22 | 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
|
||||
| 2026-05-20 | 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
|
||||
| 2026-04-16 | 置顶评论 | 支持评论置顶及商品评价管理。 |
|
||||
| 2026-04-13 | 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
|
||||
| 2026-04-01 | 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
|
||||
| 2026-03-30 | 商品分组 | 新增商品分组管理及商品关联功能。 |
|
||||
| 2026-03-24 | 会员分组 | 新增会员分组管理。 |
|
||||
|
||||
### 🔜 近期规划中
|
||||
- 🔜 微信小店对接
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
import { onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import storage from "@/plugins/storage";
|
||||
import { getBaseSite, getThemeSetting } from "@/api/common.js";
|
||||
import { fetchAndApplyTheme } from "@/utils/theme";
|
||||
import { loadSiteSettings } from "@/composables/useSiteSettings";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
defineOptions({ name: "App" });
|
||||
@@ -49,27 +48,15 @@ function init() {
|
||||
});
|
||||
}
|
||||
getSite();
|
||||
getTheme();
|
||||
}
|
||||
|
||||
function getSite() {
|
||||
getBaseSite().then((res) => {
|
||||
if (res.success && res.result.settingValue) {
|
||||
try {
|
||||
const data = JSON.parse(res.result.settingValue);
|
||||
loadSiteSettings().then((data) => {
|
||||
if (data) {
|
||||
applySiteInfo(data);
|
||||
} catch {
|
||||
// 站点配置解析失败时使用缓存
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('获取站点配置失败', err);
|
||||
});
|
||||
}
|
||||
|
||||
function getTheme() {
|
||||
fetchAndApplyTheme(getThemeSetting).catch((err) => {
|
||||
console.warn('获取主题配置失败', err);
|
||||
console.warn("获取站点配置失败", err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -449,12 +449,12 @@ onUnmounted(() => {
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: $theme_color;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: $theme_color;
|
||||
background: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
v-if="promotionMap['SECKILL']"
|
||||
:time="promotionMap['SECKILL'].endTime"
|
||||
></Promotion>
|
||||
<!-- 商品详细 价格、优惠券、促销 -->
|
||||
<!-- 商品详细 价格 -->
|
||||
<div class="item-detail-price-row">
|
||||
<div class="item-price-left">
|
||||
<!-- 秒杀价格 -->
|
||||
@@ -89,9 +89,23 @@
|
||||
<div class="item-price-row" v-else>
|
||||
<span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-price-right">
|
||||
<div class="item-remarks-sum">
|
||||
<p>累计评价</p>
|
||||
<p>
|
||||
<span class="item-remarks-num"
|
||||
>{{ skuDetail.commentNum || 0 }} 条</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 独立促销与优惠模块 -->
|
||||
<div class="goods-promotions-box" v-if="hasPromotions">
|
||||
<!-- 优惠券展示 -->
|
||||
<div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length">
|
||||
<span class="item-price-title">优惠券</span>
|
||||
<div class="promo-item-row" v-if="promotionMap['COUPON'] && promotionMap['COUPON'].length">
|
||||
<span class="promo-title">优惠券</span>
|
||||
<div class="coupon-tags">
|
||||
<span
|
||||
class="item-coupon"
|
||||
@@ -127,46 +141,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 促销活动行(满减/满折/满赠、拼团) -->
|
||||
<div class="promo-item-row" v-if="promotionMap['FULL_DISCOUNT'] || promotionMap['PINTUAN']">
|
||||
<span class="promo-title">促 销</span>
|
||||
<div class="promo-content-column">
|
||||
<!-- 满减/满折/满赠 -->
|
||||
<div class="promo-activity-entry" v-if="promotionMap['FULL_DISCOUNT']">
|
||||
<span class="promo-badge">{{ getFullDiscountTag(promotionMap['FULL_DISCOUNT']) }}</span>
|
||||
<span class="promo-desc">{{ formatFullDiscountDesc(promotionMap['FULL_DISCOUNT']) }}</span>
|
||||
</div>
|
||||
<!-- 拼团 -->
|
||||
<div class="item-price-row" v-if="promotionMap['PINTUAN']">
|
||||
<p>
|
||||
<span class="item-price-title">拼团</span>
|
||||
<span class="item-promotion">拼团价</span>
|
||||
<span class="item-desc-pintuan" v-if="promotionMap['PINTUAN'].price">
|
||||
{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}
|
||||
<div class="promo-activity-entry" v-if="promotionMap['PINTUAN']">
|
||||
<span class="promo-badge">拼团</span>
|
||||
<span class="promo-desc">
|
||||
拼团价
|
||||
<span class="promo-price-text">{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}</span>
|
||||
,多人拼单更实惠
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 满减展示 -->
|
||||
<div class="item-price-row" v-if="promotionMap['FULL_DISCOUNT']">
|
||||
<p>
|
||||
<span class="item-price-title">促销</span>
|
||||
<span class="item-promotion">满减</span>
|
||||
<span
|
||||
class="item-desc-pintuan"
|
||||
v-if="promotionMap['FULL_DISCOUNT'].fullMinus"
|
||||
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立减现金{{
|
||||
promotionMap["FULL_DISCOUNT"].fullMinus
|
||||
}}元</span>
|
||||
<span
|
||||
class="item-desc-pintuan"
|
||||
v-if="
|
||||
promotionMap['FULL_DISCOUNT'].fullRate &&
|
||||
promotionMap['FULL_DISCOUNT'].fullRateFlag
|
||||
"
|
||||
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立享{{
|
||||
promotionMap["FULL_DISCOUNT"].fullRate
|
||||
}}折</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-price-right">
|
||||
<div class="item-remarks-sum">
|
||||
<p>累计评价</p>
|
||||
<p>
|
||||
<span class="item-remarks-num"
|
||||
>{{ skuDetail.commentNum || 0 }} 条</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,6 +357,14 @@ const currentImg = computed(() => {
|
||||
return item.type === "video" ? item.poster : item.url || item;
|
||||
});
|
||||
|
||||
/** 是否包含有效促销/优惠(优惠券、满减/满折/满赠、拼团) */
|
||||
const hasPromotions = computed(() => {
|
||||
const hasCoupon = Array.isArray(promotionMap.value?.COUPON) && promotionMap.value.COUPON.length > 0;
|
||||
const hasFullDiscount = !!promotionMap.value?.FULL_DISCOUNT;
|
||||
const hasPintuan = !!promotionMap.value?.PINTUAN;
|
||||
return hasCoupon || hasFullDiscount || hasPintuan;
|
||||
});
|
||||
|
||||
|
||||
/** 卡密商品:禁加购,仅立即购买;库存读 quantity(M-02 起支持促销) */
|
||||
const isECouponGoods = computed(() => {
|
||||
@@ -533,6 +534,53 @@ function pointPay() {
|
||||
startCheckout(E_COUPON_CART_POINTS, "loading");
|
||||
}
|
||||
|
||||
function getFullDiscountTag(fd) {
|
||||
if (!fd) return "满优惠";
|
||||
if (fd.activityType === "FULL_RATE") return "满折";
|
||||
if (fd.activityType === "FULL_GIFT") return "满赠";
|
||||
if (fd.activityType === "FULL_MINUS") return "满减";
|
||||
if (fd.fullRateFlag) return "满折";
|
||||
if (fd.giftFlag) return "满赠";
|
||||
return "满减";
|
||||
}
|
||||
|
||||
function formatFullDiscountDesc(fd) {
|
||||
if (!fd) return "";
|
||||
let rules = fd.ruleList;
|
||||
if (!rules && fd.rulesJson) {
|
||||
try {
|
||||
rules = JSON.parse(fd.rulesJson);
|
||||
} catch (e) {
|
||||
rules = null;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rules) && rules.length > 0) {
|
||||
return rules
|
||||
.map((rule) => {
|
||||
if (fd.activityType === "FULL_RATE") {
|
||||
return `满${rule.fullMoney}元打${rule.fullRate}折`;
|
||||
}
|
||||
if (fd.activityType === "FULL_GIFT") {
|
||||
const gifts = Array.isArray(rule.giftItems)
|
||||
? rule.giftItems
|
||||
.map((g) => `${g.goodsName || "赠品"}x${g.num || 1}`)
|
||||
.join("、")
|
||||
: "赠品";
|
||||
return `满${rule.fullMoney}元送${gifts}`;
|
||||
}
|
||||
return `满${rule.fullMoney}元减${rule.fullMinus}元`;
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
if (fd.fullMinusFlag && fd.fullMinus) {
|
||||
return `满${fd.fullMoney}元,立减现金${fd.fullMinus}元`;
|
||||
}
|
||||
if (fd.fullRateFlag && fd.fullRate) {
|
||||
return `满${fd.fullMoney}元,立享${fd.fullRate}折`;
|
||||
}
|
||||
return fd.title || "";
|
||||
}
|
||||
|
||||
function startCheckout(action, loadingKey) {
|
||||
const params = {
|
||||
num: count.value,
|
||||
@@ -906,6 +954,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -948,15 +997,70 @@ onBeforeUnmount(() => {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.item-price-coupon-row {
|
||||
/* 促销与优惠独立展示模块 */
|
||||
.goods-promotions-box {
|
||||
margin-top: 12px;
|
||||
padding: 12px 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.promo-item-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin: 8px 0px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.item-price-title {
|
||||
.promo-title {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
margin-right: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.promo-content-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.promo-activity-entry {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.promo-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: $theme_color;
|
||||
background: rgba(235, 69, 73, 0.08);
|
||||
border: 1px solid $theme_color;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promo-desc {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 22px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.promo-price-text {
|
||||
color: $theme_color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.coupon-tags {
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
import storage from '@/plugins/storage';
|
||||
import { logout } from '@/api/account';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
@@ -69,12 +71,22 @@ const username = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
function clearLocalAuth() {
|
||||
storage.removeItem('userInfo');
|
||||
storage.removeItem('accessToken');
|
||||
storage.removeItem('refreshToken');
|
||||
storage.removeItem('cartNum');
|
||||
Cookies.remove('userInfo');
|
||||
Cookies.remove('accessToken');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout().finally(() => {
|
||||
clearLocalAuth();
|
||||
});
|
||||
}
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
const opt = props.options || {};
|
||||
|
||||
@@ -2,11 +2,14 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { getBaseSite } from "@/api/common.js";
|
||||
import config from "@/config";
|
||||
|
||||
// 同页的页头、页脚共享请求;每次重新进入页面时刷新,避免旧缓存覆盖后台设置。
|
||||
// 全局共享站点配置;应用生命周期内仅拉取一次,同页与切页均复用请求与结果。
|
||||
const settings = ref(null);
|
||||
let pendingRequest;
|
||||
let pendingRequest = null;
|
||||
|
||||
export function loadSiteSettings() {
|
||||
if (settings.value) {
|
||||
return Promise.resolve(settings.value);
|
||||
}
|
||||
if (!pendingRequest) {
|
||||
pendingRequest = getBaseSite()
|
||||
.then((res) => {
|
||||
@@ -16,7 +19,9 @@ export function loadSiteSettings() {
|
||||
return settings.value;
|
||||
})
|
||||
.catch(() => settings.value)
|
||||
.finally(() => { pendingRequest = null; });
|
||||
.finally(() => {
|
||||
pendingRequest = null;
|
||||
});
|
||||
}
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setupRequestErrorHandling } from "@/utils/requestError";
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import "core-js/stable";
|
||||
@@ -24,6 +25,7 @@ if (aMapSecurityJsCode) {
|
||||
}
|
||||
|
||||
const app = createApp(App);
|
||||
setupRequestErrorHandling(app);
|
||||
|
||||
setupElementPlus(app);
|
||||
setupLegacyMessage(app);
|
||||
|
||||
@@ -200,7 +200,7 @@ function handleSubmit () { // 提交密码
|
||||
Message.success('修改密码成功');
|
||||
router.push('login');
|
||||
}
|
||||
}).catch(() => { loading.value = false; });
|
||||
}).catch(() => { loading1.value = false; });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "@/utils/message";
|
||||
@@ -230,6 +231,8 @@ function handleSubmit() {
|
||||
params.content = "";
|
||||
getDetail();
|
||||
}
|
||||
}).catch(catchHandledRequestError).finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
</div>
|
||||
</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-cols">
|
||||
<span>订单详情</span>
|
||||
@@ -77,7 +79,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
||||
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<div class="page-size">
|
||||
@@ -134,6 +136,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
@@ -157,6 +160,8 @@ const params = reactive({ // 请求参数
|
||||
order: 'desc'
|
||||
});
|
||||
const total = ref(0); // 订单总数
|
||||
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||
const spinShow = ref(false); // 加载状态
|
||||
const companyList = ref([]); // 物流公司列表
|
||||
const logisticsShow = ref(false); // 物流信息modal
|
||||
@@ -230,14 +235,27 @@ function change(index) {
|
||||
getList()
|
||||
}
|
||||
function getList() { // 获取售后列表
|
||||
const version = ++listRequestVersion;
|
||||
queryFailed.value = false;
|
||||
spinShow.value = true;
|
||||
let paramsData = JSON.parse(JSON.stringify(params))
|
||||
afterSaleList(paramsData).then(res => {
|
||||
spinShow.value = false
|
||||
if (version !== listRequestVersion) return;
|
||||
if (res.success) {
|
||||
orderList.value = res.result.records;
|
||||
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) { // 修改页码
|
||||
|
||||
@@ -90,6 +90,11 @@ const reasonList = ref([]); // 投诉原因
|
||||
|
||||
function getOrderDetail() { // 获取订单详情
|
||||
orderDetail(route.query.sn).then(res => {
|
||||
if (res.result?.order?.orderStatus === 'CANCELLED') {
|
||||
Message.warning('订单已取消,无法发起投诉');
|
||||
router.push({ name: 'MyOrder' });
|
||||
return;
|
||||
}
|
||||
order.value = res.result
|
||||
orderGoods.value = res.result.orderItems[route.query.index]
|
||||
orderGoods.value.uploadList = []
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
</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-cols">
|
||||
<span>订单详情</span>
|
||||
@@ -65,7 +67,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="goods-info">
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
|
||||
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ goods.name }}
|
||||
</div>
|
||||
<div class="goods-num">共{{ goods.num }}件</div>
|
||||
<div class="goods-extra">
|
||||
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
|
||||
@@ -91,7 +96,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
@@ -103,7 +108,9 @@
|
||||
</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-cols">
|
||||
<span>订单详情</span>
|
||||
@@ -147,7 +154,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="goods-info">
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
|
||||
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
|
||||
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ goods.name }}
|
||||
</div>
|
||||
<div class="goods-num">共{{ goods.num }}件</div>
|
||||
<div class="goods-extra">
|
||||
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
|
||||
@@ -173,7 +183,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
|
||||
|
||||
</div>
|
||||
<el-dialog v-model="cancelAvail" title="请选择取消订单原因">
|
||||
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
|
||||
@@ -193,6 +203,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
@@ -227,12 +238,18 @@ const cancelParams = reactive({ // 取消售后参数
|
||||
const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
|
||||
const activeOrderTab = ref(0);
|
||||
const total = ref(0); // 数据总数
|
||||
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||
const spinShow = ref(false); // 加载状态
|
||||
const cancelIsPickup = ref(false);
|
||||
const cancelAvail = ref(false); // 取消订单modal控制
|
||||
const cancelReason = ref([]); // 取消订单原因
|
||||
|
||||
// 退款状态枚举
|
||||
function isGiftItem(goods) {
|
||||
if (!goods) return false;
|
||||
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
|
||||
}
|
||||
function refundPriceList(status) {
|
||||
switch (status) {
|
||||
case 'ALL_REFUND':
|
||||
@@ -322,17 +339,30 @@ function delOrder (sn) { // 删除订单
|
||||
});
|
||||
}
|
||||
function getList () {
|
||||
const version = ++listRequestVersion;
|
||||
queryFailed.value = false;
|
||||
spinShow.value = true;
|
||||
let _params = JSON.parse(JSON.stringify(params))
|
||||
if (_params.orderStatus === 'ALL') {
|
||||
delete _params.orderStatus
|
||||
}
|
||||
getOrderList(_params).then(res => {
|
||||
spinShow.value = false
|
||||
if (version !== listRequestVersion) return;
|
||||
if (res.success) {
|
||||
orderList.value = res.result.records;
|
||||
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) {
|
||||
|
||||
@@ -269,17 +269,27 @@
|
||||
@click="goodsDetail(goods.skuId, goods.goodsId)"
|
||||
class="hover-color goods-name"
|
||||
>
|
||||
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ goods.goodsName }}
|
||||
</p>
|
||||
<p class="goods-sn">货号:{{ goods.id }}</p>
|
||||
</div>
|
||||
<div class="goods-price">
|
||||
<template v-if="isGiftItem(goods)">
|
||||
<span class="unit-price gift-original-price">
|
||||
{{ $filters.unitPrice(goods.goodsPrice, "¥") }}
|
||||
</span>
|
||||
<span class="subtotal gift-subtotal">赠品 (¥0.00 × {{ goods.num }})</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="unit-price">
|
||||
{{ $filters.unitPrice(goods.goodsPrice, "¥") }} × {{ goods.num }}
|
||||
</span>
|
||||
<span class="subtotal">{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="goods-actions">
|
||||
<template v-if="!isGiftItem(goods)">
|
||||
<button
|
||||
v-if="goods.commentStatus == 'UNFINISHED'"
|
||||
type="button"
|
||||
@@ -287,7 +297,11 @@
|
||||
@click="comment(order.order.sn, goodsIndex)"
|
||||
>评价</button>
|
||||
<el-button
|
||||
v-if="goods.complainStatus == 'NO_APPLY'"
|
||||
v-if="
|
||||
order.order?.orderStatus !== 'CANCELLED' &&
|
||||
order.order?.payStatus === 'PAID' &&
|
||||
goods.complainStatus == 'NO_APPLY'
|
||||
"
|
||||
@click="complain(order.order.sn, goodsIndex)"
|
||||
type="danger"
|
||||
size="small"
|
||||
@@ -296,6 +310,7 @@
|
||||
<el-button
|
||||
v-if="
|
||||
!isECouponOrder &&
|
||||
order.order?.orderStatus !== 'CANCELLED' &&
|
||||
(goods.afterSaleStatus.includes('NOT_APPLIED') ||
|
||||
goods.afterSaleStatus.includes('PART_AFTER_SALE'))
|
||||
"
|
||||
@@ -303,6 +318,10 @@
|
||||
size="small"
|
||||
>申请售后</el-button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="gift-action-tip">随单赠送,不支持售后</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -726,6 +745,11 @@ function goPay(sn) {
|
||||
});
|
||||
}
|
||||
|
||||
function isGiftItem(goods) {
|
||||
if (!goods) return false;
|
||||
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
|
||||
}
|
||||
|
||||
function applyAfterSale(sn) {
|
||||
// 申请售后
|
||||
router.push({ name: "ApplyAfterSale", query: { sn: sn } });
|
||||
@@ -1178,4 +1202,16 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
.gift-original-price {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
.gift-subtotal {
|
||||
color: #e4393c;
|
||||
font-weight: bold;
|
||||
}
|
||||
.gift-action-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button>
|
||||
</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>
|
||||
<li
|
||||
v-for="(item, index) in list"
|
||||
@@ -39,6 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { catchHandledRequestError } from "@/utils/requestError";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
@@ -50,6 +53,8 @@ defineOptions({ name: "MyTrack" });
|
||||
const router = useRouter();
|
||||
|
||||
const list = ref([]); // 我的足迹,商品列表
|
||||
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
|
||||
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
|
||||
const spinShow = ref(false); // 控制loading是否加载
|
||||
const params = reactive({
|
||||
pageNumber: 1,
|
||||
@@ -118,9 +123,11 @@ function changePageSize(val) {
|
||||
|
||||
function getList() {
|
||||
// 获取足迹列表
|
||||
const version = ++listRequestVersion;
|
||||
queryFailed.value = false;
|
||||
spinShow.value = true;
|
||||
tracksList(params).then((res) => {
|
||||
spinShow.value = false;
|
||||
if (version !== listRequestVersion) return;
|
||||
if (res.success && res.result.records.length) {
|
||||
list.value = res.result.records.filter(item =>{
|
||||
return item != null
|
||||
@@ -129,6 +136,17 @@ function getList() {
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,24 @@
|
||||
<span>{{ goods.goodsSku?.quantity > 0 ? "有货" : "无货" }}</span>
|
||||
<span class="goods-price">{{ $filters.unitPrice(goods.subTotal, "¥") }}</span>
|
||||
</div>
|
||||
<!-- 随单实物赠品行 -->
|
||||
<div
|
||||
class="goods-item gift-item"
|
||||
v-for="(gift, giftIndex) in getShopGifts(shop)"
|
||||
:key="'gift-' + (gift.skuId || giftIndex)"
|
||||
>
|
||||
<span class="goods-main hover-color" @click="goGoodsDetail(gift.skuId, gift.goodsId)">
|
||||
<img :src="gift.thumbnail || gift.image" alt="" />
|
||||
<span class="goods-name">
|
||||
<el-tag size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
{{ gift.goodsName || gift.name || '随单实物赠品' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="goods-price gift-zero-price">¥0.00</span>
|
||||
<span>{{ gift.num || 1 }}</span>
|
||||
<span>有货</span>
|
||||
<span class="goods-price gift-zero-price">¥0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-mark">
|
||||
<div v-if="isRemarkOpen(shop)" class="mark-field">
|
||||
@@ -876,6 +894,13 @@ function delAddress(item) {
|
||||
onCancel: () => { },
|
||||
});
|
||||
}
|
||||
function getShopGifts(shop) {
|
||||
if (!shop) return [];
|
||||
if (Array.isArray(shop.giftItemList) && shop.giftItemList.length > 0) {
|
||||
return shop.giftItemList;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function goGoodsDetail(skuId, goodsId) {
|
||||
// 跳转商品详情
|
||||
let routeUrl = router.resolve({
|
||||
@@ -1800,4 +1825,8 @@ function useScope(type, storeName) {
|
||||
.pay-gcc-notice-foot {
|
||||
text-align: right;
|
||||
}
|
||||
.gift-zero-price {
|
||||
color: #e4393c !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -163,6 +163,7 @@ function getGoodsDetail() {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
isLoading.value = false;
|
||||
router.push("/");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,70 +1,134 @@
|
||||
<template>
|
||||
<div class="seckill-list">
|
||||
<BaseHeader></BaseHeader>
|
||||
<Search />
|
||||
<!-- 秒杀时间段 -->
|
||||
<div class="promotion-decorate">限时秒杀</div>
|
||||
<ul class="time-line">
|
||||
<template v-for="(time, index) in list">
|
||||
<li v-if="index < 5" @click="currIndex = index" :key="index" :class="{ currTimeline: currIndex === index }">
|
||||
<div>{{ time.timeLine + ":00" }}</div>
|
||||
<div v-if="currIndex === index">
|
||||
<p>{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</p>
|
||||
<p>
|
||||
{{ nowHour >= time.timeLine ? "距结束" : "距开始" }} {{
|
||||
currTime
|
||||
}}
|
||||
</p>
|
||||
<SearchHeader></SearchHeader>
|
||||
|
||||
<!-- 秒杀顶部氛围横幅 -->
|
||||
<div class="seckill-hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-left">
|
||||
<div class="hero-tag">
|
||||
<span class="lightning">⚡</span>
|
||||
<span>限时特惠 · 爆款直降</span>
|
||||
</div>
|
||||
<div v-else class="not-curr">
|
||||
{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}
|
||||
<h1 class="hero-title">限时秒杀</h1>
|
||||
<p class="hero-subtitle">正品严选 · 限量低价 · 抢完即止</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-countdown" v-if="list.length && list[currIndex]">
|
||||
<div class="countdown-badge">
|
||||
<span class="status-dot" :class="{ 'is-live': isCurrentSessionActive }"></span>
|
||||
<span>{{ isCurrentSessionActive ? "正在抢购中" : "即将准点开抢" }}</span>
|
||||
</div>
|
||||
<div class="countdown-label">
|
||||
{{ isCurrentSessionActive ? "距本场结束仅剩" : "距本场开始仅剩" }}
|
||||
</div>
|
||||
<div class="countdown-boxes">
|
||||
<span class="time-block">{{ countdownHours }}</span>
|
||||
<span class="time-sep">:</span>
|
||||
<span class="time-block">{{ countdownMinutes }}</span>
|
||||
<span class="time-sep">:</span>
|
||||
<span class="time-block">{{ countdownSeconds }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 秒杀时间段导航 -->
|
||||
<div class="time-line-wrap" v-if="list.length">
|
||||
<ul class="time-line" :class="{ 'single-session': list.length === 1 }">
|
||||
<template v-for="(time, index) in list">
|
||||
<li
|
||||
v-if="index < 5"
|
||||
:key="index"
|
||||
@click="currIndex = index"
|
||||
:class="{ currTimeline: currIndex === index }"
|
||||
>
|
||||
<div class="timeline-hour">{{ time.timeLine + ":00" }}</div>
|
||||
<div class="timeline-status">
|
||||
<span class="status-pill">{{ nowHour >= time.timeLine ? "秒杀中" : "即将开始" }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 秒杀商品列表 -->
|
||||
<div class="seckill-goods-wrap">
|
||||
<div class="goods-list">
|
||||
<empty v-if="goodsList.length === 0" />
|
||||
<div v-else class="goods-show-info1" v-for="(item, index) in goodsList" :key="index"
|
||||
@click="goGoodsDetail(item.skuId, item.goodsId)">
|
||||
<div class="goods-show-img">
|
||||
<div v-if="goodsList.length === 0" class="empty-wrap">
|
||||
<empty :_Title="list.length ? '当前场次暂无秒杀商品,去其他场次看看吧' : '今日暂无秒杀活动,去首页逛逛吧'" />
|
||||
</div>
|
||||
|
||||
<div v-else class="goods-grid">
|
||||
<div
|
||||
class="goods-card"
|
||||
v-for="(item, index) in goodsList"
|
||||
:key="index"
|
||||
@click="goGoodsDetail(item.skuId, item.goodsId)"
|
||||
>
|
||||
<div class="goods-img-box">
|
||||
<img :src="item.goodsImage" loading="lazy" decoding="async" :alt="item.goodsName" />
|
||||
<div v-if="isSoldOut(item)" class="sold-out-overlay">
|
||||
<span>已抢光</span>
|
||||
</div>
|
||||
<div class="goods-show-price">
|
||||
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
|
||||
<span class="origin-price">{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
|
||||
</div>
|
||||
<div class="goods-show-detail">
|
||||
<span>{{ item.goodsName }}</span>
|
||||
|
||||
<div class="goods-price-row">
|
||||
<div class="price-main">
|
||||
<span class="price-symbol">¥</span>
|
||||
<span class="price-val">{{ $filters.unitPrice(item.price, "") }}</span>
|
||||
</div>
|
||||
<div v-if="nowHour >= list[currIndex].timeLine" class="goods-show-num1">
|
||||
<span>已售{{ salePercent(item) }}%</span>
|
||||
<div class="price-origin">
|
||||
{{ $filters.unitPrice(item.originalPrice, "¥") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="goods-name-row" :title="item.goodsName">
|
||||
{{ item.goodsName }}
|
||||
</div>
|
||||
|
||||
<div class="goods-progress-row">
|
||||
<template v-if="nowHour >= list[currIndex].timeLine">
|
||||
<span class="progress-label">已抢 {{ salePercent(item) }}%</span>
|
||||
<el-progress
|
||||
:show-text="false"
|
||||
:stroke-width="6"
|
||||
:color="'var(--theme-color, #ff3c2a)'"
|
||||
:percentage="salePercent(item)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="upcoming-tip">限量抢购 · 准点开抢</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="goods-show-seller">
|
||||
<span>{{ item.storeName }}</span>
|
||||
|
||||
<div class="goods-store-row" :title="item.storeName">
|
||||
<span class="store-name">{{ item.storeName }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="goods-seckill-btn"
|
||||
:class="{ 'is-disabled': !canBuy(item) }"
|
||||
class="goods-action-btn"
|
||||
:class="{
|
||||
'btn-disabled': !canBuy(item),
|
||||
'btn-upcoming': nowHour < list[currIndex].timeLine,
|
||||
'btn-active': canBuy(item)
|
||||
}"
|
||||
>
|
||||
{{ buyText(item) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { seckillByDay } from "@/api/promotion";
|
||||
import SearchHeader from "@/components/indexDecorate/components/SearchHeader.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -72,10 +136,19 @@ const list = ref([]); // 秒杀时段列表
|
||||
const goodsList = ref([]); // 商品列表
|
||||
let interval = null; // 定时器
|
||||
const currIndex = ref(0); // 当前时间段的下标
|
||||
const currTime = ref(0); // 当前显示的倒计时
|
||||
const diffSeconds = ref(0); // 倒计时时间戳
|
||||
const currTime = ref("00:00:00"); // 当前显示的倒计时格式字符串
|
||||
const countdownHours = ref("00");
|
||||
const countdownMinutes = ref("00");
|
||||
const countdownSeconds = ref("00");
|
||||
const diffSeconds = ref(0); // 倒计时时间戳差值
|
||||
const nowHour = ref(new Date().getHours()); // 当前小时数
|
||||
|
||||
// 当前选中场次是否处于开抢中
|
||||
const isCurrentSessionActive = computed(() => {
|
||||
if (!list.value.length || !list.value[currIndex.value]) return false;
|
||||
return nowHour.value >= list.value[currIndex.value].timeLine;
|
||||
});
|
||||
|
||||
watch(currIndex, (val) => {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
@@ -85,26 +158,22 @@ watch(currIndex, (val) => {
|
||||
});
|
||||
|
||||
watch(diffSeconds, (val) => {
|
||||
const hours = Math.floor(val / 3600);
|
||||
// 当前秒数 / 60,向下取整
|
||||
// 获取到所有分钟数 3600 / 60 = 60分钟
|
||||
// 对60取模,超过小时数的分钟数
|
||||
const minutes = Math.floor(val / 60) % 60;
|
||||
// 当前的秒数 % 60,获取到 超过小时数、分钟数的秒数(秒数)
|
||||
const seconds = val % 60;
|
||||
currTime.value =
|
||||
filteTime(hours) + ":" + filteTime(minutes) + ":" + filteTime(seconds);
|
||||
if (val <= 0) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
countdownHours.value = "00";
|
||||
countdownMinutes.value = "00";
|
||||
countdownSeconds.value = "00";
|
||||
currTime.value = "00:00:00";
|
||||
return;
|
||||
}
|
||||
function filteTime (time) {
|
||||
if (time < 10) {
|
||||
return "0" + time;
|
||||
} else {
|
||||
return time;
|
||||
}
|
||||
}
|
||||
const hours = Math.floor(val / 3600);
|
||||
const minutes = Math.floor(val / 60) % 60;
|
||||
const seconds = val % 60;
|
||||
countdownHours.value = hours < 10 ? "0" + hours : String(hours);
|
||||
countdownMinutes.value = minutes < 10 ? "0" + minutes : String(minutes);
|
||||
countdownSeconds.value = seconds < 10 ? "0" + seconds : String(seconds);
|
||||
currTime.value = `${countdownHours.value}:${countdownMinutes.value}:${countdownSeconds.value}`;
|
||||
});
|
||||
|
||||
function getListByDay () {
|
||||
@@ -120,7 +189,7 @@ function getListByDay () {
|
||||
|
||||
function goGoodsDetail (skuId, goodsId) {
|
||||
// 跳转商品详情
|
||||
let routeUrl = router.resolve({
|
||||
const routeUrl = router.resolve({
|
||||
path: "/goodsDetail",
|
||||
query: { skuId, goodsId },
|
||||
});
|
||||
@@ -135,41 +204,46 @@ function salePercent (item) {
|
||||
|
||||
// 场次已开始,且销量达到库存
|
||||
function isSoldOut (item) {
|
||||
if (!list.value[currIndex.value]) return false;
|
||||
return nowHour.value >= list.value[currIndex.value].timeLine && item.quantity <= item.salesNum;
|
||||
}
|
||||
|
||||
// 场次已开始且未售罄才可抢
|
||||
function canBuy (item) {
|
||||
if (!list.value[currIndex.value]) return false;
|
||||
return nowHour.value >= list.value[currIndex.value].timeLine && !isSoldOut(item);
|
||||
}
|
||||
|
||||
// 按钮文案:未开始 / 已售罄 / 立即抢购
|
||||
function buyText (item) {
|
||||
if (!list.value[currIndex.value]) return "即将开始";
|
||||
if (nowHour.value < list.value[currIndex.value].timeLine) return "即将开始";
|
||||
if (isSoldOut(item)) return "已售罄";
|
||||
return "立即抢购";
|
||||
}
|
||||
|
||||
function countDown (currIndex) {
|
||||
// 倒计时
|
||||
// 0点时间戳
|
||||
let zeroTime = new Date(new Date().toLocaleDateString()).getTime();
|
||||
let currTime = new Date().getTime();
|
||||
if (!list.value[currIndex]) return;
|
||||
// 倒计时:0点时间戳
|
||||
const zeroTime = new Date(new Date().toLocaleDateString()).getTime();
|
||||
const currTimeMs = new Date().getTime();
|
||||
let actTime = 0;
|
||||
let nowHour = new Date().getHours(); // 当前小时数
|
||||
if (list.value[currIndex].timeLine > nowHour) {
|
||||
// 活动未开始
|
||||
const currentHour = new Date().getHours();
|
||||
nowHour.value = currentHour;
|
||||
|
||||
if (list.value[currIndex].timeLine > currentHour) {
|
||||
// 活动未开始:距开始时间
|
||||
actTime = zeroTime + list.value[currIndex].timeLine * 3600 * 1000;
|
||||
} else if (list.value[currIndex].timeLine <= nowHour) {
|
||||
// 活动进行中
|
||||
} else {
|
||||
// 活动进行中:距结束时间
|
||||
if (currIndex === list.value.length - 1) {
|
||||
// 如果是最后一个活动,直到24点结束
|
||||
actTime = zeroTime + 24 * 3600 * 1000;
|
||||
} else {
|
||||
actTime = zeroTime + list.value[currIndex + 1].timeLine * 3600 * 1000;
|
||||
}
|
||||
}
|
||||
diffSeconds.value = Math.floor((actTime - currTime) / 1000);
|
||||
|
||||
diffSeconds.value = Math.max(0, Math.floor((actTime - currTimeMs) / 1000));
|
||||
interval = setInterval(() => {
|
||||
diffSeconds.value--;
|
||||
}, 1000);
|
||||
@@ -183,216 +257,434 @@ onBeforeUnmount(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../assets/styles/goodsList.scss";
|
||||
|
||||
.seckill-list {
|
||||
background: #f5f5f5;
|
||||
background: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.promotion-decorate {
|
||||
width: auto;
|
||||
margin: 28px auto 16px;
|
||||
color: #171A26;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
/* 顶部氛围 Hero 横幅 */
|
||||
.seckill-hero {
|
||||
background: linear-gradient(135deg, #e52d27 0%, #b31217 60%, #790c10 100%);
|
||||
color: #fff;
|
||||
padding: 30px 0 38px;
|
||||
box-shadow: inset 0 -4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
.hero-inner {
|
||||
width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.hero-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
width: fit-content;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
.lightning {
|
||||
color: #ffeb3b;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-countdown {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
|
||||
|
||||
.countdown-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffc107;
|
||||
|
||||
&.is-live {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.countdown-boxes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.time-block {
|
||||
display: inline-block;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
background: #1e1e1e;
|
||||
color: #ffeb3b;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
border-radius: 6px;
|
||||
padding: 0 4px;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.time-sep {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间轴导航 */
|
||||
.time-line-wrap {
|
||||
width: 1200px;
|
||||
margin: -18px auto 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.time-line {
|
||||
width: 1200px;
|
||||
max-width: 1200px;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&.single-session {
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-height: 78px;
|
||||
padding: 12px 20px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
height: 68px;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #171A26;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
& + li {
|
||||
box-shadow: inset 1px 0 0 #f2f2f2;
|
||||
box-shadow: inset 1px 0 0 #f0f0f0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
&:hover:not(.currTimeline) {
|
||||
background: #fafafa;
|
||||
color: var(--theme-color, #e52d27);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
.timeline-hour {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.not-curr {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
.timeline-status {
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f5f5f5;
|
||||
color: #888;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.currTimeline {
|
||||
background: $theme_color;
|
||||
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(229, 45, 39, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: $theme_color;
|
||||
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
|
||||
}
|
||||
|
||||
> div:nth-child(1) {
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
.timeline-hour {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
> div:nth-child(2) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.not-curr {
|
||||
.timeline-status {
|
||||
.status-pill {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 秒杀商品容器 */
|
||||
.seckill-goods-wrap {
|
||||
width: 1200px;
|
||||
max-width: 1200px;
|
||||
margin: 16px auto 40px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
margin: 20px auto 50px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
min-height: 380px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.empty-wrap {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.goods-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 商品卡片 */
|
||||
.goods-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebebeb;
|
||||
border-radius: 8px;
|
||||
min-height: 320px;
|
||||
|
||||
.goods-list {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
:deep(.empty) {
|
||||
margin: 48px 0;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-show-info1 {
|
||||
width: calc(25% - 12px);
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
margin: 0 0 16px;
|
||||
position: relative;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: #eee;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
border-color: #ffd0d0;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 24px rgba(229, 45, 39, 0.12);
|
||||
}
|
||||
|
||||
.goods-show-img {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.goods-img-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
background: #f9f9f9;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.sold-out-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-show-price {
|
||||
.goods-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
margin-top: 12px;
|
||||
|
||||
.price-main {
|
||||
color: var(--theme-color, #e52d27);
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.price-symbol {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.origin-price {
|
||||
color: #999;
|
||||
.price-val {
|
||||
font-size: 22px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.price-origin {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.goods-show-detail {
|
||||
height: 40px;
|
||||
margin: 8px 0;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.goods-show-num1 {
|
||||
.goods-name-row {
|
||||
margin-top: 8px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.goods-progress-row {
|
||||
margin-top: 10px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: #666;
|
||||
color: #777;
|
||||
|
||||
span {
|
||||
.progress-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.goods-show-seller {
|
||||
margin-bottom: 10px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.goods-seckill-btn {
|
||||
margin-top: auto;
|
||||
height: 34px;
|
||||
.upcoming-tip {
|
||||
color: #e67e22;
|
||||
font-size: 12px;
|
||||
background: #fff8f0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 34px;
|
||||
font-size: 14px;
|
||||
background-color: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background: #c8c8c8;
|
||||
.goods-store-row {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.store-name {
|
||||
&:hover {
|
||||
color: var(--theme-color, #e52d27);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-action-btn {
|
||||
margin-top: auto;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&.btn-active {
|
||||
background: linear-gradient(135deg, var(--theme-color, #e52d27) 0%, #ff5252 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 10px rgba(229, 45, 39, 0.25);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 6px 14px rgba(229, 45, 39, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-upcoming {
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-disabled {
|
||||
background: #e0e0e0;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { markHandledRequestError, isHandledRequestError } from "@/utils/requestError";
|
||||
import { ElLoading } from "element-plus";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
import Storage from "./storage";
|
||||
@@ -14,7 +15,7 @@ export const buyerUrl =
|
||||
? BASE.API_DEV.buyer
|
||||
: BASE.API_PROD.buyer;
|
||||
|
||||
let isRefreshToken = 0;
|
||||
let loginPrompt = null;
|
||||
const refreshToken = getTokenDebounce();
|
||||
|
||||
/** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */
|
||||
@@ -37,20 +38,22 @@ const service = axios.create({
|
||||
});
|
||||
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
async (config) => {
|
||||
const { loading } = config;
|
||||
const isPutPost = config.method === "put" || config.method === "post";
|
||||
const isJson = config.headers["Content-Type"] === "application/json";
|
||||
const isFile = config.headers["Content-Type"] === "multipart/form-data";
|
||||
if (isPutPost && isJson) {
|
||||
if (!config._dataSerialized && isPutPost && isJson) {
|
||||
config.data = JSON.stringify(config.data);
|
||||
}
|
||||
if (isPutPost && !isFile && !isJson) {
|
||||
if (!config._dataSerialized && isPutPost && !isFile && !isJson) {
|
||||
config.data = qs.stringify(config.data, {
|
||||
arrayFormat: "repeat",
|
||||
});
|
||||
}
|
||||
|
||||
config._dataSerialized = true;
|
||||
|
||||
const method = (config.method || "get").toLowerCase();
|
||||
const isReadRequest = method === "get" || method === "head";
|
||||
const shouldShowLoading =
|
||||
@@ -58,7 +61,7 @@ service.interceptors.request.use(
|
||||
(!isReadRequest && loading !== false && !isMemberCenterRoute());
|
||||
|
||||
if (typeof window !== "undefined" && shouldShowLoading) {
|
||||
config.loading = ElLoading.service({ fullscreen: true });
|
||||
config._loadingInstance = ElLoading.service({ fullscreen: true });
|
||||
}
|
||||
|
||||
let uuid = Storage.getItem("uuid");
|
||||
@@ -82,7 +85,7 @@ service.interceptors.request.use(
|
||||
const expired = jwtData.exp < Math.round(new Date() / 1000);
|
||||
if (expired) {
|
||||
if (config.needToken) {
|
||||
return refresh({ response: { config } });
|
||||
return refresh(config);
|
||||
}
|
||||
// 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录
|
||||
Storage.removeItem("accessToken");
|
||||
@@ -94,7 +97,7 @@ service.interceptors.request.use(
|
||||
Storage.removeItem("accessToken");
|
||||
Storage.removeItem("refreshToken");
|
||||
if (config.needToken) {
|
||||
return refresh({ response: { config } });
|
||||
return refresh(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,59 +107,64 @@ service.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
/** 保留请求配置、状态与错误码,供页面判断业务失败并定位原因。 */
|
||||
function buildRejectError(error, errorData = {}) {
|
||||
const message =
|
||||
errorData.message ||
|
||||
(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") {
|
||||
if (error?.response?.config) {
|
||||
error.response.config.headers.accessToken = Storage.getItem("accessToken");
|
||||
return service(error.response.config);
|
||||
/**
|
||||
* 刷新凭证并返回请求配置;请求拦截器不能返回业务响应。每个业务请求最多刷新一次。
|
||||
* 刷新失败时保持 Promise 拒绝,阻止后续支付、订单等成功回调继续执行。
|
||||
*/
|
||||
async function refresh(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("refreshToken");
|
||||
Storage.removeItem("userInfo");
|
||||
Storage.setItem("cartNum", 0);
|
||||
useAppStore().setCartNum(0);
|
||||
// 多个受保护接口同时失败时共用一个弹窗,关闭后允许再次提示。
|
||||
if (!loginPrompt) {
|
||||
const currentRoute = router.currentRoute.value;
|
||||
Modal.confirm({
|
||||
loginPrompt = Modal.confirm({
|
||||
title: "请登录",
|
||||
content: "请登录后执行此操作",
|
||||
okText: "立即登录",
|
||||
cancelText: "继续浏览",
|
||||
onOk: () => {
|
||||
router.push({
|
||||
onOk: () => router.push({
|
||||
path: "/login",
|
||||
query: {
|
||||
rePath: currentRoute.path,
|
||||
query: JSON.stringify(currentRoute.query),
|
||||
},
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
router.push("/");
|
||||
Modal.remove();
|
||||
},
|
||||
});
|
||||
return Promise.reject(new Error("登录已过期"));
|
||||
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 });
|
||||
throw markHandledRequestError(error);
|
||||
}
|
||||
|
||||
service.interceptors.response.use(
|
||||
async (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;
|
||||
},
|
||||
async (error) => {
|
||||
if (typeof window === "undefined") return Promise.reject(error);
|
||||
await closeLoading(error);
|
||||
if (isHandledRequestError(error)) return Promise.reject(error);
|
||||
const errorResponse = error.response || {};
|
||||
const errorData = errorResponse.data || {};
|
||||
|
||||
@@ -166,32 +174,26 @@ service.interceptors.response.use(
|
||||
errorResponse.status === 403 ||
|
||||
error.response?.data?.code === 20004)
|
||||
) {
|
||||
isRefreshToken++;
|
||||
if (isRefreshToken === 1) {
|
||||
isRefreshToken = 0;
|
||||
return refresh(error);
|
||||
return service(await refresh(error.config || errorResponse.config));
|
||||
}
|
||||
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(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));
|
||||
return Promise.reject(rejected);
|
||||
}
|
||||
);
|
||||
|
||||
const closeLoading = (target) => {
|
||||
if (!target.config || !target.config.loading) return true;
|
||||
if (typeof target.config?._loadingInstance?.close !== "function") return true;
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
target.config.loading.close();
|
||||
target.config._loadingInstance.close();
|
||||
delete target.config._loadingInstance;
|
||||
resolve();
|
||||
}, 200);
|
||||
});
|
||||
@@ -208,42 +210,22 @@ export default function request(options) {
|
||||
return service(options);
|
||||
}
|
||||
|
||||
/** 并发刷新共用同一个 Promise;不轮询,不保留上次刷新结果,失败后允许下次重试。 */
|
||||
function getTokenDebounce() {
|
||||
let lock = false;
|
||||
let success = false;
|
||||
let pending = null;
|
||||
return function () {
|
||||
if (!lock) {
|
||||
const oldRefreshToken = Storage.getItem("refreshToken");
|
||||
// 无 refreshToken(未登录)时不发起刷新请求,直接判失败,避免请求 /refresh/undefined 造成拦截器死锁
|
||||
if (!oldRefreshToken) {
|
||||
return Promise.resolve("fail");
|
||||
}
|
||||
lock = true;
|
||||
handleRefreshToken(oldRefreshToken)
|
||||
if (pending) return pending;
|
||||
const token = Storage.getItem("refreshToken");
|
||||
if (!token) return Promise.resolve("fail");
|
||||
pending = handleRefreshToken(token)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
const { accessToken, refreshToken: newRefreshToken } = res.result;
|
||||
Storage.setItem("accessToken", accessToken);
|
||||
Storage.setItem("refreshToken", newRefreshToken);
|
||||
success = true;
|
||||
lock = false;
|
||||
} else {
|
||||
success = false;
|
||||
lock = false;
|
||||
}
|
||||
if (!res.success || !res.result?.accessToken || !res.result?.refreshToken) return "fail";
|
||||
Storage.setItem("accessToken", res.result.accessToken);
|
||||
Storage.setItem("refreshToken", res.result.refreshToken);
|
||||
return "success";
|
||||
})
|
||||
.catch(() => {
|
||||
success = false;
|
||||
lock = false;
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (!lock) {
|
||||
clearInterval(timer);
|
||||
resolve(success ? "success" : "fail");
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
.catch(() => "fail")
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -69,12 +69,19 @@ export function normalizeTheme(data = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
let pendingThemeRequest = null;
|
||||
|
||||
export async function fetchAndApplyTheme(requestFn) {
|
||||
const cachedTheme = loadCachedTheme();
|
||||
if (cachedTheme) {
|
||||
applyThemeToDocument(normalizeTheme(cachedTheme));
|
||||
}
|
||||
|
||||
if (pendingThemeRequest) {
|
||||
return pendingThemeRequest;
|
||||
}
|
||||
|
||||
pendingThemeRequest = (async () => {
|
||||
try {
|
||||
const res = await requestFn();
|
||||
if (res?.success && res?.result?.settingValue) {
|
||||
@@ -89,7 +96,12 @@ export async function fetchAndApplyTheme(requestFn) {
|
||||
"[theme] 主题色加载失败,请确认 buyer-api 已启动且站点主题配置可用",
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
pendingThemeRequest = null;
|
||||
}
|
||||
|
||||
return cachedTheme ? normalizeTheme(cachedTheme) : normalizeTheme();
|
||||
})();
|
||||
|
||||
return pendingThemeRequest;
|
||||
}
|
||||
|
||||
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: {
|
||||
port: configs.port,
|
||||
client: {
|
||||
overlay: {
|
||||
// 保留编译错误和程序异常遮罩,仅跳过请求层已向用户提示的错误。
|
||||
runtimeErrors: (error) => error?.[Symbol.for("lilishop.buyer.handledRequestError")] !== true,
|
||||
},
|
||||
},
|
||||
},
|
||||
productionSourceMap: false,
|
||||
configureWebpack: {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# IM 客服端 Vue 3 迁移说明
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3.5 + Vue Router 4 + Pinia 4
|
||||
- Element Plus(替代 element-ui)
|
||||
- 自研右键菜单 `src/plugins/contextmenu.js`(替代 vue-contextmenujs)
|
||||
- `vue-virtual-scroller@3`、`vue-cropper@1.x`、`vue-prism-editor@2.0.0-alpha.2`
|
||||
|
||||
## 组合式 API 迁移(2026-10 完成)
|
||||
|
||||
全部 53 个 `.vue` 组件已从 Options API 转换为 `<script setup>`,并完成 vuex → pinia:
|
||||
|
||||
- **状态管理**:`src/stores/` 下 8 个 store(app/user/talk/notify/settings/emoticon/dialogue/note),对应原 vuex 根 state + 7 个模块;mutation 全部转为同名 SCREAMING_CASE action。原 `src/store/` 已删除。
|
||||
- **全局 mixin 保留**:`main.js` 的 `app.mixin(MainMixin)` 依然生效(对 `<script setup>` 组件同样注入),mixin 内部已改用 pinia。**注意**:mixin 经 `this` 回调组件成员时,`<script setup>` 的绑定不会出现在公共实例上——`views/message/index.vue` 采用了 ctx 桥接(`Object.defineProperties(instance.ctx, ...)`)方案,后续若有组件被全局 mixin 回调需用同样方案。
|
||||
- **socket 事件层**(`im-server/event/*`、`plugins/ws-socket.js`、`utils/talk.js` 等纯 JS):vuex 引用全部改为函数内部 `useXxxStore()`。
|
||||
- **$user() 名片弹层**(`components/user/user-card/index.js`):独立子 app 改用独立 `createPinia()`。
|
||||
- **app-bridge.js**:保留了 `$store.state.talks.items` 兼容门面(惰性 getter),供旧调用形态使用。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd im
|
||||
yarn install
|
||||
yarn dev # 端口 8000
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 迁移要点
|
||||
|
||||
1. **入口** `src/main.js` — `createApp`,全局过滤器 `$filters`
|
||||
2. **路由** `createRouter` + `createWebHistory`,404 路由 `/:pathMatch(.*)*`
|
||||
3. **通知** `im-server/event/talk.js` 通过 `app-bridge` 发 `ElNotification`,避免循环引用
|
||||
4. **用户卡片** `$user()` 插件用 `createApp` 挂载
|
||||
5. **指令** `v-focus` / `v-paste` / `v-drag` / `v-outside` 已改为 Vue 3 钩子
|
||||
6. **SVG** `svg-sprite-loader` + `svg-icon` 全局组件
|
||||
7. **生命周期** `destroyed` → `beforeUnmount`;`$set` / `$delete` 已移除
|
||||
|
||||
## 上线前检查
|
||||
|
||||
统一清单见仓库根目录:[../MIGRATION-VUE3.md](../MIGRATION-VUE3.md)
|
||||
IM 专项见该文档 **「IM 客服端」** 一节。
|
||||
@@ -1,4 +1,5 @@
|
||||
import { post, get, upload, del } from "@/utils/request";
|
||||
import config from "@/config/config";
|
||||
|
||||
// 获取聊天列表服务接口
|
||||
export const ServeGetTalkList = (data) => {
|
||||
@@ -69,9 +70,9 @@ export const ServeSendTalkFile = (data) => {
|
||||
return post("/im/talk/message/file", data);
|
||||
};
|
||||
|
||||
// 发送聊天图片服务接口
|
||||
// 上传客服图片到独立 OSS;FormData 必须包含会话 ID,店铺及目录由后端推导。
|
||||
export const ServeSendTalkImage = (data) => {
|
||||
return upload("/buyer/common/upload/file", data);
|
||||
return upload("/im/message/image", data, { apiBase: config.BASE_API_URL });
|
||||
};
|
||||
|
||||
// 发送表情包服务接口
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="image-message no-select">
|
||||
<el-image
|
||||
fit="cover"
|
||||
fit="contain"
|
||||
:src="src"
|
||||
:lazy="true"
|
||||
:style="getImgStyle(src)"
|
||||
:preview-src-list="[src]"
|
||||
preview-teleported
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot">图片加载失败...</div>
|
||||
@@ -29,6 +29,10 @@ defineProps({
|
||||
})
|
||||
|
||||
function getImgStyle(url) {
|
||||
// 商城上传地址不携带尺寸,提供可见的预览区域;原尺寸后缀仍沿用旧展示规则。
|
||||
if (!/\d+x\d+/.test(url.split('_').pop())) {
|
||||
return { width: '200px', height: '200px', maxWidth: '100%' }
|
||||
}
|
||||
return imgZoom(url, 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -295,7 +295,7 @@ function formatDateToString(date) {
|
||||
// margin-left: 12px;
|
||||
}
|
||||
:deep(.el-tabs__nav-scroll) {
|
||||
min-width: 362px;
|
||||
min-width: 0;
|
||||
}
|
||||
:deep(.el-tabs__item) {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<el-container class="ov-hidden flex-10 full-height ">
|
||||
<el-container class="ov-hidden flex-10 full-height chat-main">
|
||||
<PanelHeader ref="panelHeader" :data="params" :online="isOnline" :keyboard="inputEvent"
|
||||
@event="handleHeaderEvent" />
|
||||
<!-- 主体信息 -->
|
||||
@@ -65,7 +65,7 @@
|
||||
<pre v-if="!emojistwo.includes(item.text)" v-html="item.text" />
|
||||
<pre v-if="emojistwo.includes(item.text)" v-html="textReplaceEmoji(item.text)" />
|
||||
</div>
|
||||
|
||||
<ImageMessage v-if="item.messageType === 'PICTURE'" :src="item.text" />
|
||||
</div>
|
||||
<div v-if="item.webSocketStatus" class="tips">网络异常发送失败,请重新发送。</div>
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
<!-- 页脚信息 -->
|
||||
<el-footer class="footer-box" height="180">
|
||||
<template v-if="multiSelect.isOpen === false">
|
||||
<MeEditor @send="submitSendMessage" @keyboard-event="onKeyboardEvent" />
|
||||
<MeEditor :talk-id="params.talkId" @send="submitSendMessage" @send-image="submitSendImage" @keyboard-event="onKeyboardEvent" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<PanelToolbar v-model="multiSelect.items.length" @event="handleMultiMode" />
|
||||
@@ -185,7 +185,7 @@
|
||||
</transition>
|
||||
|
||||
<!-- 链接信息 -->
|
||||
<OtherLink :toUser="toUser" :id="id" :goodsParams="goodsParams" class="flex-4" />
|
||||
<OtherLink :toUser="toUser" :id="id" :goodsParams="goodsParams" class="chat-sidebar" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
@@ -202,6 +202,7 @@ import { textReplaceEmoji, emojistwo } from "@/utils/emojis";
|
||||
import OtherLink from "@/components/chat/panel/OtherLink.vue";
|
||||
import TalkSearchRecord from "@/components/chat/TalkSearchRecord";
|
||||
import MeEditor from "@/components/editor/MeEditor";
|
||||
import ImageMessage from "@/components/chat/messaege/ImageMessage.vue";
|
||||
import PanelHeader from "./PanelHeader";
|
||||
import PanelToolbar from "./PanelToolbar";
|
||||
import SocketInstance from "@/im-server/socket-instance";
|
||||
@@ -414,16 +415,21 @@ function formateDateAndTimeToString(date) {
|
||||
// 重新发送消息
|
||||
function againSendMessage(val) {
|
||||
|
||||
submitSendMessage(val.text)
|
||||
submitSendMessage(val.text, val.messageType)
|
||||
}
|
||||
|
||||
// 回车键发送消息回调事件
|
||||
function submitSendMessage(content) {
|
||||
// 发送已上传图片,复用消息发送和失败重发流程。
|
||||
function submitSendImage(url) {
|
||||
submitSendMessage(url, "PICTURE");
|
||||
}
|
||||
|
||||
// 发送文本或图片,重发保持原类型,发送图片时保留文字草稿。
|
||||
function submitSendMessage(content, messageType = "MESSAGE") {
|
||||
const record = {
|
||||
operation_type: "MESSAGE",
|
||||
to: props.params.receiver_id,
|
||||
from: id.value,
|
||||
message_type: "MESSAGE",
|
||||
message_type: messageType,
|
||||
context: content,
|
||||
talk_id: props.params.talkId,
|
||||
};
|
||||
@@ -434,7 +440,7 @@ function submitSendMessage(content) {
|
||||
|
||||
talkStore.UPDATE_TALK_ITEM({
|
||||
index_name: index_name.value,
|
||||
draft_text: "",
|
||||
...(messageType === "MESSAGE" ? { draft_text: "" } : {}),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -445,7 +451,8 @@ function submitSendMessage(content) {
|
||||
fromUser: id.value,
|
||||
toUser: record.to,
|
||||
isRead: false,
|
||||
messageType: "MESSAGE",
|
||||
messageType,
|
||||
talkId: record.talk_id,
|
||||
text: content,
|
||||
float: "right",
|
||||
};
|
||||
@@ -465,7 +472,7 @@ function submitSendMessage(content) {
|
||||
});
|
||||
} else {
|
||||
talkStore.SET_TLAK_UNREAD_MESSAGE({
|
||||
content: content,
|
||||
content: messageType === "PICTURE" ? "[图片]" : content,
|
||||
nickname: record.name,
|
||||
});
|
||||
}
|
||||
@@ -889,6 +896,17 @@ function talkPanelScrollBottom() {
|
||||
}
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
/* 固定侧栏宽度,防止 tab 内容最小宽度挤压聊天区域。 */
|
||||
.chat-main {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-sidebar {
|
||||
flex: 0 0 362px;
|
||||
width: 362px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.flex-a-c{
|
||||
align-items: center;
|
||||
>div{
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
<legacy-el-icon name="el-icon-headset" />
|
||||
<p class="tip-title">语音消息</p>
|
||||
</li> -->
|
||||
<!-- #TODO 发图片功能暂时隐藏 此处会涉及到token过期 -->
|
||||
<!-- <li @click="$refs.restFile.click()">
|
||||
<li role="button" tabindex="0" aria-label="发送图片" @click="restFile.click()"
|
||||
@keydown.enter="restFile.click()" @keydown.space.prevent="restFile.click()">
|
||||
<legacy-el-icon name="el-icon-picture-outline-round" />
|
||||
<p class="tip-title">图片</p>
|
||||
</li> -->
|
||||
</li>
|
||||
<!-- <li @click="$refs.restFile2.click()">
|
||||
<legacy-el-icon name="el-icon-folder" />
|
||||
<p class="tip-title">附件</p>
|
||||
@@ -117,8 +117,12 @@ import {
|
||||
} from "@/api/chat";
|
||||
|
||||
defineOptions({ name: "MeEditor" });
|
||||
const props = defineProps({
|
||||
// 后端 li_im_talk.id;不使用接收方 ID 代替会话 ID,避免图片存入错误会话。
|
||||
talkId: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["keyboard-event", "send"]);
|
||||
const emit = defineEmits(["keyboard-event", "send", "send-image"]);
|
||||
|
||||
const dialogueStore = useDialogueStore();
|
||||
|
||||
@@ -141,6 +145,10 @@ const imageViewer = reactive({
|
||||
isShow: false,
|
||||
file: null,
|
||||
});
|
||||
// 预览绑定原会话,上传期间切换会话后不发送,避免图片误发。
|
||||
let imageTalk = null;
|
||||
let imageTalkId = null;
|
||||
let uploadingImage = false;
|
||||
|
||||
const codeBlock = reactive({
|
||||
isShow: false,
|
||||
@@ -165,6 +173,8 @@ const sendtime = ref(0);
|
||||
const interval = ref(1000);
|
||||
|
||||
watch(talkUser, (n_index_name) => {
|
||||
imageViewer.isShow = false;
|
||||
imageViewer.file = null;
|
||||
fileManageRef.value?.clear();
|
||||
editorText.value = getDraftText(n_index_name);
|
||||
});
|
||||
@@ -253,6 +263,21 @@ function uploadFileChange(e) {
|
||||
|
||||
// 打开图片查看器
|
||||
function openImageViewer(file) {
|
||||
if (!file || uploadingImage) return;
|
||||
if (!props.talkId) {
|
||||
ElNotification.warning({ message: "会话尚未就绪,请稍后重试" });
|
||||
return;
|
||||
}
|
||||
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type) || !file.size) {
|
||||
ElNotification.warning({ message: "请选择有效的 PNG、JPG、GIF 或 WebP 图片" });
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElNotification.warning({ message: "图片不能大于10MB" });
|
||||
return;
|
||||
}
|
||||
imageTalk = talkUser.value;
|
||||
imageTalkId = props.talkId;
|
||||
imageViewer.isShow = true;
|
||||
imageViewer.file = file;
|
||||
}
|
||||
@@ -278,29 +303,34 @@ function confirmCodeBlock(data) {
|
||||
});
|
||||
}
|
||||
|
||||
// 确认上传图片消息回调事件
|
||||
function confirmUploadImage() {
|
||||
let fileData = new FormData();
|
||||
// 上传返回地址后通过现有消息通道发送;失败保留预览以便重试。
|
||||
async function confirmUploadImage() {
|
||||
if (uploadingImage) return;
|
||||
const imageView = imageViewerRef.value;
|
||||
const targetTalk = imageTalk;
|
||||
const targetTalkId = imageTalkId;
|
||||
uploadingImage = true;
|
||||
try {
|
||||
const fileData = new FormData();
|
||||
fileData.append("file", imageViewer.file);
|
||||
|
||||
let imageView = imageViewerRef.value;
|
||||
|
||||
ServeSendTalkImage(fileData)
|
||||
.then((res) => {
|
||||
imageView.loading = false;
|
||||
if (res.code == 200) {
|
||||
imageView.closeBox();
|
||||
} else {
|
||||
ElNotification({
|
||||
title: "友情提示",
|
||||
message: res.message,
|
||||
type: "warning",
|
||||
});
|
||||
fileData.append("talkId", targetTalkId);
|
||||
const res = await ServeSendTalkImage(fileData);
|
||||
if (res.code !== 200 || typeof res.result !== "string" || !/^https?:\/\//i.test(res.result)) {
|
||||
throw new Error(res.message || "图片上传失败,请重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (targetTalk !== talkUser.value || targetTalkId !== props.talkId || !imageViewer.isShow) {
|
||||
ElNotification.warning({ message: "会话已切换,图片未发送,请在目标会话重新选择" });
|
||||
return;
|
||||
}
|
||||
emit("send-image", res.result);
|
||||
imageView.loading = false;
|
||||
});
|
||||
imageView.closeBox();
|
||||
} catch (error) {
|
||||
ElNotification.error({ message: error.message || "图片上传失败,请重试" });
|
||||
} finally {
|
||||
uploadingImage = false;
|
||||
imageView.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选中表情包回调事件
|
||||
|
||||
@@ -62,7 +62,10 @@ function closeBox() {
|
||||
emit('close', false)
|
||||
}
|
||||
|
||||
// 清理切换会话后的预览,仅对有效文件读取内容。
|
||||
function loadFile(file) {
|
||||
src.value = ''
|
||||
if (!file) return
|
||||
let reader = new FileReader()
|
||||
fileSize.value = Math.ceil(file.size / 1024)
|
||||
fileName.value = file.name
|
||||
@@ -75,6 +78,7 @@ function loadFile(file) {
|
||||
|
||||
// 确认按钮事件
|
||||
function uploadImage() {
|
||||
if (loading.value || !src.value) return
|
||||
loading.value = true
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ class Talk extends Base {
|
||||
super();
|
||||
this.sender_id = resource.fromUser; //发送
|
||||
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;
|
||||
|
||||
@@ -93,12 +94,22 @@ class Talk extends Base {
|
||||
return userId == this.getAccountId() ? "right" : "left";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息内容类型(MESSAGE/PICTURE/GOODS/ORDERS)
|
||||
*/
|
||||
getMessageType () {
|
||||
return this.resource.messageType || this.resource.msg_type || "MESSAGE";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天列表左侧的对话信息
|
||||
*/
|
||||
getTalkText () {
|
||||
let text = this.resource.content || this.resource.text;
|
||||
switch (this.resource.msg_type) {
|
||||
switch (this.getMessageType()) {
|
||||
case 'PICTURE':
|
||||
text = "[图片]";
|
||||
break;
|
||||
case 'GOODS':
|
||||
text = "[商品链接]";
|
||||
break;
|
||||
@@ -224,10 +235,15 @@ class Talk extends Base {
|
||||
}
|
||||
// console.log("%c 准备更新...UPDATE_TALK_ITEM ", "color:red");
|
||||
|
||||
const nowTime = parseTime(new Date());
|
||||
const msgText = this.getTalkText() || record.text;
|
||||
talkStore.UPDATE_TALK_ITEM({
|
||||
index_name: this.getIndexName(),
|
||||
msg_text: this.getTalkText() || record.text,
|
||||
updated_at: parseTime(new Date()),
|
||||
msg_text: msgText,
|
||||
lastTalkMessage: msgText,
|
||||
lastTalkTime: nowTime,
|
||||
lastMessageType: this.getMessageType(),
|
||||
updated_at: nowTime,
|
||||
});
|
||||
|
||||
if (this.talk_type == 1 && this.getAccountId() !== this.sender_id) {
|
||||
@@ -248,6 +264,7 @@ class Talk extends Base {
|
||||
useTalkStore().UPDATE_TALK_MESSAGE({
|
||||
index_name: this.getIndexName(),
|
||||
msg_text: this.getTalkText(),
|
||||
lastMessageType: this.getMessageType(),
|
||||
updated_at: parseTime(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useDialogueStore } from "@/stores/dialogue";
|
||||
import config from "@/config/config";
|
||||
import WsSocket from "@/plugins/ws-socket";
|
||||
import { getToken } from "@/utils/auth";
|
||||
@@ -86,6 +87,15 @@ class SocketInstance {
|
||||
});
|
||||
|
||||
this.socket.on("event_error", (data) => {
|
||||
// 将被服务端拒绝的图片标记为失败,避免乐观展示被误认为发送成功。
|
||||
if (data.pictureUrl) {
|
||||
useDialogueStore().records.forEach((record) => {
|
||||
if (record.messageType === "PICTURE" && record.float === "right"
|
||||
&& record.talkId === data.talkId && record.text === data.pictureUrl) {
|
||||
record.webSocketStatus = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
ElNotification({
|
||||
title: "友情提示",
|
||||
message: data.message,
|
||||
|
||||
@@ -38,6 +38,8 @@ export default {
|
||||
await this.createTalk(this.$route.query.id);
|
||||
} else if (typeof this.loadChatList === "function") {
|
||||
await this.loadChatList();
|
||||
} else if (typeof this.loadChatListInJs === "function") {
|
||||
await this.loadChatListInJs();
|
||||
}
|
||||
}
|
||||
if(this.$route.query.goodsId && this.$route.query.skuId && this.goodsParams){
|
||||
@@ -104,8 +106,8 @@ export default {
|
||||
items: result.map((item) => formatTalkItem(item)),
|
||||
});
|
||||
|
||||
// 判断
|
||||
if (this.$route.query.id) {
|
||||
// 判断(仅在挂载了 clickTab 的页面组件实例上自动切换会话)
|
||||
if (this.$route.query.id && typeof this.clickTab === "function") {
|
||||
let takeData, takeIndex;
|
||||
result.forEach((take, index) => {
|
||||
if (take.id == this.$route.query.id) {
|
||||
@@ -113,9 +115,13 @@ export default {
|
||||
takeIndex = index;
|
||||
}
|
||||
});
|
||||
if (typeof this.$nextTick === "function") {
|
||||
this.$nextTick(() =>
|
||||
this.clickTab(this.$route.query.id, takeData, takeIndex)
|
||||
);
|
||||
} else {
|
||||
this.clickTab(this.$route.query.id, takeData, takeIndex);
|
||||
}
|
||||
}
|
||||
}).finally(() => {
|
||||
this.loadStatus = 1;
|
||||
|
||||
@@ -272,6 +272,12 @@ class WsSocket {
|
||||
* @param {Object} evt Websocket 消息
|
||||
*/
|
||||
onMessage(evt) {
|
||||
// 图片权限校验失败属于系统反馈,不能当成聊天消息插入记录。
|
||||
const envelope = JSON.parse(evt.data);
|
||||
if (envelope.messageResultType === "SYSTEM_TIPS") {
|
||||
this.onCallBacks["event_error"]?.(envelope.result);
|
||||
return;
|
||||
}
|
||||
let result = this.onParse(evt);
|
||||
// 部分推送(在线状态、系统通知等)没有 text 字段,不做商品/群名解析直接透传
|
||||
const text = result.text;
|
||||
|
||||
@@ -71,11 +71,17 @@ export const useTalkStore = defineStore("talks", {
|
||||
UPDATE_TALK_MESSAGE(resource) {
|
||||
let enableGetTalkList = true;
|
||||
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.msg_text = resource.msg_text;
|
||||
item.lastTalkTime = resource.updated_at;
|
||||
item.lastTalkMessage = resource.msg_text;
|
||||
if (resource.lastMessageType) {
|
||||
item.lastMessageType = resource.lastMessageType;
|
||||
}
|
||||
item.updated_at = resource.updated_at;
|
||||
enableGetTalkList = false;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const mixinCtx = {
|
||||
return router.currentRoute.value;
|
||||
},
|
||||
};
|
||||
mixinCtx.loadChatListInJs = MainMixin.methods.loadChatListInJs.bind(mixinCtx);
|
||||
mixinCtx.loadStoreChatList = MainMixin.methods.loadStoreChatList.bind(mixinCtx);
|
||||
|
||||
export function setRefreshView(fn) {
|
||||
refreshViewFn = fn;
|
||||
|
||||
@@ -153,10 +153,12 @@ export const del = (url, data = {}, options = {}) => {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const upload = (url, data = {}, options = {}) => {
|
||||
// 默认沿用买家上传地址;客服图片显式选择 IM 服务,不将文件发往通用上传入口。
|
||||
const { apiBase = config.BASE_BUYER, ...requestOptions } = options;
|
||||
return request({
|
||||
url: config.BASE_BUYER + url,
|
||||
url: apiBase + url,
|
||||
method: "post",
|
||||
data: data,
|
||||
...options,
|
||||
...requestOptions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -113,8 +113,9 @@
|
||||
</div>
|
||||
<div class="talk-message">
|
||||
<span v-if="item.lastMessageType === 'MESSAGE'" v-html="textReplaceEmoji(item.lastTalkMessage)"></span>
|
||||
<span v-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
|
||||
<span v-if="item.lastMessageType === 'ORDER'">[订单链接]</span>
|
||||
<span v-else-if="item.lastMessageType === 'PICTURE'">[图片]</span>
|
||||
<span v-else-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
|
||||
<span v-else-if="item.lastMessageType === 'ORDERS' || item.lastMessageType === 'ORDER'">[订单链接]</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<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]) {
|
||||
talkStore.UPDATE_TALK_ITEM({
|
||||
userId: id,
|
||||
index_name: "MESSAGE_" + index_name.value.split("_")[1],
|
||||
index_name: index_name.value,
|
||||
unread: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +78,15 @@ export const getOrderList = (params) => {
|
||||
return getRequest(`/order/order`, params)
|
||||
}
|
||||
|
||||
// 小程序专用接口由服务端固定来源与微信支付范围,并回填本地上报结果。
|
||||
export const getWechatOrderList = (params) => getRequest('/order/order/wechat', params)
|
||||
// 状态统计与专用列表共用上报状态筛选。
|
||||
export const getWechatOrderNum = (params) => getRequest('/order/order/wechat/orderNum', params)
|
||||
// 人工补报复用支付流水锁和真实履约校验,不受自动上报开关限制。
|
||||
export const retryWechatOrder = (sn) => postRequest(`/order/order/wechat/${encodeURIComponent(sn)}/retry`, {}, undefined, 120000)
|
||||
// 查询同笔微信支付下的操作日志,不返回原始支付和物流凭据。
|
||||
export const getWechatOrderLogs = (sn, params) => getRequest(`/order/order/wechat/${encodeURIComponent(sn)}/logs`, params)
|
||||
|
||||
// 导出订单列表
|
||||
export const exportOrder = (params) => {
|
||||
return getRequest(`/order/order/queryExportOrder`, params,'blob')
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<!--
|
||||
楼层装修链接选择器-营销活动选择面板(满额活动拆分为满减、满折、满赠三项)
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="(item, index) in Object.keys(promotionList)"
|
||||
v-for="(item, index) in promotionKeys"
|
||||
:key="index"
|
||||
class="list-item"
|
||||
:class="{ active: selectedIndex == index }"
|
||||
@@ -76,7 +81,7 @@ const emit = defineEmits(["selected"]);
|
||||
|
||||
const totals = ref("");
|
||||
const loading = ref(true);
|
||||
const promotionList = ref("");
|
||||
const promotionList = ref({});
|
||||
const selectedIndex = ref(0);
|
||||
const promotions = ref("");
|
||||
const index = ref(999);
|
||||
@@ -86,6 +91,12 @@ const params = ref({
|
||||
});
|
||||
const showPromotionList = ref([]);
|
||||
|
||||
const promotionKeys = computed(() => {
|
||||
return Object.keys(promotionList.value || {}).filter(
|
||||
(k) => k !== "FULL_DISCOUNT" && Boolean(typeOption(k).title)
|
||||
);
|
||||
});
|
||||
|
||||
const isSeckillMode = computed(() => {
|
||||
return promotions.value === "SECKILL";
|
||||
});
|
||||
@@ -106,12 +117,18 @@ function seckillStatusText(v) {
|
||||
}
|
||||
function sortGoods(type) {
|
||||
loading.value = false;
|
||||
showPromotionList.value = promotionList.value[type];
|
||||
showPromotionList.value = promotionList.value[type] || [];
|
||||
}
|
||||
function typeOption(type) {
|
||||
switch (type) {
|
||||
case "FULL_MINUS":
|
||||
return { title: "满减活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_MINUS"); } };
|
||||
case "FULL_RATE":
|
||||
return { title: "满折活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_RATE"); } };
|
||||
case "FULL_GIFT":
|
||||
return { title: "满赠活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_GIFT"); } };
|
||||
case "FULL_DISCOUNT":
|
||||
return { title: "满减", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_DISCOUNT"); } };
|
||||
return { title: "满减活动", methodsed: () => { showPromotionList.value = []; sortGoods("FULL_DISCOUNT"); } };
|
||||
case "PINTUAN":
|
||||
return { title: "拼团", methodsed: () => { showPromotionList.value = []; sortGoods("PINTUAN"); } };
|
||||
case "KANJIA":
|
||||
@@ -143,15 +160,18 @@ function getPromotion(res) {
|
||||
if (res.result) {
|
||||
promotionList.value = res.result;
|
||||
delete promotionList.value.COUPON;
|
||||
Object.keys(res.result)[0] && typeOption(Object.keys(res.result)[0]).methodsed();
|
||||
promotions.value = Object.keys(res.result)[0];
|
||||
const keys = promotionKeys.value;
|
||||
if (keys.length) {
|
||||
promotions.value = keys[0];
|
||||
typeOption(keys[0]) && typeOption(keys[0]).methodsed();
|
||||
}
|
||||
}
|
||||
}
|
||||
function clickPromotion(val, i) {
|
||||
promotions.value = val;
|
||||
selectedIndex.value = i;
|
||||
params.value.pageNumber = 1;
|
||||
typeOption(val) && typeOption(val).methodsed(promotionList.value[val].id);
|
||||
typeOption(val) && typeOption(val).methodsed(promotionList.value[val]?.id);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -182,10 +182,12 @@ export const postBlobRequest = (url, data) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const postRequest = (url, params, headers) => {
|
||||
// 个别需等待外部平台的操作可传超时毫秒数,现有调用保持 8 秒默认值。
|
||||
export const postRequest = (url, params, headers, timeout = 8000) => {
|
||||
let accessToken = getStore("accessToken");
|
||||
return service({
|
||||
method: "post",
|
||||
timeout,
|
||||
url: `${url}`,
|
||||
data: params,
|
||||
transformRequest: headers
|
||||
|
||||
@@ -408,6 +408,15 @@ util.initRouterNode = function(routers, data) {
|
||||
}
|
||||
const menu = Object.assign({}, item);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
if (
|
||||
!hasChildren &&
|
||||
(menu.frontRoute === "promotions/full-discount/full-discount" ||
|
||||
menu.name === "full-discount" ||
|
||||
menu.name === "full-cut-activity") &&
|
||||
menu.title === "满额活动"
|
||||
) {
|
||||
menu.title = "满减活动";
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
menu.children = [];
|
||||
|
||||
23
manager/src/utils/wechatOrderSync.js
Normal file
23
manager/src/utils/wechatOrderSync.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 小程序订单上报状态及日志显示口径,成功仅表示发货接口受理。
|
||||
* @author Bulbasaur
|
||||
* @since 2026-11-09
|
||||
*/
|
||||
export const wechatSyncOptions = [
|
||||
{ value: "UNCHECKED", label: "待核查", type: "info" },
|
||||
{ value: "PENDING", label: "待上报", type: "warning" },
|
||||
{ value: "PROCESSING", label: "处理中", type: "warning" },
|
||||
{ value: "SUCCESS", label: "上报成功", type: "success" },
|
||||
{ value: "FAILED", label: "上报失败", type: "danger" },
|
||||
];
|
||||
|
||||
/** 查找页面和日志的统一状态标签,重复请求跳过使用独立说明。 */
|
||||
export function wechatSyncLabel(value) {
|
||||
if (value === "SKIPPED") return "已跳过重复";
|
||||
return wechatSyncOptions.find((item) => item.value === value)?.label || value || "—";
|
||||
}
|
||||
|
||||
/** 返回 Element Plus 标签类型,未知状态使用中性色。 */
|
||||
export function wechatSyncTag(value) {
|
||||
return wechatSyncOptions.find((item) => item.value === value)?.type || "info";
|
||||
}
|
||||
@@ -49,6 +49,24 @@ const menuList = computed(() => appStore.menuList);
|
||||
const navList = computed(() => appStore.navList);
|
||||
const currNav = computed(() => appStore.currNav);
|
||||
const activeMenuName = computed(() => {
|
||||
if (route.name === "full-discount-detail") {
|
||||
const type = route.query?.activityType;
|
||||
const candidates =
|
||||
type === "FULL_GIFT"
|
||||
? ["manager-full-gift", "full-gift", "full-discount", "full-cut-activity"]
|
||||
: type === "FULL_RATE"
|
||||
? ["manager-full-rate", "full-rate", "full-discount", "full-cut-activity"]
|
||||
: ["full-discount", "full-cut-activity"];
|
||||
for (const candidate of candidates) {
|
||||
for (const item of menuList.value) {
|
||||
for (const menu of item.children || []) {
|
||||
if (menu.name === candidate || menu.path === candidate) {
|
||||
return menu.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const routePath = util.normalizeRoutePath(route.path);
|
||||
for (const item of menuList.value) {
|
||||
for (const menu of item.children || []) {
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
/>
|
||||
<div class="goods-info">
|
||||
<div class="goods-name">
|
||||
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
|
||||
<span>{{ row.goodsName }}</span>
|
||||
</div>
|
||||
<div v-if="row.specs" class="goods-specs">
|
||||
@@ -391,7 +392,11 @@
|
||||
|
||||
<el-table-column label="单价" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono font-bold">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</span>
|
||||
<template v-if="isGiftItem(row)">
|
||||
<div class="font-mono text-gray" style="text-decoration: line-through;">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</div>
|
||||
<div class="text-danger font-bold">赠品 ¥ 0.00</div>
|
||||
</template>
|
||||
<span v-else class="font-mono font-bold">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -1159,10 +1164,16 @@ function formatOperatorType(type) {
|
||||
return map[type] || type || "系统";
|
||||
}
|
||||
|
||||
function isGiftItem(row) {
|
||||
if (!row) return false;
|
||||
return row.promotionType === 'GIFT' || (Number(row.flowPrice) === 0 && row.promotionType === 'GIFT');
|
||||
}
|
||||
|
||||
function getPromotionText(row) {
|
||||
let resultText = "";
|
||||
if (row && row.promotionType) {
|
||||
const type = row.promotionType.split(",");
|
||||
if (type.indexOf("GIFT") !== -1) resultText += "满赠赠品 ";
|
||||
if (type.indexOf("PINTUAN") !== -1) resultText += "拼团 ";
|
||||
if (type.indexOf("SECKILL") !== -1) resultText += "秒杀 ";
|
||||
if (type.indexOf("COUPON") !== -1) resultText += "优惠券 ";
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
@change="selectDateRange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<slot name="extra-filters" :form="searchForm" />
|
||||
<el-form-item class="order-search-action">
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
@@ -74,6 +75,9 @@
|
||||
<el-button type="warning" class="export" :loading="exporting" @click="exportOrder">导出订单</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" title="订单加载失败,请重试" type="error" :closable="false" show-icon>
|
||||
<el-button link type="primary" @click="init">重新加载</el-button>
|
||||
</el-alert>
|
||||
<el-table v-loading="loading" :data="data" ref="tableRef" class="mt_10" style="width: 100%">
|
||||
<el-table-column prop="sn" label="订单号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="商品" min-width="280">
|
||||
@@ -126,9 +130,11 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="下单时间" width="170" />
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<slot name="extra-columns" />
|
||||
<el-table-column label="操作" :width="actionWidth" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<a class="link-text" @click="detail(row)">查看</a>
|
||||
<slot name="extra-actions" :row="row" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -162,6 +168,8 @@ const props = defineProps({
|
||||
fixedClientType: { type: String, default: "" },
|
||||
fixedPaymentMethod: { type: String, default: "" },
|
||||
allowExport: { type: Boolean, default: true },
|
||||
wechatSync: { type: Boolean, default: false },
|
||||
actionWidth: { type: Number, default: 80 },
|
||||
});
|
||||
|
||||
const PROMOTION_OPTIONS = [
|
||||
@@ -204,6 +212,7 @@ function emptySearchForm(pageSize = 20) {
|
||||
orderStatus: "",
|
||||
paymentMethod: props.fixedPaymentMethod,
|
||||
orderPromotionType: "",
|
||||
...(props.wechatSync ? { wechatSyncStatus: "" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,6 +225,9 @@ const searchFormRef = ref(null);
|
||||
const tableRef = ref(null);
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
let listRequestSeq = 0;
|
||||
let countRequestSeq = 0;
|
||||
const exporting = ref(false);
|
||||
const pageSizes = ref(PAGE_SIZES);
|
||||
const promotionOptions = ref(PROMOTION_OPTIONS);
|
||||
@@ -389,16 +401,28 @@ function listParams() {
|
||||
return params;
|
||||
}
|
||||
function getDataList() {
|
||||
const requestId = ++listRequestSeq;
|
||||
loading.value = true;
|
||||
API_Order.getOrderList(listParams())
|
||||
loadError.value = false;
|
||||
const fetchList = props.wechatSync ? API_Order.getWechatOrderList : API_Order.getOrderList;
|
||||
fetchList(listParams())
|
||||
.then((res) => {
|
||||
if (requestId !== listRequestSeq) return;
|
||||
if (res.success) {
|
||||
data.value = res.result.records;
|
||||
total.value = res.result.total;
|
||||
} else {
|
||||
throw new Error("订单查询失败");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId !== listRequestSeq) return;
|
||||
data.value = [];
|
||||
total.value = 0;
|
||||
loadError.value = true;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
if (requestId === listRequestSeq) loading.value = false;
|
||||
});
|
||||
}
|
||||
function detail(v) {
|
||||
@@ -454,14 +478,19 @@ function orderStatusClick(name) {
|
||||
getDataList();
|
||||
}
|
||||
function getOrderNumData() {
|
||||
API_Order.getOrderNum(countParams())
|
||||
const requestId = ++countRequestSeq;
|
||||
const fetchCount = props.wechatSync ? API_Order.getWechatOrderNum : API_Order.getOrderNum;
|
||||
fetchCount(countParams())
|
||||
.then((res) => {
|
||||
if (requestId !== countRequestSeq) return;
|
||||
if (res.success) {
|
||||
orderNumData.value = res.result;
|
||||
} else {
|
||||
orderNumData.value = {};
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
orderNumData.value = {};
|
||||
if (requestId === countRequestSeq) orderNumData.value = {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -476,6 +505,9 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// 补报完成后刷新当前筛选和页码,不清空运营人员已选条件。
|
||||
defineExpose({ reload: init });
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
sessionStorage.removeItem("manager-order-list-query");
|
||||
|
||||
88
manager/src/views/order/wechat/SyncLogDrawer.vue
Normal file
88
manager/src/views/order/wechat/SyncLogDrawer.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<!--
|
||||
小程序订单同步日志抽屉:按支付流水展示审计记录,不显示原始身份或支付凭据。
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<el-drawer :model-value="modelValue" title="微信订单同步日志" size="min(1100px, 95vw)" @update:model-value="emit('update:modelValue', $event)">
|
||||
<p>订单:{{ orderSn }}。日志包含同笔微信支付下的关联子订单操作;上报成功仅代表微信接口已受理。</p>
|
||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||
<el-alert v-if="failed" title="日志加载失败,请点击刷新重试" type="error" :closable="false" class="mt_10" />
|
||||
<el-table v-loading="loading" :data="records" class="mt_10" empty-text="暂无本地同步记录,历史上报结果需核查">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="log-detail">
|
||||
<p>触发订单:{{ row.orderSn }}</p>
|
||||
<p>请求摘要:{{ row.summary || '—' }}</p>
|
||||
<p>错误码:{{ row.errorCode || '—' }}</p>
|
||||
<p>处理原因:{{ row.errorMessage || '—' }}</p>
|
||||
<p>结束时间:{{ row.finishTime || '—' }};耗时:{{ row.durationMs == null ? '—' : `${row.durationMs} 毫秒` }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="操作时间" width="175" />
|
||||
<el-table-column label="操作" width="110">
|
||||
<template #default="{ row }">{{ row.operation === 'RECEIVE' ? '收货通知' : '发货上报' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="110">
|
||||
<template #default="{ row }">{{ row.triggerType === 'MANUAL' ? '人工补报' : '自动事件' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operatorName" label="操作人" min-width="100" />
|
||||
<el-table-column label="结果" min-width="120">
|
||||
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.status)">{{ wechatSyncLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorMessage" label="处理原因" min-width="220" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="pageNumber" :page-size="20" :total="total" layout="total, prev, pager, next" class="mt_10" @current-change="load" />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { getWechatOrderLogs } from "@/api/order";
|
||||
import { wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
|
||||
|
||||
const props = defineProps({ modelValue: Boolean, orderSn: { type: String, default: "" } });
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const records = ref([]);
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
const total = ref(0);
|
||||
const pageNumber = ref(1);
|
||||
let requestSeq = 0;
|
||||
|
||||
/** 请求序号隔离不同订单及页码,关闭抽屉后不接受过期响应。 */
|
||||
async function load() {
|
||||
if (!props.modelValue || !props.orderSn) return;
|
||||
const id = ++requestSeq;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const res = await getWechatOrderLogs(props.orderSn, { pageNumber: pageNumber.value, pageSize: 20 });
|
||||
if (id !== requestSeq) return;
|
||||
if (!res.success) throw new Error("日志加载失败");
|
||||
records.value = res.result.records;
|
||||
total.value = res.result.total;
|
||||
} catch (e) {
|
||||
if (id !== requestSeq) return;
|
||||
failed.value = true;
|
||||
records.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
if (id === requestSeq) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.modelValue, props.orderSn], () => {
|
||||
requestSeq++;
|
||||
records.value = [];
|
||||
total.value = 0;
|
||||
pageNumber.value = 1;
|
||||
loading.value = false;
|
||||
if (props.modelValue) load();
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-detail { padding: 0 20px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -8,14 +8,76 @@
|
||||
class="wechat-order-notice"
|
||||
description="仅展示微信小程序下单且使用微信支付的订单。自动上报开关开启且微信侧已开通发货管理时,按现有履约流程上报。"
|
||||
/>
|
||||
<OrderList fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" />
|
||||
<OrderList ref="orderListRef" fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" wechat-sync :action-width="220">
|
||||
<template #extra-filters="{ form }">
|
||||
<el-form-item label="上报状态" prop="wechatSyncStatus">
|
||||
<el-select v-model="form.wechatSyncStatus" placeholder="全部上报状态" clearable>
|
||||
<el-option v-for="item in wechatSyncOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template #extra-columns>
|
||||
<el-table-column label="微信上报状态" width="130">
|
||||
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.wechatSyncStatus)">{{ wechatSyncLabel(row.wechatSyncStatus) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="wechatSyncTime" label="最近上报时间" width="175" />
|
||||
<el-table-column prop="wechatSyncError" label="上报处理原因" min-width="230" show-overflow-tooltip />
|
||||
</template>
|
||||
<template #extra-actions="{ row }">
|
||||
<el-button link type="primary" @click="showLogs(row)">同步日志</el-button>
|
||||
<el-button link type="warning" :loading="retryingSn === row.sn" :disabled="!!retryingSn" @click="retry(row)">补报</el-button>
|
||||
</template>
|
||||
</OrderList>
|
||||
<SyncLogDrawer v-model="logVisible" :order-sn="logOrderSn" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OrderList from "../order/orderList.vue";
|
||||
import SyncLogDrawer from "./SyncLogDrawer.vue";
|
||||
import { ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { retryWechatOrder } from "@/api/order";
|
||||
import { wechatSyncOptions, wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
|
||||
|
||||
defineOptions({ name: "wechatOrderList" });
|
||||
|
||||
const orderListRef = ref(null);
|
||||
const retryingSn = ref("");
|
||||
const logVisible = ref(false);
|
||||
const logOrderSn = ref("");
|
||||
|
||||
/** 展示当前订单所在微信支付流水的审计日志。 */
|
||||
function showLogs(row) {
|
||||
logOrderSn.value = row.sn;
|
||||
logVisible.value = true;
|
||||
}
|
||||
|
||||
/** 人工补报需要明确确认范围;同笔支付共用锁,已成功且内容未变时由后端跳过。 */
|
||||
async function retry(row) {
|
||||
if (retryingSn.value) return;
|
||||
try {
|
||||
const historyNotice = row.wechatSyncStatus === "UNCHECKED" ? "该订单没有本地上报记录,请先核查微信后台,确认需要补报后继续。" : "";
|
||||
await ElMessageBox.confirm(historyNotice + "将按该笔微信支付的全部关联订单归集补报。人工补报不受自动上报开关限制;已成功且内容未变化会跳过。", "补报微信发货信息", { type: "warning", confirmButtonText: "确认补报", cancelButtonText: "取消" });
|
||||
} catch (e) { return; }
|
||||
if (retryingSn.value) return;
|
||||
retryingSn.value = row.sn;
|
||||
try {
|
||||
const res = await retryWechatOrder(row.sn);
|
||||
if (!res.success) throw new Error(res.message || "补报失败");
|
||||
if (res.result === "SUCCESS") ElMessage.success("微信接口已受理发货信息");
|
||||
else if (res.result === "SKIPPED") ElMessage.success("相同发货信息已成功上报,本次已跳过");
|
||||
else {
|
||||
ElMessage.warning("本次未完成上报,请查看同步日志中的处理原因");
|
||||
showLogs(row);
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error("补报未完成,请刷新状态并查看日志后再处理");
|
||||
} finally {
|
||||
retryingSn.value = "";
|
||||
orderListRef.value?.reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -433,9 +433,15 @@
|
||||
<!-- 当选择完活动之后的其他回调 -->
|
||||
<span v-if="item.url.___type == 'marketing'">
|
||||
<span v-if="item.url.___promotion == 'SECKILL'"> 秒杀 </span>
|
||||
<span v-if="item.url.___promotion == 'FULL_DISCOUNT'">
|
||||
<span v-if="item.url.___promotion == 'FULL_DISCOUNT' || item.url.___promotion == 'FULL_MINUS'">
|
||||
满减
|
||||
</span>
|
||||
<span v-if="item.url.___promotion == 'FULL_RATE'">
|
||||
满折
|
||||
</span>
|
||||
<span v-if="item.url.___promotion == 'FULL_GIFT'">
|
||||
满赠
|
||||
</span>
|
||||
<span v-if="item.url.___promotion == 'PINTUAN'"> 拼团 </span>
|
||||
{{ item.url.title || item.url.goodsName }}</span>
|
||||
<!-- 当选择完活动之后的其他回调 -->
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<!--
|
||||
管理端-满减/满折/满赠活动详情只读视图(适配 1~3 级阶梯及满赠多赠品展示)
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<div class="full-discount-detail-page">
|
||||
<!-- 顶部标题与返回栏 -->
|
||||
@@ -5,7 +10,7 @@
|
||||
<div class="page-header-row">
|
||||
<div class="header-left">
|
||||
<div class="header-title">
|
||||
<span>满优惠活动详情</span>
|
||||
<span>{{ activityTypeTitle }}详情</span>
|
||||
<el-tag type="info" size="small" effect="plain" class="mode-tag">
|
||||
只读模式
|
||||
</el-tag>
|
||||
@@ -30,13 +35,17 @@
|
||||
<span class="font-semibold text-dark">{{ form.promotionName || "-" }}</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="活动类型">
|
||||
<el-tag size="small" :type="activityTagType">{{ activityTypeTitle }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="活动范围">
|
||||
<el-tag size="small" effect="light" :type="form.scopeType === 'ALL' ? 'success' : 'primary'">
|
||||
{{ form.scopeType === "ALL" ? "全品类通用" : "指定商品适用" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="活动时间" :span="2">
|
||||
<el-descriptions-item label="活动时间">
|
||||
<span class="font-mono text-dark">
|
||||
{{ form.startTime || "-" }} 至 {{ form.endTime || "-" }}
|
||||
</span>
|
||||
@@ -48,66 +57,49 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 优惠规则与赠品权益卡片 -->
|
||||
<!-- 阶梯优惠规则卡片 -->
|
||||
<el-card shadow="never" class="info-card mt_10">
|
||||
<div class="section-header">
|
||||
<span class="section-indicator"></span>
|
||||
<span class="section-title">优惠规则与权益</span>
|
||||
<span class="section-title">阶梯优惠规则</span>
|
||||
<span class="section-badge">共 {{ (form.ruleList || []).length }} 级阶梯(仅生效满足的最高一级)</span>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border class="custom-descriptions">
|
||||
<el-descriptions-item label="优惠门槛">
|
||||
<el-descriptions :column="1" border class="custom-descriptions">
|
||||
<el-descriptions-item
|
||||
v-for="(tier, idx) in form.ruleList || []"
|
||||
:key="idx"
|
||||
:label="`第 ${idx + 1} 级阶梯`"
|
||||
>
|
||||
<span class="money-highlight font-mono font-semibold">
|
||||
满 ¥ {{ Number(form.fullMoney || 0).toFixed(2) }}
|
||||
满 ¥ {{ Number(tier.fullMoney || 0).toFixed(2) }}
|
||||
</span>
|
||||
<span class="text-secondary ml_10" style="font-size: 12px">(达到当前金额可享优惠)</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="优惠形式">
|
||||
<template v-if="form.discountType === 'fullMinusFlag'">
|
||||
<el-tag size="small" effect="light" type="danger">减现金</el-tag>
|
||||
<template v-if="form.activityType === 'FULL_MINUS'">
|
||||
<el-tag size="small" effect="light" type="danger" class="ml_10">减现金</el-tag>
|
||||
<span class="money-highlight font-mono font-semibold ml_10">
|
||||
减 ¥ {{ Number(form.fullMinus || 0).toFixed(2) }}
|
||||
立减 ¥ {{ Number(tier.fullMinus || 0).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="form.discountType === 'fullRateFlag'">
|
||||
<el-tag size="small" effect="light" type="warning">打折</el-tag>
|
||||
<template v-else-if="form.activityType === 'FULL_RATE'">
|
||||
<el-tag size="small" effect="light" type="warning" class="ml_10">打折</el-tag>
|
||||
<span class="money-highlight font-mono font-semibold ml_10">
|
||||
打 {{ form.fullRate }} 折
|
||||
打 {{ tier.fullRate }} 折
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-secondary">无直接立减/打折</span>
|
||||
</template>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="附加赠送权益" :span="2">
|
||||
<div class="extra-rights-row">
|
||||
<el-tag :type="form.freeFreightFlag ? 'success' : 'info'" effect="plain" size="small">
|
||||
{{ form.freeFreightFlag ? "✓ 包邮(免运费)" : "不包邮" }}
|
||||
</el-tag>
|
||||
<el-tag :type="form.couponFlag ? 'success' : 'info'" effect="plain" size="small">
|
||||
{{ form.couponFlag ? "✓ 赠送优惠券" : "不送券" }}
|
||||
</el-tag>
|
||||
<el-tag :type="form.giftFlag ? 'success' : 'info'" effect="plain" size="small">
|
||||
{{ form.giftFlag ? "✓ 赠送赠品" : "不送赠品" }}
|
||||
</el-tag>
|
||||
<el-tag :type="form.pointFlag ? 'success' : 'info'" effect="plain" size="small">
|
||||
{{ form.pointFlag ? "✓ 赠送积分" : "不送积分" }}
|
||||
<template v-else-if="form.activityType === 'FULL_GIFT'">
|
||||
<el-tag size="small" effect="light" type="success" class="ml_10">满赠</el-tag>
|
||||
<div class="tier-gift-tags">
|
||||
<el-tag
|
||||
v-for="(gift, gIdx) in tier.giftItems || []"
|
||||
:key="gIdx"
|
||||
size="small"
|
||||
type="success"
|
||||
effect="plain"
|
||||
>
|
||||
{{ formatGiftName(gift) }} × {{ gift.num || 1 }}件
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="form.couponFlag" label="赠送优惠券" :span="2">
|
||||
<span class="font-semibold text-dark">{{ couponNameText }}</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="form.giftFlag" label="赠品信息" :span="2">
|
||||
<span class="font-semibold text-dark">{{ giftNameText }}</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="form.pointFlag" label="赠送积分数" :span="2">
|
||||
<span class="font-mono font-semibold text-dark">{{ form.point ?? 0 }} 积分</span>
|
||||
</template>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
@@ -186,7 +178,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getPlatformCouponList, getFullDiscountById } from "@/api/promotion";
|
||||
import { getFullDiscountById } from "@/api/promotion";
|
||||
import { getGoodsSkuData } from "@/api/goods";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
@@ -198,64 +190,83 @@ const appStore = useAppStore();
|
||||
|
||||
const id = ref(route.query.id);
|
||||
const form = ref({
|
||||
discountType: "fullMinusFlag",
|
||||
activityType: "FULL_MINUS",
|
||||
scopeType: "ALL",
|
||||
promotionGoodsList: [],
|
||||
ruleList: [],
|
||||
});
|
||||
const couponList = ref([]);
|
||||
const giftList = ref([]);
|
||||
|
||||
const couponNameText = computed(() => {
|
||||
if (!form.value.couponId) return "未指定优惠券";
|
||||
const found = couponList.value.find((c) => String(c.id) === String(form.value.couponId));
|
||||
return found ? found.couponName : `优惠券 ID: ${form.value.couponId}`;
|
||||
const activityTypeTitle = computed(() => {
|
||||
if (form.value.activityType === "FULL_RATE") return "满折活动";
|
||||
if (form.value.activityType === "FULL_GIFT") return "满赠活动";
|
||||
return "满减活动";
|
||||
});
|
||||
|
||||
const giftNameText = computed(() => {
|
||||
if (!form.value.giftId) return "未指定赠品";
|
||||
const found = giftList.value.find((g) => String(g.id) === String(form.value.giftId));
|
||||
return found ? found.goodsName : `赠品 ID: ${form.value.giftId}`;
|
||||
const activityTagType = computed(() => {
|
||||
if (form.value.activityType === "FULL_RATE") return "warning";
|
||||
if (form.value.activityType === "FULL_GIFT") return "success";
|
||||
return "danger";
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (id.value) {
|
||||
getDetail();
|
||||
}
|
||||
getCouponList();
|
||||
getGiftList();
|
||||
});
|
||||
|
||||
function formatGiftName(gift) {
|
||||
if (gift?.goodsName) return gift.goodsName;
|
||||
const found = giftList.value.find((g) => String(g.id) === String(gift?.skuId));
|
||||
return found ? found.goodsName : `赠品 ID: ${gift?.skuId || "-"}`;
|
||||
}
|
||||
|
||||
function getDetail() {
|
||||
getFullDiscountById(id.value).then((res) => {
|
||||
if (res && res.success && res.result) {
|
||||
let data = res.result;
|
||||
const data = res.result;
|
||||
if (data.scopeType !== "PORTION_GOODS") {
|
||||
data.promotionGoodsList = [];
|
||||
}
|
||||
if (!data.activityType) {
|
||||
if (data.fullRateFlag) {
|
||||
data.discountType = "fullRateFlag";
|
||||
data.activityType = "FULL_RATE";
|
||||
} else if (!data.fullMinusFlag && data.giftFlag) {
|
||||
data.activityType = "FULL_GIFT";
|
||||
} else {
|
||||
data.discountType = "fullMinusFlag";
|
||||
data.activityType = "FULL_MINUS";
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(data.ruleList) || !data.ruleList.length) {
|
||||
if (data.rulesJson) {
|
||||
try {
|
||||
data.ruleList = JSON.parse(data.rulesJson);
|
||||
} catch (e) {
|
||||
data.ruleList = [];
|
||||
}
|
||||
}
|
||||
if (!data.ruleList || !data.ruleList.length) {
|
||||
data.ruleList = [
|
||||
{
|
||||
fullMoney: data.fullMoney,
|
||||
fullMinus: data.fullMinus,
|
||||
fullRate: data.fullRate,
|
||||
giftItems: data.giftId
|
||||
? String(data.giftId)
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((skuId) => ({ skuId, goodsName: data.giftSkuName, num: 1 }))
|
||||
: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
form.value = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getCouponList() {
|
||||
const params = {
|
||||
pageSize: 50,
|
||||
pageNumber: 1,
|
||||
getType: "ACTIVITY",
|
||||
promotionStatus: "START",
|
||||
};
|
||||
getPlatformCouponList(params).then((res) => {
|
||||
if (res && res.success && res.result) {
|
||||
couponList.value = res.result.records || [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getGiftList() {
|
||||
const params = {
|
||||
pageSize: 50,
|
||||
@@ -357,11 +368,11 @@ function back() {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.extra-rights-row {
|
||||
.tier-gift-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<!--
|
||||
管理端-满额活动列表页(支持满减活动 FULL_MINUS、满折活动 FULL_RATE、满赠活动 FULL_GIFT)
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<div class="search full-cut">
|
||||
<el-card>
|
||||
@@ -55,16 +60,23 @@
|
||||
class="mt_10"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="promotionName" label="活动名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="startTime" label="开始时间" width="170" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="170" />
|
||||
<el-table-column prop="storeName" label="店铺名称" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column label="活动类型" min-width="80">
|
||||
<el-table-column prop="promotionName" label="活动名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="storeName" label="店铺名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="阶梯优惠规则" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ row.fullMinusFlag ? "满减" : "满折" }}</span>
|
||||
<span v-if="row">{{ formatRowRules(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活动状态" min-width="100">
|
||||
<el-table-column prop="startTime" label="开始时间" width="170" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="170" />
|
||||
<el-table-column label="活动类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row" size="small" effect="plain" :type="activityTagType(row)">
|
||||
{{ activityTypeText(row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活动状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row" :type="promotionStatusTagType(row.promotionStatus)">
|
||||
{{ promotionStatusText(row.promotionStatus) }}
|
||||
@@ -101,14 +113,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getFullDiscountList, updateFullDiscount } from "@/api/promotion.js";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 固定活动子类型(FULL_MINUS-满减,FULL_RATE-满折,FULL_GIFT-满赠)
|
||||
*/
|
||||
defaultActivityType: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 模板 ref(script 未使用,仅避免与 data 同名互相覆盖)
|
||||
const currentActivityType = computed(() => {
|
||||
if (props.defaultActivityType) {
|
||||
return props.defaultActivityType;
|
||||
}
|
||||
const routePath = String(route.path || "");
|
||||
const routeName = String(route.name || "");
|
||||
if (routePath.includes("full-rate") || routeName.includes("full-rate")) {
|
||||
return "FULL_RATE";
|
||||
}
|
||||
if (routePath.includes("full-gift") || routeName.includes("full-gift")) {
|
||||
return "FULL_GIFT";
|
||||
}
|
||||
return "FULL_MINUS";
|
||||
});
|
||||
|
||||
const searchFormRef = ref(null);
|
||||
const tableRef = ref(null);
|
||||
|
||||
@@ -120,9 +157,67 @@ const searchForm = reactive({
|
||||
pageSize: 20,
|
||||
sort: "createTime",
|
||||
order: "desc",
|
||||
promotionName: "",
|
||||
promotionStatus: "",
|
||||
});
|
||||
const data = ref([]);
|
||||
|
||||
watch(currentActivityType, () => {
|
||||
searchForm.pageNumber = 1;
|
||||
getDataList();
|
||||
});
|
||||
|
||||
function resolveRowType(row) {
|
||||
if (row?.activityType) return row.activityType;
|
||||
if (row?.fullRateFlag) return "FULL_RATE";
|
||||
if (!row?.fullMinusFlag && row?.giftFlag) return "FULL_GIFT";
|
||||
return "FULL_MINUS";
|
||||
}
|
||||
|
||||
function activityTypeText(row) {
|
||||
const type = resolveRowType(row);
|
||||
if (type === "FULL_RATE") return "满折";
|
||||
if (type === "FULL_GIFT") return "满赠";
|
||||
return "满减";
|
||||
}
|
||||
|
||||
function activityTagType(row) {
|
||||
const type = resolveRowType(row);
|
||||
if (type === "FULL_RATE") return "warning";
|
||||
if (type === "FULL_GIFT") return "success";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function formatRowRules(row) {
|
||||
if (row?.title) return row.title;
|
||||
if (row?.rulesJson) {
|
||||
try {
|
||||
const rules = JSON.parse(row.rulesJson);
|
||||
if (Array.isArray(rules) && rules.length) {
|
||||
const type = resolveRowType(row);
|
||||
return rules
|
||||
.map((r) => {
|
||||
if (type === "FULL_RATE") return `满${r.fullMoney}元打${r.fullRate}折`;
|
||||
if (type === "FULL_GIFT") {
|
||||
const count = Array.isArray(r.giftItems) ? r.giftItems.length : 1;
|
||||
return `满${r.fullMoney}元赠${count}种商品`;
|
||||
}
|
||||
return `满${r.fullMoney}元减${r.fullMinus}元`;
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (row?.fullMoney != null) {
|
||||
if (row.fullRateFlag) return `满${row.fullMoney}元打${row.fullRate}折`;
|
||||
if (row.giftFlag) return `满${row.fullMoney}元享赠品`;
|
||||
return `满${row.fullMoney}元减${row.fullMinus}元`;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function promotionStatusText(status) {
|
||||
const map = {
|
||||
NEW: "未开始",
|
||||
@@ -145,18 +240,16 @@ function init() {
|
||||
getDataList();
|
||||
}
|
||||
function openOrClose(row) {
|
||||
let name = "开启";
|
||||
if (row.promotionStatus === "NEW" || row.promotionStatus === "START") {
|
||||
name = "关闭";
|
||||
Modal.confirm({
|
||||
title: "提示",
|
||||
content: `确认${name}此活动吗?需要一定时间才能生效,请耐心等待`,
|
||||
content: "确认关闭此活动吗?需要一定时间才能生效,请耐心等待",
|
||||
loading: true,
|
||||
onOk: () => {
|
||||
updateFullDiscount(row.id).then((res) => {
|
||||
Modal.remove();
|
||||
if (res.success) {
|
||||
Message.success(`${name}成功`);
|
||||
Message.success("关闭成功");
|
||||
getDataList();
|
||||
}
|
||||
});
|
||||
@@ -185,7 +278,11 @@ function getDataList() {
|
||||
searchForm.startTime = null;
|
||||
searchForm.endTime = null;
|
||||
}
|
||||
getFullDiscountList(searchForm).then((res) => {
|
||||
const query = {
|
||||
...searchForm,
|
||||
activityType: currentActivityType.value,
|
||||
};
|
||||
getFullDiscountList(query).then((res) => {
|
||||
loading.value = false;
|
||||
if (res.success) {
|
||||
data.value = res.result.records;
|
||||
@@ -194,7 +291,10 @@ function getDataList() {
|
||||
});
|
||||
}
|
||||
function view(row) {
|
||||
router.push({ name: "full-discount-detail", query: { id: row.id } });
|
||||
router.push({
|
||||
name: "full-discount-detail",
|
||||
query: { id: row.id, activityType: resolveRowType(row) },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
14
manager/src/views/promotions/full-discount/full-gift.vue
Normal file
14
manager/src/views/promotions/full-discount/full-gift.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
管理端-满赠活动列表页
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<FullDiscountList default-activity-type="FULL_GIFT" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FullDiscountList from "./full-discount.vue";
|
||||
|
||||
defineOptions({ name: "manager-full-gift" });
|
||||
</script>
|
||||
14
manager/src/views/promotions/full-discount/full-rate.vue
Normal file
14
manager/src/views/promotions/full-discount/full-rate.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
管理端-满折活动列表页
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<FullDiscountList default-activity-type="FULL_RATE" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FullDiscountList from "./full-discount.vue";
|
||||
|
||||
defineOptions({ name: "manager-full-rate" });
|
||||
</script>
|
||||
@@ -57,7 +57,9 @@ const activityMetrics = ref([
|
||||
{ key: "couponActiveNum", label: "进行中优惠券" },
|
||||
{ key: "seckillActiveNum", label: "进行中秒杀" },
|
||||
{ key: "pintuanActiveNum", label: "进行中拼团" },
|
||||
{ key: "fullDiscountActiveNum", label: "进行中满减" },
|
||||
{ key: "fullMinusActiveNum", label: "进行中满减" },
|
||||
{ key: "fullRateActiveNum", label: "进行中满折" },
|
||||
{ key: "fullGiftActiveNum", label: "进行中满赠" },
|
||||
{ key: "kanjiaActiveNum", label: "进行中砍价商品" },
|
||||
{ key: "pointsGoodsActiveNum", label: "进行中积分商品" },
|
||||
]);
|
||||
@@ -128,7 +130,7 @@ h4 {
|
||||
}
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.metric-item {
|
||||
|
||||
@@ -6,6 +6,25 @@
|
||||
<span class="desc">配置买家端联系客服以及商家端登录客服跳转的路径</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">客服图片存储</el-divider>
|
||||
<el-form-item label="OSS Bucket" prop="imageOssBucket">
|
||||
<el-input v-model.trim="formValidate.imageOssBucket" placeholder="客服图片专用桶名称" />
|
||||
<span class="desc">使用独立于商品、头像的桶,并在 OSS 控制台设为公共读、私有写。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="OSS Endpoint" prop="imageOssEndpoint">
|
||||
<el-input v-model.trim="formValidate.imageOssEndpoint" placeholder="oss-cn-beijing.aliyuncs.com" />
|
||||
<span class="desc">填写公网节点域名,不含 https:// 或路径。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey ID" prop="imageOssAccessKeyId">
|
||||
<el-input v-model.trim="formValidate.imageOssAccessKeyId" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey Secret" prop="imageOssAccessKeySecret">
|
||||
<el-input v-model.trim="formValidate.imageOssAccessKeySecret" type="password" show-password autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<span class="desc">客户和客服的图片统一按会话所属店铺存储。支持 PNG、JPG、GIF、WebP,单张不超过10MB。四项均留空时无法发送图片,上传失败不会写入通用桶;持有图片链接即可查看。</span>
|
||||
</el-form-item>
|
||||
|
||||
<div class="label-btns">
|
||||
<el-button type="primary" @click="submit('formValidateRef')">保存</el-button>
|
||||
</div>
|
||||
@@ -26,7 +45,11 @@ const { proxy } = getCurrentInstance();
|
||||
// 只做属性合并的成组对象
|
||||
const ruleValidate = reactive({}); // 验证规则
|
||||
const formValidate = ref({ // 表单数据
|
||||
httpUrl: ""
|
||||
httpUrl: "",
|
||||
imageOssBucket: "",
|
||||
imageOssEndpoint: "",
|
||||
imageOssAccessKeyId: "",
|
||||
imageOssAccessKeySecret: "",
|
||||
});
|
||||
const result = ref("");
|
||||
|
||||
@@ -57,18 +80,30 @@ function setupSetting() {
|
||||
function init() {
|
||||
result.value = JSON.parse(props.res);
|
||||
|
||||
formValidate.value = { ...result.value };
|
||||
Object.keys(formValidate.value).forEach((item) => {
|
||||
ruleValidate[item] = [
|
||||
formValidate.value = { ...formValidate.value, ...result.value };
|
||||
ruleValidate.httpUrl = [
|
||||
{
|
||||
required: true,
|
||||
message: "请填写必填项",
|
||||
trigger: "blur",
|
||||
},
|
||||
];
|
||||
["imageOssBucket", "imageOssEndpoint", "imageOssAccessKeyId", "imageOssAccessKeySecret"].forEach((key) => {
|
||||
ruleValidate[key] = [{ validator: validateImageStorage, trigger: "blur" }];
|
||||
});
|
||||
}
|
||||
|
||||
// 四项配置可整体留空;填写任一项后必须完整,避免只保存节点或密钥导致上传不可用。
|
||||
function validateImageStorage(rule, value, callback) {
|
||||
const keys = ["imageOssBucket", "imageOssEndpoint", "imageOssAccessKeyId", "imageOssAccessKeySecret"];
|
||||
const values = keys.map((key) => (formValidate.value[key] || "").trim());
|
||||
if (values.some(Boolean) && !values.every(Boolean)) {
|
||||
callback(new Error("请完整填写客服图片 OSS 的四项配置"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
|
||||
74
manager/tests/wechatOrderSync.test.cjs
Normal file
74
manager/tests/wechatOrderSync.test.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 小程序订单筛选及异步竞态回归,隔离网络验证旧响应不覆盖新页面。
|
||||
* @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 vm = require('node:vm');
|
||||
const { parse } = require('@vue/compiler-sfc');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
/** 从实际 Vue 脚本加载逻辑,替换网络及 Vue 生命周期,不启动业务服务。 */
|
||||
function script(file) {
|
||||
return parse(fs.readFileSync(path.join(root,file),'utf8')).descriptor.scriptSetup.content.replace(/^import .*;\r?$/gm,'');
|
||||
}
|
||||
|
||||
/** 为并发请求提供可控完成顺序。 */
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes,no) => {resolve=yes; reject=no;});
|
||||
return {promise,resolve,reject};
|
||||
}
|
||||
|
||||
/** 保留表单值及计算属性的最小运行环境。 */
|
||||
function primitives() {
|
||||
return { ref:value=>({value}), computed:fn=>({get value(){return fn();}}), defineOptions:()=>{}, defineExpose:()=>{}, onMounted:()=>{} };
|
||||
}
|
||||
|
||||
test('专用列表固定支付范围、重置筛选,且旧列表和数量响应不能覆盖新结果', async()=>{
|
||||
const requests=[];
|
||||
const send=kind=>params=>{const wait=deferred();requests.push({kind,params:{...params},...wait});return wait.promise;};
|
||||
const context={...primitives(),defineProps:()=>({fixedClientType:'WECHAT_MP',fixedPaymentMethod:'WECHAT',wechatSync:true,allowExport:false}),useRoute:()=>({query:{clientType:'PC',paymentMethod:'WALLET'}}),watch:()=>{},wechatSyncOptions:[],API_Order:{getWechatOrderList:send('list'),getWechatOrderNum:send('count')}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(script('src/views/order/order/orderList.vue')+';globalThis.t={init,handleSearch,handleReset,changePage,orderStatusClick,searchForm,data,orderNumData,loading,loadError};',context);
|
||||
const t=context.t;
|
||||
t.init();
|
||||
t.searchForm.value.paymentMethod='WALLET';t.searchForm.value.wechatSyncStatus='FAILED';t.handleSearch();
|
||||
const newest=requests.filter(r=>r.kind==='list').at(-1);
|
||||
newest.resolve({success:true,result:{records:[{sn:'new'}],total:1}});
|
||||
requests.filter(r=>r.kind==='count').at(-1).resolve({success:true,result:{waitPayNum:5}});
|
||||
await tick();
|
||||
requests[0].resolve({success:true,result:{records:[{sn:'old'}],total:99}});
|
||||
requests[1].resolve({success:true,result:{waitPayNum:99}});
|
||||
await tick();
|
||||
assert.equal(t.data.value[0].sn,'new');assert.equal(t.orderNumData.value.waitPayNum,5);
|
||||
t.changePage(2);t.orderStatusClick('DELIVERED');t.handleReset();
|
||||
for(const r of requests){assert.equal(r.params.clientType,'WECHAT_MP');assert.equal(r.params.paymentMethod,'WECHAT');}
|
||||
assert.equal(t.searchForm.value.wechatSyncStatus,'');assert.equal(t.searchForm.value.paymentMethod,'WECHAT');
|
||||
requests.filter(r=>r.kind==='list').at(-1).reject(new Error('network'));
|
||||
await tick();assert.equal(t.loadError.value,true);assert.equal(t.data.value.length,0);assert.equal(t.loading.value,false);
|
||||
});
|
||||
|
||||
test('普通订单仍使用原接口且允许其他支付方式,不强制附加上报条件', async()=>{
|
||||
let sent;
|
||||
const context={...primitives(),defineProps:()=>({fixedClientType:'',fixedPaymentMethod:'',wechatSync:false,allowExport:true}),useRoute:()=>({query:{}}),watch:()=>{},wechatSyncOptions:[],API_Order:{getOrderList:params=>{sent={...params};return Promise.resolve({success:true,result:{records:[],total:0}});},getOrderNum:()=>Promise.resolve({success:true,result:{}})}};
|
||||
vm.createContext(context);vm.runInContext(script('src/views/order/order/orderList.vue')+';globalThis.t={init,searchForm};',context);
|
||||
context.t.searchForm.value.paymentMethod='WALLET';context.t.init();await tick();
|
||||
assert.equal(sent.paymentMethod,'WALLET');assert.equal(sent.clientType,undefined);assert.equal(sent.wechatSyncStatus,undefined);
|
||||
});
|
||||
|
||||
test('日志抽屉切换订单及关闭后,旧请求不会回填另一笔订单', async()=>{
|
||||
const props={modelValue:true,orderSn:'A'},requests=[];let changed;
|
||||
const context={...primitives(),defineProps:()=>props,defineEmits:()=>()=>{},watch:(source,callback)=>{changed=callback;callback();},getWechatOrderLogs:(sn,params)=>{const wait=deferred();requests.push({sn,...wait});return wait.promise;}};
|
||||
vm.createContext(context);vm.runInContext(script('src/views/order/wechat/SyncLogDrawer.vue')+';globalThis.t={records,loading,failed,load};',context);
|
||||
props.orderSn='B';changed();
|
||||
requests[1].resolve({success:true,result:{records:[{orderSn:'B'}],total:1}});await tick();
|
||||
requests[0].resolve({success:true,result:{records:[{orderSn:'A'}],total:1}});await tick();
|
||||
assert.equal(context.t.records.value[0].orderSn,'B');
|
||||
context.t.load();props.modelValue=false;changed();requests[2].resolve({success:true,result:{records:[{orderSn:'B'}],total:1}});await tick();
|
||||
assert.equal(context.t.records.value.length,0);assert.equal(context.t.loading.value,false);
|
||||
});
|
||||
@@ -45,3 +45,16 @@ test('搜索清空与空权限账号正确处理', () => {
|
||||
assert.deepEqual(searchNavigation(flattenNavigation([]), ''), []);
|
||||
assert.equal(findNavigationItem(items, 'unknown-detail'), null);
|
||||
});
|
||||
test('满减/满折/满赠详情页按 activityType 定位对应左侧菜单', () => {
|
||||
const promoMenus = [
|
||||
{ name: 'promotion', title: '营销', children: [{ title: '店铺活动', children: [
|
||||
{ name: 'full-discount', path: 'full-discount', title: '满减活动', component },
|
||||
{ name: 'store-full-rate', path: 'full-rate', title: '满折活动', component },
|
||||
{ name: 'store-full-gift', path: 'full-gift', title: '满赠活动', component },
|
||||
] }] },
|
||||
];
|
||||
const promoItems = flattenNavigation(promoMenus);
|
||||
assert.equal(findNavigationItem(promoItems, 'full-discount-detail', { activityType: 'FULL_GIFT' })?.name, 'store-full-gift');
|
||||
assert.equal(findNavigationItem(promoItems, 'full-discount-detail', { activityType: 'FULL_RATE' })?.name, 'store-full-rate');
|
||||
assert.equal(findNavigationItem(promoItems, 'full-discount-detail', { activityType: 'FULL_MINUS' })?.name, 'full-discount');
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
postRequest,
|
||||
putRequest,
|
||||
deleteRequest,
|
||||
postRequestWithNoForm
|
||||
postRequestWithNoForm,
|
||||
uploadFileRequest
|
||||
} from "@/libs/axios";
|
||||
import { downloadBlob } from "@/utils/downloadBlob";
|
||||
// 获取数据字典
|
||||
export const getDictData = "/dictData/getByType/";
|
||||
// Websocket
|
||||
@@ -222,6 +224,11 @@ export const queryExportStock = (params) => {
|
||||
return getRequest(`/goods/goods/queryExportStock`, params,'blob')
|
||||
}
|
||||
|
||||
// 下载商品库存导入模板
|
||||
export const downloadStockTemplate = () => {
|
||||
return getRequest(`/goods/goods/downloadStockTemplate`, {}, 'blob');
|
||||
};
|
||||
|
||||
// 上传商品库存列表
|
||||
export const importStockExcel = params => {
|
||||
return postRequestWithNoForm(`/goods/goods/importStockExcel`, params );
|
||||
@@ -381,3 +388,23 @@ export const downLoadGoods = () => {
|
||||
export const uploadGoodsExcel = params => {
|
||||
return postRequestWithNoForm(`/goods/import/import`, params );
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载商品批量导入(多规格)模板(v2,同步文件流)
|
||||
* @returns {Promise<void>} 成功时直接触发浏览器下载「商品导入模板.xlsx」
|
||||
*/
|
||||
export const downloadGoodsImportTemplate = async () => {
|
||||
const blob = await getRequest('/goods/import/downLoad/v2', {}, 'blob');
|
||||
downloadBlob(blob, '商品导入模板.xlsx');
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量导入商品(多规格)Excel(v2)
|
||||
* @param {File} file 待导入的 .xlsx 文件,multipart 字段名 files
|
||||
* @returns {Promise<any>} 返回 {success, result: {successCount, failCount, failList: [{goodsNo, rows, reason}]}}
|
||||
*/
|
||||
export const importGoodsExcel = file => {
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
return uploadFileRequest('/goods/import/import/v2', formData);
|
||||
};
|
||||
|
||||
@@ -39,10 +39,31 @@ export function flattenNavigation(menus) {
|
||||
));
|
||||
}
|
||||
|
||||
const FULL_DISCOUNT_DETAIL_MENUS = {
|
||||
FULL_GIFT: ["store-full-gift", "full-gift", "full-discount", "full-cut"],
|
||||
FULL_RATE: ["store-full-rate", "full-rate", "full-discount", "full-cut"],
|
||||
FULL_MINUS: ["full-discount", "full-cut"],
|
||||
};
|
||||
|
||||
function resolveDetailMenuCandidates(routeName, routeQuery = {}) {
|
||||
if (routeName === "full-discount-detail") {
|
||||
const type = routeQuery?.activityType;
|
||||
if (type && FULL_DISCOUNT_DETAIL_MENUS[type]) {
|
||||
return FULL_DISCOUNT_DETAIL_MENUS[type];
|
||||
}
|
||||
}
|
||||
return DETAIL_MENUS[routeName] || [];
|
||||
}
|
||||
|
||||
/** 优先精确匹配菜单,再匹配详情页业务入口;无授权入口时返回空,不沿用旧账号栏目。 */
|
||||
export function findNavigationItem(items, routeName) {
|
||||
return items.find((item) => item.name === routeName) ||
|
||||
(DETAIL_MENUS[routeName] || []).map((name) => items.find((item) => item.name === name)).find(Boolean) || null;
|
||||
export function findNavigationItem(items, routeName, routeQuery = {}) {
|
||||
return (
|
||||
items.find((item) => item.name === routeName) ||
|
||||
resolveDetailMenuCandidates(routeName, routeQuery)
|
||||
.map((name) => items.find((item) => item.name === name || item.path === name))
|
||||
.find(Boolean) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** 按菜单、分组和栏目检索,空格分隔的关键词须全部匹配。 */
|
||||
|
||||
@@ -30,6 +30,27 @@ const STATIC_ROUTE_NAMES = new Set([
|
||||
|
||||
util.dynamicRouteNames = [];
|
||||
|
||||
/**
|
||||
* 提取商家端会话必需的基础展示字段,避免超长经营范围或入驻详情导致 Cookie 超限
|
||||
*
|
||||
* @param {Object} raw 商家信息对象
|
||||
* @returns {Object} 核心会话档案对象
|
||||
*/
|
||||
util.pickSellerProfile = function (raw) {
|
||||
if (!raw) return {};
|
||||
return {
|
||||
id: raw.id,
|
||||
storeId: raw.storeId || raw.id,
|
||||
storeName: raw.storeName,
|
||||
storeLogo: raw.storeLogo,
|
||||
nickName: raw.nickName,
|
||||
storeDisable: raw.storeDisable,
|
||||
serviceScore: raw.serviceScore,
|
||||
deliveryScore: raw.deliveryScore,
|
||||
descriptionScore: raw.descriptionScore,
|
||||
};
|
||||
};
|
||||
|
||||
util.clearDynamicRoutes = function () {
|
||||
util.dynamicRouteNames.forEach((name) => {
|
||||
if (STATIC_ROUTE_NAMES.has(name)) {
|
||||
@@ -482,6 +503,15 @@ util.initRouterNode = function (routers, data) {
|
||||
}
|
||||
const menu = Object.assign({}, item);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
if (
|
||||
!hasChildren &&
|
||||
(menu.frontRoute === "promotion/full-discount/full-discount" ||
|
||||
menu.name === "full-discount" ||
|
||||
menu.name === "full-cut") &&
|
||||
menu.title === "满额活动"
|
||||
) {
|
||||
menu.title = "满减活动";
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
menu.children = [];
|
||||
|
||||
@@ -144,3 +144,40 @@
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='none' stroke='%23c0c4cc' stroke-width='2' stroke-linejoin='round'><path d='M8 36l8-20h32l8 20v12a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V36z'/><path d='M8 36h16a8 8 0 0 0 16 0h16'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* 商家端固定左侧 300px 菜单布局下的 Dialog 弹窗居中与边界保护 (Issue #IKUDFL)
|
||||
* 1. 扣除左侧 300px 菜单宽度,将弹窗容器限定在右侧内容工作区内,使得弹窗自然以工作区为中心水平居中,不再全屏居中;
|
||||
* 2. 弹窗最大宽度限制在 calc(100% - 40px),在中低分辨率(如 1440px/1600px)下左右预留各 20px 边距,避免大尺寸弹窗(如 1200px)溢出或贴紧菜单;
|
||||
* 3. 兼容未挂载至 body 的弹窗,当内容区内弹窗展开时提升 .single-page-con 层级,避免被菜单(z-index: 21)遮挡。
|
||||
*/
|
||||
body.seller-has-sidebar,
|
||||
.main {
|
||||
.el-overlay-dialog {
|
||||
left: 300px;
|
||||
width: calc(100vw - 300px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.el-overlay-dialog .el-dialog {
|
||||
max-width: calc(100% - 40px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端/极窄屏兜底:若宽度小于等于 768px 则恢复全屏居中 */
|
||||
@media (max-width: 768px) {
|
||||
body.seller-has-sidebar,
|
||||
.main {
|
||||
.el-overlay-dialog {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 未使用 append-to-body 挂载的局部弹窗,在打开时提升内容区层叠上下文,避免被菜单栏或顶栏压盖 */
|
||||
.single-page-con:has(.el-overlay:not([style*="display: none"])) {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,19 @@ const load = ref(false);
|
||||
const loading = computed(() => appStore.loading);
|
||||
const unreadMessageCount = computed(() => appStore.unreadMessageCount);
|
||||
const pageTagsList = computed(() => appStore.storeOpenedList);
|
||||
const currentPageTitle = computed(() => route.meta.title || pageTagsList.value.find((item) => item.name === route.name)?.title || '工作台');
|
||||
const currentPageTitle = computed(() => {
|
||||
if (route.name === "full-discount-detail") {
|
||||
const type = route.query?.activityType;
|
||||
const isDetail = Boolean(route.query?.id);
|
||||
if (type === "FULL_GIFT") return isDetail ? "满赠活动详情" : "添加满赠活动";
|
||||
if (type === "FULL_RATE") return isDetail ? "满折活动详情" : "添加满折活动";
|
||||
return isDetail ? "满减活动详情" : "添加满减活动";
|
||||
}
|
||||
return route.meta.title || pageTagsList.value.find((item) => item.name === route.name)?.title || '工作台';
|
||||
});
|
||||
const navigationItems = computed(() => flattenNavigation(appStore.navigationMenus || []));
|
||||
const currentPathParts = computed(() => {
|
||||
const matched = findNavigationItem(navigationItems.value, route.name);
|
||||
const matched = findNavigationItem(navigationItems.value, route.name, route.query);
|
||||
const rawParts = matched
|
||||
? [
|
||||
matched.topTitle,
|
||||
@@ -308,12 +317,14 @@ watch(() => appStore.tagsList, () => {
|
||||
appStore.setOpenedList();
|
||||
|
||||
onMounted(() => {
|
||||
document.body.classList.add("seller-has-sidebar");
|
||||
init();
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove("seller-has-sidebar");
|
||||
window.removeEventListener("resize", resize);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -62,6 +62,18 @@
|
||||
<el-table-column label="库存" width="100" align="right">
|
||||
<template #default="{ row }">{{ row.quantity || 0 }}</template>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -79,30 +91,97 @@
|
||||
</div>
|
||||
</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">
|
||||
<el-upload drag :before-upload="handleUpload" :show-file-list="false" accept=".xlsx,.xls">
|
||||
<div style="padding: 50px 0">
|
||||
<div style="font-size: 48px; color: #3399ff">↑</div>
|
||||
<h2>选择或拖拽文件上传</h2>
|
||||
<div style="padding: 40px 0">
|
||||
<el-icon :size="48" style="color: #3399ff"><UploadFilled /></el-icon>
|
||||
<h2 style="font-size: 16px; margin-top: 10px">选择或拖拽文件上传</h2>
|
||||
<p style="font-size: 12px; color: #909399; margin-top: 8px">支持 .xlsx / .xls 格式文件</p>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
<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>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useTable } from "@/utils/useTable";
|
||||
import { Download, UploadFilled } from "@element-plus/icons-vue";
|
||||
import { downloadBlob } from "@/utils/downloadBlob";
|
||||
import {
|
||||
downloadStockTemplate,
|
||||
getGoodsCategoryAll,
|
||||
getGoodsSkuListDataSeller,
|
||||
importStockExcel,
|
||||
queryExportStock,
|
||||
updateGoodsSkuStocks,
|
||||
} from "@/api/goods";
|
||||
import { Message } from "@/utils/message";
|
||||
|
||||
@@ -116,6 +195,43 @@ const spinShow = ref(false);
|
||||
const categoryList = ref([]);
|
||||
const category = ref([]);
|
||||
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(
|
||||
getGoodsSkuListDataSeller,
|
||||
@@ -193,6 +309,64 @@ function openImportStock() {
|
||||
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) {
|
||||
file.value = f;
|
||||
upload();
|
||||
@@ -250,4 +424,47 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<!--
|
||||
商品批量导入(多规格v2)弹窗
|
||||
用途:商家端商品列表页批量导入商品;支持下载导入模板、上传 .xlsx 文件、
|
||||
展示导入结果(成功/失败计数及失败明细),失败明细可下载为 CSV。
|
||||
@author Bulbasaur
|
||||
@since 2026-10-20
|
||||
-->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="批量导入商品"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<template v-if="!importResult">
|
||||
<p class="import-tip">
|
||||
请先下载导入模板,按模板格式填写商品信息(同一商品货号的多规格写为多行),仅支持 .xlsx 文件。
|
||||
</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
class="mb_10"
|
||||
:loading="templateLoading"
|
||||
@click="handleDownloadTemplate"
|
||||
>
|
||||
下载导入模板
|
||||
</el-button>
|
||||
<el-upload
|
||||
drag
|
||||
:show-file-list="false"
|
||||
accept=".xlsx"
|
||||
:disabled="importing"
|
||||
:before-upload="handleImportUpload"
|
||||
>
|
||||
<div class="el-upload__text" v-loading="importing">
|
||||
将文件拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p>
|
||||
导入完成:成功 {{ importResult.successCount || 0 }} 条,失败
|
||||
{{ importResult.failCount || 0 }} 条
|
||||
</p>
|
||||
<el-table
|
||||
v-if="failList.length"
|
||||
:data="failList"
|
||||
max-height="320"
|
||||
border
|
||||
class="mt_10"
|
||||
>
|
||||
<el-table-column label="商品序号" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.goodsNo || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rows" label="行范围" width="110" align="center" />
|
||||
<el-table-column
|
||||
prop="reason"
|
||||
label="失败原因"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="!importResult">
|
||||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button v-if="failList.length" @click="downloadFailDetail">
|
||||
下载失败明细
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleFinish">完成</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
downloadGoodsImportTemplate,
|
||||
importGoodsExcel,
|
||||
} from "@/api/goods";
|
||||
import { downloadBlob } from "@/utils/downloadBlob";
|
||||
import { Message } from "@/utils/message";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "imported"]);
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
const templateLoading = ref(false); // 模板下载 loading
|
||||
const importing = ref(false); // 导入请求 loading
|
||||
const importResult = ref(null); // 导入结果 {successCount, failCount, failList}
|
||||
const failList = computed(() => importResult.value?.failList || []);
|
||||
|
||||
// 下载导入模板
|
||||
function handleDownloadTemplate() {
|
||||
templateLoading.value = true;
|
||||
downloadGoodsImportTemplate()
|
||||
.then(() => {
|
||||
Message.success("模板下载成功");
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
templateLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 选择文件后立即上传(手动模式,before-upload 返回 false 阻止自动提交)
|
||||
function handleImportUpload(file) {
|
||||
if (!/\.xlsx$/i.test(file.name)) {
|
||||
Message.error("请上传 .xlsx 文件");
|
||||
return false;
|
||||
}
|
||||
importing.value = true;
|
||||
importGoodsExcel(file)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
importResult.value = res.result || {};
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
importing.value = false;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// CSV 字段转义:含逗号、引号或换行时用引号包裹
|
||||
function csvField(value) {
|
||||
const text = value == null ? "" : String(value);
|
||||
if (/[",\n\r]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// 将失败明细生成 CSV 落盘(加 BOM 保证 Excel 打开中文不乱码)
|
||||
function downloadFailDetail() {
|
||||
const header = "商品序号,行范围,失败原因";
|
||||
const lines = failList.value.map((item) =>
|
||||
[csvField(item.goodsNo || "-"), csvField(item.rows), csvField(item.reason)].join(",")
|
||||
);
|
||||
const csv = "\uFEFF" + [header, ...lines].join("\r\n");
|
||||
downloadBlob(csv, "商品导入失败明细.csv");
|
||||
}
|
||||
|
||||
// 完成:关闭弹窗并通知父页刷新列表
|
||||
function handleFinish() {
|
||||
dialogVisible.value = false;
|
||||
emit("imported");
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置状态,下次打开重新走上传流程
|
||||
function handleClosed() {
|
||||
importResult.value = null;
|
||||
importing.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.import-tip {
|
||||
margin: 0 0 10px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mb_10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mt_10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -140,6 +140,9 @@
|
||||
>
|
||||
批量下架
|
||||
</el-button>
|
||||
<el-button type="primary" @click="importDialogVisible = true">
|
||||
批量导入
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
@@ -252,6 +255,9 @@
|
||||
<el-button type="primary" :loading="submitLoading" @click="lower">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 商品批量导入(多规格v2) -->
|
||||
<GoodsImportDialog v-model="importDialogVisible" @imported="onGoodsImported" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -269,6 +275,7 @@ import {
|
||||
} from "@/api/goods";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
import { customRouterPush } from "@/utils/filters";
|
||||
import GoodsImportDialog from "./components/GoodsImportDialog.vue";
|
||||
|
||||
defineOptions({ name: "goods" });
|
||||
const emit = defineEmits(["edit-schedule", "delete-schedule"]);
|
||||
@@ -329,6 +336,7 @@ const currentStatus = ref(props.scheduledOnly ? props.scheduledStatus : "ALL");
|
||||
const goodsNumerData = ref({});
|
||||
const selectedRows = ref([]); // 选中的行数据
|
||||
const selectAll = ref(false); // 全选状态
|
||||
const importDialogVisible = ref(false); // 批量导入弹窗显示状态
|
||||
|
||||
const goodsStatusWithCount = computed(() => {
|
||||
return [
|
||||
@@ -525,6 +533,11 @@ function getNumberData() {
|
||||
}
|
||||
})
|
||||
}
|
||||
// 批量导入完成,刷新列表与状态数量
|
||||
function onGoodsImported() {
|
||||
getDataList();
|
||||
getNumberData();
|
||||
}
|
||||
// 编辑
|
||||
function edit(v) {
|
||||
id.value = v.id;
|
||||
|
||||
@@ -212,12 +212,13 @@ function afterLogin(res) {
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
setStore("saveLogin", saveLogin.value);
|
||||
const profile = util.pickSellerProfile(res.result);
|
||||
if (saveLogin.value) {
|
||||
Cookies.set("userInfoSeller", JSON.stringify(res.result), {
|
||||
Cookies.set("userInfoSeller", JSON.stringify(profile), {
|
||||
expires: 7,
|
||||
});
|
||||
} else {
|
||||
Cookies.set("userInfoSeller", JSON.stringify(res.result));
|
||||
Cookies.set("userInfoSeller", JSON.stringify(profile));
|
||||
}
|
||||
|
||||
util.initRouter();
|
||||
|
||||
@@ -44,7 +44,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const closedSections = ref([]);
|
||||
const allItems = computed(() => flattenNavigation(appStore.navigationMenus).filter((item) => router.hasRoute(item.name)));
|
||||
const activeItem = computed(() => findNavigationItem(allItems.value, route.name));
|
||||
const activeItem = computed(() => findNavigationItem(allItems.value, route.name, route.query));
|
||||
const sections = computed(() => appStore.navigationMenus.map((top) => ({
|
||||
...top, items: allItems.value.filter((item) => item.topName === top.name),
|
||||
})).filter((top) => top.items.length));
|
||||
|
||||
@@ -191,32 +191,152 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card v-if="showDelivery && orderPackages.length" class="mt_10 order-goods-card">
|
||||
<el-divider content-position="left">发货记录</el-divider>
|
||||
<el-table :data="orderPackages" border style="width: 100%">
|
||||
<el-table-column label="发货方式" width="120">
|
||||
<template #default="{ row }">{{ getFulfillmentModeLabel(row.fulfillmentMode) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="物流信息" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="isNoDeliveryPackage(row)">
|
||||
<span>无需配送</span>
|
||||
<span v-if="row.deliveryRemark" class="delivery-remark-text">({{ row.deliveryRemark }})</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ row.logisticsName || "-" }} / {{ row.logisticsNo || "-" }}
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="logisticsTime" label="发货时间" width="170" />
|
||||
<el-table-column label="商品明细" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div v-for="(item, idx) in row.orderPackageItemList || []" :key="idx" class="package-item-line">
|
||||
{{ item.goodsName }} × {{ item.deliverNumber }}
|
||||
<el-card v-if="showDelivery && orderPackages.length" class="mt_10 delivery-record-card">
|
||||
<div class="delivery-section-header">
|
||||
<div class="delivery-section-title-wrap">
|
||||
<span class="delivery-title-bar"></span>
|
||||
<span class="delivery-section-title">发货记录</span>
|
||||
<el-tag size="small" type="primary" effect="plain" round class="delivery-count-badge">
|
||||
共 {{ orderPackages.length }} 个包裹
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="delivery-package-list">
|
||||
<div
|
||||
v-for="(pkg, pkgIdx) in orderPackages"
|
||||
:key="pkg.packageNo || pkg.id || pkgIdx"
|
||||
class="delivery-package-item"
|
||||
>
|
||||
<!-- 包裹顶部信息栏 -->
|
||||
<div class="pkg-bar">
|
||||
<div class="pkg-bar-left">
|
||||
<div class="pkg-index-tag">
|
||||
<el-icon class="pkg-icon"><Box /></el-icon>
|
||||
<span>包裹 {{ orderPackages.length > 1 ? `#${pkgIdx + 1}` : '' }}</span>
|
||||
</div>
|
||||
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="getFulfillmentTagType(pkg.fulfillmentMode)"
|
||||
effect="light"
|
||||
class="pkg-mode-tag"
|
||||
>
|
||||
{{ getFulfillmentModeLabel(pkg.fulfillmentMode) }}
|
||||
</el-tag>
|
||||
|
||||
<div v-if="!isNoDeliveryPackage(pkg)" class="pkg-express-group">
|
||||
<span class="express-name">{{ pkg.logisticsName || "快递物流" }}</span>
|
||||
<span class="express-divider">/</span>
|
||||
<span class="express-no-label">运单号:</span>
|
||||
<span class="express-no">{{ pkg.logisticsNo || "-" }}</span>
|
||||
<el-tooltip content="复制运单号" placement="top" v-if="pkg.logisticsNo">
|
||||
<span
|
||||
class="copy-icon-btn"
|
||||
@click="copyText(pkg.logisticsNo, '运单号')"
|
||||
>
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-else class="pkg-no-express-group">
|
||||
<span class="no-express-text">无需物流配送</span>
|
||||
<span v-if="pkg.deliveryRemark" class="no-express-remark">(备注:{{ pkg.deliveryRemark }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pkg-bar-right">
|
||||
<div v-if="pkg.logisticsTime" class="pkg-time">
|
||||
<el-icon class="clock-icon" style="margin-right: 4px;"><Clock /></el-icon>
|
||||
<span>发货时间:{{ pkg.logisticsTime }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="!isNoDeliveryPackage(pkg) && pkg.logisticsNo"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="pkg-track-btn"
|
||||
@click="checkLogistics"
|
||||
>
|
||||
<el-icon style="margin-right: 3px;"><Van /></el-icon>查看物流
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 包裹内商品明细 -->
|
||||
<div class="pkg-goods-container">
|
||||
<div
|
||||
v-for="(item, itemIdx) in pkg.orderPackageItemList || []"
|
||||
:key="item.orderItemSn || item.id || itemIdx"
|
||||
class="pkg-goods-row"
|
||||
>
|
||||
<!-- 商品缩略图 -->
|
||||
<div class="pkg-goods-img-box">
|
||||
<img
|
||||
:src="getPackageItemThumb(item)"
|
||||
class="pkg-goods-img"
|
||||
alt="商品图片"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 商品详情 -->
|
||||
<div class="pkg-goods-detail">
|
||||
<div class="pkg-goods-title" :title="item.goodsName">
|
||||
{{ item.goodsName }}
|
||||
</div>
|
||||
<div v-if="getPackageItemSpecsText(item)" class="pkg-goods-specs">
|
||||
{{ getPackageItemSpecsText(item) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发货数量 -->
|
||||
<div class="pkg-goods-num-col">
|
||||
<span class="pkg-num-label">发货数量</span>
|
||||
<span class="pkg-num-val">× {{ item.deliverNumber || 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物流信息与动态跟踪栏 -->
|
||||
<div class="pkg-logistics-footer">
|
||||
<template v-if="!isNoDeliveryPackage(pkg)">
|
||||
<!-- 若已有物流轨迹节点 -->
|
||||
<div v-if="hasPackageTraces(pkg)" class="pkg-trace-content">
|
||||
<el-tag size="small" type="success" effect="light" class="trace-status-tag">最新轨迹</el-tag>
|
||||
<span class="trace-time">{{ getLatestTrace(pkg).AcceptTime || getLatestTrace(pkg).acceptTime }}</span>
|
||||
<span class="trace-desc" :title="getLatestTrace(pkg).AcceptStation || getLatestTrace(pkg).remark">
|
||||
{{ getLatestTrace(pkg).AcceptStation || getLatestTrace(pkg).remark }}
|
||||
</span>
|
||||
<el-button type="primary" link size="small" class="trace-more-btn" @click="checkLogistics">
|
||||
查看完整轨迹
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 暂无第三方轨迹更新时 -->
|
||||
<div v-else class="pkg-trace-content">
|
||||
<el-tag size="small" type="info" effect="light" class="trace-status-tag">物流动态</el-tag>
|
||||
<span class="trace-desc">
|
||||
已生成 {{ pkg.logisticsName || "快递" }} 运单({{ pkg.logisticsNo }}),等待快递网点揽收更新
|
||||
</span>
|
||||
<el-button type="primary" link size="small" class="trace-more-btn" @click="checkLogistics">
|
||||
查看物流
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 无需配送 -->
|
||||
<template v-else>
|
||||
<div class="pkg-trace-content">
|
||||
<el-tag size="small" type="warning" effect="light" class="trace-status-tag">配送说明</el-tag>
|
||||
<span class="trace-desc">
|
||||
{{ pkg.deliveryRemark ? `配送说明:${pkg.deliveryRemark}` : "无需物流配送,商家已完成线上/线下交付" }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="mt_10 order-goods-card">
|
||||
<div v-loading="loading" class="order-fit-wrap">
|
||||
@@ -246,6 +366,7 @@
|
||||
<img alt="商品图片" :src="row.image" class="order-goods-img" />
|
||||
<div class="order-goods-meta">
|
||||
<div class="div-zoom">
|
||||
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" style="margin-right: 5px;">赠品</el-tag>
|
||||
<a class="link-text" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a>
|
||||
</div>
|
||||
<span v-for="(item, key) in parseOrderItemSpecs(row.specs)" :key="key">
|
||||
@@ -438,7 +559,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="fulfillModal" title="订单发货" width="1200px">
|
||||
<el-dialog v-model="fulfillModal" title="订单发货" width="1200px" append-to-body>
|
||||
<div v-if="showFulfillModeSelector" class="fulfill-mode-selector">
|
||||
<el-radio-group v-model="fulfillForm.fulfillmentMode">
|
||||
<el-radio v-if="allowOperation.fulfillWhole" label="WHOLE">整单发货</el-radio>
|
||||
@@ -497,7 +618,10 @@
|
||||
<div class="part-delivery-goods">
|
||||
<img :src="row.image" class="part-delivery-goods-img" alt="" />
|
||||
<div class="part-delivery-goods-info">
|
||||
<div class="part-delivery-goods-name">{{ row.goodsName }}</div>
|
||||
<div class="part-delivery-goods-name">
|
||||
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" style="margin-right: 5px;">赠品</el-tag>
|
||||
{{ row.goodsName }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)"
|
||||
:key="specKey"
|
||||
@@ -546,7 +670,10 @@
|
||||
<div class="part-delivery-goods">
|
||||
<img :src="row.image" class="part-delivery-goods-img" alt="" />
|
||||
<div class="part-delivery-goods-info">
|
||||
<div class="part-delivery-goods-name">{{ row.goodsName }}</div>
|
||||
<div class="part-delivery-goods-name">
|
||||
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" style="margin-right: 5px;">赠品</el-tag>
|
||||
{{ row.goodsName }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)"
|
||||
:key="specKey"
|
||||
@@ -849,6 +976,7 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
|
||||
import { Box, DocumentCopy, Clock, Van } from "@element-plus/icons-vue";
|
||||
import * as API_Order from "@/api/order";
|
||||
import * as API_Logistics from "@/api/logistics";
|
||||
import * as RegExp from "@/libs/RegExp.js";
|
||||
@@ -1120,22 +1248,33 @@ onBeforeRouteLeave((to, from, next) => {
|
||||
function lineCardKeyRows(line) {
|
||||
return line.cardKeys || [];
|
||||
}
|
||||
function copySn() {
|
||||
const text = orderInfo.value?.order?.sn || "";
|
||||
function copyText(text, label = "内容") {
|
||||
if (!text) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
Message.success("订单号已复制");
|
||||
navigator.clipboard.writeText(String(text)).then(() => {
|
||||
Message.success(`${label}已复制`);
|
||||
}).catch(() => {
|
||||
fallbackCopyText(String(text), label);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyText(String(text), label);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopyText(text, label = "内容") {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
Message.success("订单号已复制");
|
||||
Message.success(`${label}已复制`);
|
||||
}
|
||||
|
||||
function copySn() {
|
||||
copyText(orderInfo.value?.order?.sn, "订单号");
|
||||
}
|
||||
function copyCardKey(row) {
|
||||
const text = `卡号:${row.cardNo || ""}\n卡密:${row.cardSecret || ""}`;
|
||||
@@ -1153,10 +1292,15 @@ function copyCardKey(row) {
|
||||
Message.success("已复制到剪贴板");
|
||||
}
|
||||
}
|
||||
function isGiftItem(row) {
|
||||
if (!row) return false;
|
||||
return row.promotionType === 'GIFT' || (Number(row.flowPrice) === 0 && row.promotionType === 'GIFT');
|
||||
}
|
||||
function getPromotionText(row) {
|
||||
let resultText = "";
|
||||
if (row && row.promotionType) {
|
||||
const type = row.promotionType.split(",");
|
||||
if (type.indexOf("GIFT") != -1) resultText += "满赠赠品 ";
|
||||
if (type.indexOf("PINTUAN") != -1) resultText += "拼团 ";
|
||||
if (type.indexOf("SECKILL") != -1) resultText += "秒杀 ";
|
||||
if (type.indexOf("COUPON") != -1) resultText += "优惠券 ";
|
||||
@@ -1219,6 +1363,40 @@ function isPackageItemMatch(orderItem, pkgItem) {
|
||||
.map(String);
|
||||
return pkgKeys.some((key) => orderKeys.includes(key));
|
||||
}
|
||||
function getPackageItemThumb(item) {
|
||||
if (item && item.thumbnail) {
|
||||
return item.thumbnail;
|
||||
}
|
||||
const match = (data.value || []).find((row) => isPackageItemMatch(row, item));
|
||||
return match?.image || match?.goodsImage || "";
|
||||
}
|
||||
function getPackageItemSpecsText(item) {
|
||||
const match = (data.value || []).find((row) => isPackageItemMatch(row, item));
|
||||
if (!match || !match.specs) return "";
|
||||
try {
|
||||
const specsObj = parseOrderItemSpecs(match.specs);
|
||||
if (!specsObj || typeof specsObj !== "object") return "";
|
||||
return Object.entries(specsObj)
|
||||
.filter(([k]) => k !== "images")
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" | ");
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function getFulfillmentTagType(mode) {
|
||||
if (mode === "NO_DELIVERY") return "info";
|
||||
if (mode === "SPLIT") return "warning";
|
||||
return "primary";
|
||||
}
|
||||
function hasPackageTraces(pkg) {
|
||||
return Boolean(pkg && pkg.traces && Array.isArray(pkg.traces.traces) && pkg.traces.traces.length > 0);
|
||||
}
|
||||
function getLatestTrace(pkg) {
|
||||
if (!hasPackageTraces(pkg)) return {};
|
||||
const list = pkg.traces.traces;
|
||||
return list[0] || {};
|
||||
}
|
||||
function getRemainingDeliveryNum(item) {
|
||||
if (item != null && item.remainDeliverNumber != null && item.remainDeliverNumber !== "") {
|
||||
const remain = Number(item.remainDeliverNumber);
|
||||
@@ -1968,6 +2146,324 @@ function editAddressSubmit () {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.delivery-record-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.delivery-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
|
||||
.delivery-section-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delivery-title-bar {
|
||||
width: 3px;
|
||||
height: 15px;
|
||||
background: #409eff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.delivery-section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.delivery-count-badge {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
height: 22px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.delivery-package-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.delivery-package-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.pkg-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
.pkg-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pkg-index-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
|
||||
.pkg-icon {
|
||||
font-size: 15px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-mode-tag {
|
||||
font-size: 12px;
|
||||
height: 22px;
|
||||
line-height: 20px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.pkg-express-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
background: #f1f5f9;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
.express-name {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.express-divider {
|
||||
margin: 0 6px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.express-no-label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.express-no {
|
||||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
color: #0284c7;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.copy-icon-btn {
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #3b82f6;
|
||||
transition: transform 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-no-express-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
|
||||
.no-express-text {
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.no-express-remark {
|
||||
color: #94a3b8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
|
||||
.pkg-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
|
||||
.clock-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-track-btn {
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-goods-container {
|
||||
padding: 6px 16px;
|
||||
background: #ffffff;
|
||||
|
||||
.pkg-goods-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pkg-goods-img-box {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.pkg-goods-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-goods-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 12px;
|
||||
margin-right: 16px;
|
||||
|
||||
.pkg-goods-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pkg-goods-specs {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #edf2f7;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-goods-num-col {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
|
||||
.pkg-num-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pkg-num-val {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-logistics-footer {
|
||||
padding: 9px 16px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
.pkg-trace-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
|
||||
.trace-status-tag {
|
||||
font-size: 11px;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.trace-time {
|
||||
color: #64748b;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
.trace-desc {
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.trace-more-btn {
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.package-item-line {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<el-card>
|
||||
<div class="operation padding-row" v-if="getType !== 'ACTIVITY'">
|
||||
<el-button type="primary" @click="add">添加优惠券</el-button>
|
||||
<el-button class="activity-coupon-button" @click="addActivityCoupon">添加活动优惠券</el-button>
|
||||
<el-button v-if="false" class="activity-coupon-button" @click="addActivityCoupon">添加活动优惠券</el-button>
|
||||
<el-button @click="delAll">批量关闭</el-button>
|
||||
<el-button type="warning" @click="receivePage()">优惠券领取记录</el-button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,8 @@
|
||||
<!--
|
||||
满额活动列表页(支持满减活动 FULL_MINUS、满折活动 FULL_RATE、满赠活动 FULL_GIFT)
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<div class="search full-cut">
|
||||
<el-card>
|
||||
@@ -48,7 +53,7 @@
|
||||
|
||||
<el-card>
|
||||
<div class="operation padding-row">
|
||||
<el-button type="primary" @click="add">新增</el-button>
|
||||
<el-button type="primary" @click="add">{{ addBtnText }}</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
ref="table"
|
||||
@@ -58,16 +63,27 @@
|
||||
class="mt_10"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="promotionName" label="活动名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="startTime" label="开始时间" width="170" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="170" />
|
||||
<el-table-column prop="storeName" label="店铺名称" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column label="活动类型" min-width="80">
|
||||
<el-table-column prop="promotionName" label="活动名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="阶梯优惠规则" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ row.fullMinusFlag ? "满减" : "满折" }}</span>
|
||||
<span v-if="row">{{ formatRowRules(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活动状态" min-width="100">
|
||||
<el-table-column prop="startTime" label="开始时间" width="170" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="170" />
|
||||
<el-table-column label="适用范围" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ row.scopeType === "ALL" ? "全品类" : "指定商品" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活动类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row" size="small" effect="plain" :type="activityTagType(row)">
|
||||
{{ activityTypeText(row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活动状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row" :type="promotionStatusTagType(row.promotionStatus)">
|
||||
{{ promotionStatusText(row.promotionStatus) }}
|
||||
@@ -106,13 +122,45 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getFullDiscountList, updateFullDiscount } from "@/api/promotion.js";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 固定活动子类型(FULL_MINUS-满减,FULL_RATE-满折,FULL_GIFT-满赠)
|
||||
*/
|
||||
defaultActivityType: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const currentActivityType = computed(() => {
|
||||
if (props.defaultActivityType) {
|
||||
return props.defaultActivityType;
|
||||
}
|
||||
const routePath = String(route.path || "");
|
||||
const routeName = String(route.name || "");
|
||||
if (routePath.includes("full-rate") || routeName.includes("full-rate")) {
|
||||
return "FULL_RATE";
|
||||
}
|
||||
if (routePath.includes("full-gift") || routeName.includes("full-gift")) {
|
||||
return "FULL_GIFT";
|
||||
}
|
||||
return "FULL_MINUS";
|
||||
});
|
||||
|
||||
const addBtnText = computed(() => {
|
||||
if (currentActivityType.value === "FULL_RATE") return "新增满折活动";
|
||||
if (currentActivityType.value === "FULL_GIFT") return "新增满赠活动";
|
||||
return "新增满减活动";
|
||||
});
|
||||
|
||||
const selectDate = ref([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
@@ -121,16 +169,72 @@ const searchForm = reactive({
|
||||
pageSize: 20,
|
||||
sort: "createTime",
|
||||
order: "desc",
|
||||
promotionName: "",
|
||||
promotionStatus: "",
|
||||
});
|
||||
const data = ref([]);
|
||||
|
||||
// searchFormRef:模板 ref 改名避免与 searchForm 变量互相覆盖
|
||||
const searchFormRef = ref(null);
|
||||
|
||||
watch(currentActivityType, () => {
|
||||
searchForm.pageNumber = 1;
|
||||
getDataList();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
function resolveRowType(row) {
|
||||
if (row?.activityType) return row.activityType;
|
||||
if (row?.fullRateFlag) return "FULL_RATE";
|
||||
if (!row?.fullMinusFlag && row?.giftFlag) return "FULL_GIFT";
|
||||
return "FULL_MINUS";
|
||||
}
|
||||
|
||||
function activityTypeText(row) {
|
||||
const type = resolveRowType(row);
|
||||
if (type === "FULL_RATE") return "满折";
|
||||
if (type === "FULL_GIFT") return "满赠";
|
||||
return "满减";
|
||||
}
|
||||
|
||||
function activityTagType(row) {
|
||||
const type = resolveRowType(row);
|
||||
if (type === "FULL_RATE") return "warning";
|
||||
if (type === "FULL_GIFT") return "success";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function formatRowRules(row) {
|
||||
if (row?.title) return row.title;
|
||||
if (row?.rulesJson) {
|
||||
try {
|
||||
const rules = JSON.parse(row.rulesJson);
|
||||
if (Array.isArray(rules) && rules.length) {
|
||||
const type = resolveRowType(row);
|
||||
return rules
|
||||
.map((r) => {
|
||||
if (type === "FULL_RATE") return `满${r.fullMoney}元打${r.fullRate}折`;
|
||||
if (type === "FULL_GIFT") {
|
||||
const count = Array.isArray(r.giftItems) ? r.giftItems.length : 1;
|
||||
return `满${r.fullMoney}元赠${count}种商品`;
|
||||
}
|
||||
return `满${r.fullMoney}元减${r.fullMinus}元`;
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (row?.fullMoney != null) {
|
||||
if (row.fullRateFlag) return `满${row.fullMoney}元打${row.fullRate}折`;
|
||||
if (row.giftFlag) return `满${row.fullMoney}元享赠品`;
|
||||
return `满${row.fullMoney}元减${row.fullMinus}元`;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function promotionStatusText(status) {
|
||||
const map = {
|
||||
NEW: "未开始",
|
||||
@@ -156,18 +260,16 @@ function init() {
|
||||
}
|
||||
|
||||
function openOrClose(row) {
|
||||
let name = "开启";
|
||||
if (row.promotionStatus === "NEW" || row.promotionStatus === "START") {
|
||||
name = "关闭";
|
||||
Modal.confirm({
|
||||
title: "提示",
|
||||
content: `确认${name}此活动吗?需要一定时间才能生效,请耐心等待`,
|
||||
content: "确认关闭此活动吗?需要一定时间才能生效,请耐心等待",
|
||||
loading: true,
|
||||
onOk: () => {
|
||||
updateFullDiscount(row.id).then((res) => {
|
||||
Modal.remove();
|
||||
if (res.success) {
|
||||
Message.success(`${name}成功`);
|
||||
Message.success("关闭成功");
|
||||
getDataList();
|
||||
}
|
||||
});
|
||||
@@ -200,7 +302,11 @@ function getDataList() {
|
||||
searchForm.startTime = null;
|
||||
searchForm.endTime = null;
|
||||
}
|
||||
getFullDiscountList(searchForm).then((res) => {
|
||||
const query = {
|
||||
...searchForm,
|
||||
activityType: currentActivityType.value,
|
||||
};
|
||||
getFullDiscountList(query).then((res) => {
|
||||
loading.value = false;
|
||||
if (res.success) {
|
||||
data.value = res.result.records;
|
||||
@@ -210,11 +316,17 @@ function getDataList() {
|
||||
}
|
||||
|
||||
function add() {
|
||||
router.push({ name: "full-discount-detail" });
|
||||
router.push({
|
||||
name: "full-discount-detail",
|
||||
query: { activityType: currentActivityType.value },
|
||||
});
|
||||
}
|
||||
|
||||
function goDetail(row) {
|
||||
router.push({ name: "full-discount-detail", query: { id: row.id } });
|
||||
router.push({
|
||||
name: "full-discount-detail",
|
||||
query: { id: row.id, activityType: resolveRowType(row) },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
14
seller/src/views/promotion/full-discount/full-gift.vue
Normal file
14
seller/src/views/promotion/full-discount/full-gift.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
商家端-满赠活动列表页
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<FullDiscountList default-activity-type="FULL_GIFT" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FullDiscountList from "./full-discount.vue";
|
||||
|
||||
defineOptions({ name: "store-full-gift" });
|
||||
</script>
|
||||
14
seller/src/views/promotion/full-discount/full-rate.vue
Normal file
14
seller/src/views/promotion/full-discount/full-rate.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
商家端-满折活动列表页
|
||||
@author Bulbasaur
|
||||
@since 2026-11-09
|
||||
-->
|
||||
<template>
|
||||
<FullDiscountList default-activity-type="FULL_RATE" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FullDiscountList from "./full-discount.vue";
|
||||
|
||||
defineOptions({ name: "store-full-rate" });
|
||||
</script>
|
||||
@@ -48,6 +48,7 @@ import { validateMobile } from "@/libs/validate";
|
||||
import uploadPicThumb from "@/views/my-components/lili/upload-pic-thumb";
|
||||
import multipleMap from "@/views/my-components/map/multiple-map";
|
||||
import Cookies from "js-cookie";
|
||||
import util from "@/libs/util.js";
|
||||
import { Message } from "@/utils/message";
|
||||
|
||||
defineOptions({ name: "shopSetting" });
|
||||
@@ -158,7 +159,7 @@ function getShopInfo() {
|
||||
form.value = res.result;
|
||||
storeName.value = res.result.storeName;
|
||||
form.value.storeCenter = res.result.storeCenter;
|
||||
Cookies.set("userInfoSeller", JSON.stringify(res.result));
|
||||
Cookies.set("userInfoSeller", JSON.stringify(util.pickSellerProfile(res.result)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,9 +433,15 @@
|
||||
<!-- 当选择完活动之后的其他回调 -->
|
||||
<span v-if="item.url.___type == 'marketing'">
|
||||
<span v-if="item.url.___promotion == 'SECKILL'"> 秒杀 </span>
|
||||
<span v-if="item.url.___promotion == 'FULL_DISCOUNT'">
|
||||
<span v-if="item.url.___promotion == 'FULL_DISCOUNT' || item.url.___promotion == 'FULL_MINUS'">
|
||||
满减
|
||||
</span>
|
||||
<span v-if="item.url.___promotion == 'FULL_RATE'">
|
||||
满折
|
||||
</span>
|
||||
<span v-if="item.url.___promotion == 'FULL_GIFT'">
|
||||
满赠
|
||||
</span>
|
||||
<span v-if="item.url.___promotion == 'PINTUAN'"> 拼团 </span>
|
||||
{{ item.url.title || item.url.goodsName }}</span>
|
||||
<!-- 当选择完活动之后的其他回调 -->
|
||||
|
||||
Reference in New Issue
Block a user