mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-12 20:08:59 +08:00
feat(promotion,stock): 商家出库单管理、库存流水查询及优惠券礼包推广二维码
This commit is contained in:
@@ -168,6 +168,8 @@ lilishop-ui
|
||||
|
||||
| 时间 | 功能模块 | 完成内容 |
|
||||
| :--- | :--- | :--- |
|
||||
| 2026-11-12 | 优惠券礼包 | 支持券礼包活动创建、多券组合打包、管理端 H5 推广二维码生成及链接复制。 |
|
||||
| 2026-11-12 | 出库单 / 库存流水 | 支持商家端出库单管理(录入、审核与作废)及商品库存流水台账。 |
|
||||
| 2026-11-06 | 提货卡 | 支持提货卡活动与候选商品配置、制卡发卡、兑换核销及使用记录管理。 |
|
||||
| 2026-11-05 | 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
|
||||
| 2026-11-04 | 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
|
||||
|
||||
@@ -147,6 +147,11 @@ export const getCouponActivityList = params => {
|
||||
return getRequest("/promotion/couponActivity", params);
|
||||
};
|
||||
// 关闭优惠券活动
|
||||
// 获取优惠券礼包H5推广二维码及链接
|
||||
export const getCouponPackageQrCode = (id, params) => {
|
||||
return getRequest(`/promotion/couponActivity/package/qrcode/${id}`, params);
|
||||
};
|
||||
|
||||
export const closeActivity = id => {
|
||||
return deleteRequest(`/promotion/couponActivity/${id}`);
|
||||
};
|
||||
|
||||
@@ -202,7 +202,11 @@ const form = reactive({
|
||||
watch(
|
||||
() => route.query.couponActivityType,
|
||||
(type) => {
|
||||
if (["REGISTERED", "SPECIFY", "AUTO_COUPON"].includes(type)) {
|
||||
if (["REGISTERED", "SPECIFY", "AUTO_COUPON", "COUPON_PACKAGE"].includes(type)) {
|
||||
if (type === "COUPON_PACKAGE") {
|
||||
form.couponFrequencyEnum = "DAY";
|
||||
form.activityScope = "ALL";
|
||||
}
|
||||
form.couponActivityType = type;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<el-button type="primary" plain @click="addRegistered">注册赠券</el-button>
|
||||
<el-button type="primary" plain @click="addSpecify">精准发券</el-button>
|
||||
<el-button type="primary" plain @click="addAutoCoupon">自动赠券</el-button>
|
||||
<el-button type="success" plain @click="addCouponPackage">创建券礼包</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
@@ -47,6 +48,8 @@
|
||||
<template #default="{ row }">
|
||||
<template v-if="row">
|
||||
<a class="link-text" @click="info(row)">查看</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="openQrCode(row)">推广码</a>
|
||||
<template
|
||||
v-if="(!checked && row.promotionStatus === 'START') || row.promotionStatus === 'NEW'"
|
||||
>
|
||||
@@ -70,6 +73,22 @@
|
||||
@size-change="changePageSize"
|
||||
/>
|
||||
</div>
|
||||
<!-- 推广二维码弹窗 -->
|
||||
<el-dialog v-model="qrCodeDialogVisible" title="券礼包 H5 推广二维码" width="440px" align-center>
|
||||
<div style="text-align: center; padding: 10px 0;">
|
||||
<div v-if="qrCodeLoading" style="padding: 30px 0;">
|
||||
<div style="color: #999;">正在生成推广二维码...</div>
|
||||
</div>
|
||||
<div v-else-if="qrCodeInfo.qrCodeBase64">
|
||||
<img :src="qrCodeInfo.qrCodeBase64" alt="H5推广二维码" style="width: 220px; height: 220px; border: 1px solid #ebeef5; padding: 6px; border-radius: 8px;" />
|
||||
<div style="margin-top: 15px; font-size: 13px; color: #555; word-break: break-all; text-align: left; background: #f8f9fa; padding: 10px; border-radius: 6px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">H5 访问链接:</div>
|
||||
<div style="color: #409eff;">{{ qrCodeInfo.h5Url }}</div>
|
||||
</div>
|
||||
<el-button type="primary" style="margin-top: 15px; width: 100%;" @click="copyH5Url">复制 H5 访问链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,7 +96,7 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { getCouponActivityList, closeActivity } from "@/api/promotion";
|
||||
import { getCouponActivityList, closeActivity, getCouponPackageQrCode } from "@/api/promotion";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
|
||||
defineOptions({ name: "coupon-activity" });
|
||||
@@ -105,6 +124,7 @@ const selectDate = ref([]);
|
||||
function activityTypeText(type) {
|
||||
const map = {
|
||||
REGISTERED: "注册赠券",
|
||||
COUPON_PACKAGE: "优惠券礼包",
|
||||
AUTO_COUPON: "自动发券",
|
||||
};
|
||||
return map[type] || "精准发券";
|
||||
@@ -147,6 +167,56 @@ function promotionStatusTagType(status) {
|
||||
function init() {
|
||||
getDataList();
|
||||
}
|
||||
const qrCodeDialogVisible = ref(false);
|
||||
const qrCodeLoading = ref(false);
|
||||
const qrCodeInfo = reactive({
|
||||
activityId: "",
|
||||
h5Url: "",
|
||||
qrCodeBase64: "",
|
||||
});
|
||||
|
||||
function addCouponPackage() {
|
||||
router.push({
|
||||
name: "add-coupon-activity",
|
||||
query: { couponActivityType: "COUPON_PACKAGE" },
|
||||
});
|
||||
}
|
||||
|
||||
function openQrCode(row) {
|
||||
qrCodeDialogVisible.value = true;
|
||||
qrCodeLoading.value = true;
|
||||
qrCodeInfo.activityId = row.id;
|
||||
qrCodeInfo.h5Url = "";
|
||||
qrCodeInfo.qrCodeBase64 = "";
|
||||
|
||||
getCouponPackageQrCode(row.id)
|
||||
.then((res) => {
|
||||
if (res.success && res.result) {
|
||||
qrCodeInfo.h5Url = res.result.h5Url;
|
||||
qrCodeInfo.qrCodeBase64 = res.result.qrCodeBase64;
|
||||
} else {
|
||||
Message.error(res.message || "生成推广二维码失败");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Message.error("获取二维码异常");
|
||||
})
|
||||
.finally(() => {
|
||||
qrCodeLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function copyH5Url() {
|
||||
if (!qrCodeInfo.h5Url) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(qrCodeInfo.h5Url).then(() => {
|
||||
Message.success("H5 访问链接已复制到剪贴板!");
|
||||
});
|
||||
} else {
|
||||
Message.success("链接:" + qrCodeInfo.h5Url);
|
||||
}
|
||||
}
|
||||
|
||||
function addRegistered() {
|
||||
router.push({
|
||||
name: "add-coupon-activity",
|
||||
|
||||
26
seller/src/api/stock.js
Normal file
26
seller/src/api/stock.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 商家端库存管理 API(出库单与库存流水)
|
||||
* @author Bulbasaur
|
||||
* @since 2026-11-12
|
||||
*/
|
||||
import {
|
||||
getRequest,
|
||||
postRequest,
|
||||
putRequest,
|
||||
} from "@/libs/axios";
|
||||
|
||||
// ========== 出库单 ==========
|
||||
export const getStockReasonList = params => getRequest("/procurement/reason", params);
|
||||
export const getStockOutboundPage = params => getRequest("/stock/outbound", params);
|
||||
export const getStockOutboundDetail = id => getRequest(`/stock/outbound/${id}`);
|
||||
export const createStockOutbound = params =>
|
||||
postRequest("/stock/outbound", params, { "Content-Type": "application/json" });
|
||||
export const updateStockOutbound = (id, params) =>
|
||||
putRequest(`/stock/outbound/${id}`, params, { "Content-Type": "application/json" });
|
||||
export const submitStockOutbound = id => putRequest(`/stock/outbound/submit/${id}`);
|
||||
export const auditStockOutbound = (id, params) =>
|
||||
putRequest(`/stock/outbound/audit/${id}`, params, { "Content-Type": "application/json" });
|
||||
export const cancelStockOutbound = id => putRequest(`/stock/outbound/cancel/${id}`);
|
||||
|
||||
// ========== 库存流水 ==========
|
||||
export const getStockFlowPage = params => getRequest("/stock/flow", params);
|
||||
@@ -29,6 +29,7 @@ const DETAIL_MENUS = {
|
||||
"procurement-inbound-operation": ["procurementInboundList"],
|
||||
"procurement-inventory-count-detail": ["procurementInventoryCountList"],
|
||||
"procurement-damage-report-operation": ["procurementDamageReportList"],
|
||||
"stock-outbound-operation": ["stockOutboundList"],
|
||||
};
|
||||
|
||||
/** 将权限树展开为带栏目与分组信息的可跳转菜单,用于搜索和路由定位。 */
|
||||
|
||||
235
seller/src/views/stock/flow/list.vue
Normal file
235
seller/src/views/stock/flow/list.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<!--
|
||||
~ 商家端商品库存流水列表页面
|
||||
~ @author Bulbasaur
|
||||
~ @since 2026-11-12
|
||||
-->
|
||||
<template>
|
||||
<div class="stock-flow-list">
|
||||
<el-card>
|
||||
<el-form ref="searchFormRef" :model="searchForm" inline label-width="80px">
|
||||
<el-form-item label="商品名称">
|
||||
<el-input
|
||||
v-model="searchForm.goodsName"
|
||||
placeholder="请输入商品名称"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="SKU ID">
|
||||
<el-input
|
||||
v-model="searchForm.skuId"
|
||||
placeholder="请输入SKU ID"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="流水类型">
|
||||
<el-select
|
||||
v-model="searchForm.flowType"
|
||||
placeholder="全部类型"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in flowTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="变动方向">
|
||||
<el-select
|
||||
v-model="searchForm.direction"
|
||||
placeholder="全部方向"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option label="入库" value="IN" />
|
||||
<el-option label="出库" value="OUT" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务单号">
|
||||
<el-input
|
||||
v-model="searchForm.bizSn"
|
||||
placeholder="订单号/出库单号等"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="变动时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt_10">
|
||||
<el-table v-loading="loading" :data="data" border style="width: 100%">
|
||||
<el-table-column prop="createTime" label="发生时间" width="170" align="center" />
|
||||
<el-table-column prop="goodsName" label="商品名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="skuId" label="SKU ID" width="180" />
|
||||
<el-table-column prop="specs" label="规格" width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.specs || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="flowType" label="业务类型" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="flowTypeTag(row.flowType)">{{ flowTypeText(row.flowType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" label="方向" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.direction === 'IN' ? 'success' : 'danger'" effect="light">
|
||||
{{ row.direction === 'IN' ? '入库' : '出库' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="变动数量" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.direction === 'IN' ? 'text-success font-bold' : 'text-danger font-bold'">
|
||||
{{ row.direction === 'IN' ? '+' : '-' }}{{ row.quantity }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="beforeStock" label="变动前库存" width="110" align="center">
|
||||
<template #default="{ row }">{{ row.beforeStock ?? "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="afterStock" label="变动后库存" width="110" align="center">
|
||||
<template #default="{ row }">{{ row.afterStock ?? "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bizSn" label="关联业务单号" width="190" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.bizSn || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="操作人" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.operator || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt_10 flex flex-justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
size="small"
|
||||
@current-change="getDataList"
|
||||
@size-change="getDataList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useTable } from "@/utils/useTable";
|
||||
import { getStockFlowPage } from "@/api/stock";
|
||||
|
||||
defineOptions({ name: "stock-flow-list" });
|
||||
|
||||
const searchFormRef = ref(null);
|
||||
const dateRange = ref([]);
|
||||
|
||||
const flowTypeOptions = [
|
||||
{ label: "销售出库", value: "ORDER_PAID" },
|
||||
{ label: "退货入库", value: "ORDER_CANCEL" },
|
||||
{ label: "采购入库", value: "PROCUREMENT_INBOUND" },
|
||||
{ label: "领用/调拨出库", value: "STOCK_OUTBOUND" },
|
||||
{ label: "报损出库", value: "DAMAGE" },
|
||||
{ label: "手动调整", value: "MANUAL_ADJUST" },
|
||||
];
|
||||
|
||||
function buildSearchParams(params) {
|
||||
const p = { ...params };
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
p.startDate = dateRange.value[0];
|
||||
p.endDate = dateRange.value[1];
|
||||
} else {
|
||||
delete p.startDate;
|
||||
delete p.endDate;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const {
|
||||
loading,
|
||||
data,
|
||||
total,
|
||||
searchForm,
|
||||
getDataList,
|
||||
handleSearch: search,
|
||||
resetSearch: reset,
|
||||
} = useTable(getStockFlowPage, {
|
||||
form: {
|
||||
goodsName: "",
|
||||
skuId: "",
|
||||
flowType: "",
|
||||
direction: "",
|
||||
bizSn: "",
|
||||
},
|
||||
immediate: true,
|
||||
beforeRequest: (p) => buildSearchParams(p),
|
||||
});
|
||||
|
||||
function flowTypeText(type) {
|
||||
const item = flowTypeOptions.find((it) => it.value === type);
|
||||
return item ? item.label : type || "-";
|
||||
}
|
||||
|
||||
function flowTypeTag(type) {
|
||||
const map = {
|
||||
ORDER_PAID: "danger",
|
||||
ORDER_CANCEL: "success",
|
||||
PROCUREMENT_INBOUND: "success",
|
||||
STOCK_OUTBOUND: "warning",
|
||||
DAMAGE: "danger",
|
||||
MANUAL_ADJUST: "info",
|
||||
};
|
||||
return map[type] || "";
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
searchForm.pageNumber = 1;
|
||||
search();
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
dateRange.value = [];
|
||||
searchForm.goodsName = "";
|
||||
searchForm.skuId = "";
|
||||
searchForm.flowType = "";
|
||||
searchForm.direction = "";
|
||||
searchForm.bizSn = "";
|
||||
reset();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// useTable with immediate: true automatically fetches data
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-flow-list {
|
||||
padding: 10px;
|
||||
}
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
339
seller/src/views/stock/outbound/list.vue
Normal file
339
seller/src/views/stock/outbound/list.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<!--
|
||||
商家端出库单列表页面
|
||||
@author Bulbasaur
|
||||
@since 2026-11-12
|
||||
-->
|
||||
<template>
|
||||
<div class="search">
|
||||
<el-card shadow="never">
|
||||
<el-form ref="searchFormRef" :model="searchForm" inline label-width="80px">
|
||||
<el-form-item label="出库单号">
|
||||
<el-input
|
||||
v-model="searchForm.outboundSn"
|
||||
placeholder="请输入出库单号"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt_10" shadow="never">
|
||||
<div class="order-tab">
|
||||
<el-tabs v-model="currentStatus" @tab-click="onStatusTabClick">
|
||||
<el-tab-pane
|
||||
v-for="item in statusTabs"
|
||||
:key="item.value"
|
||||
:label="item.title"
|
||||
:name="item.value"
|
||||
/>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div class="operation padding-row">
|
||||
<el-button type="primary" @click="goCreate">新建出库单</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="data" border class="mt_10" style="width: 100%">
|
||||
<el-table-column prop="outboundSn" label="出库单号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="reasonName" label="出库原因" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="totalQuantity" label="出库数量" width="100" align="center" />
|
||||
<el-table-column label="单据状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="出库备注" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="auditBy" label="审核人" width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="auditRemark" label="审核备注" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="220" align="right" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="goDetail(row)">查看</el-button>
|
||||
<template v-if="['DRAFT', 'REJECTED'].includes(row.status)">
|
||||
<span class="op-split">|</span>
|
||||
<el-button link type="primary" @click="goEdit(row)">编辑</el-button>
|
||||
<span class="op-split">|</span>
|
||||
<el-button link type="primary" @click="handleSubmit(row)">提交</el-button>
|
||||
</template>
|
||||
<template v-if="row.status === 'AUDITING'">
|
||||
<span class="op-split">|</span>
|
||||
<el-button link type="success" @click="openAuditDialog(row)">审核</el-button>
|
||||
</template>
|
||||
<template v-if="['DRAFT', 'AUDITING', 'REJECTED'].includes(row.status)">
|
||||
<span class="op-split">|</span>
|
||||
<el-button link type="danger" @click="handleCancel(row)">作废</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
size="small"
|
||||
@current-change="getDataList"
|
||||
@size-change="getDataList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<el-dialog v-model="auditDialogVisible" title="出库单审核" width="460px" destroy-on-close>
|
||||
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="90px">
|
||||
<el-form-item label="出库单号">
|
||||
<span>{{ currentOutbound?.outboundSn }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="出库数量">
|
||||
<span>{{ currentOutbound?.totalQuantity }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核结果" prop="pass">
|
||||
<el-radio-group v-model="auditForm.pass">
|
||||
<el-radio :label="true">通过(扣减库存)</el-radio>
|
||||
<el-radio :label="false">驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见" prop="auditRemark">
|
||||
<el-input
|
||||
v-model="auditForm.auditRemark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入审核意见或驳回原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="auditLoading" @click="confirmAudit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useTable } from "@/utils/useTable";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
import { customRouterPush } from "@/utils/filters";
|
||||
import {
|
||||
getStockOutboundPage,
|
||||
submitStockOutbound,
|
||||
auditStockOutbound,
|
||||
cancelStockOutbound,
|
||||
} from "@/api/stock";
|
||||
|
||||
defineOptions({ name: "StockOutboundList" });
|
||||
|
||||
const searchFormRef = ref(null);
|
||||
const dateRange = ref([]);
|
||||
const currentStatus = ref("ALL");
|
||||
|
||||
const STATUS_MAP = {
|
||||
DRAFT: { text: "草稿", tag: "info" },
|
||||
AUDITING: { text: "待审核", tag: "warning" },
|
||||
COMPLETED: { text: "已完成", tag: "success" },
|
||||
REJECTED: { text: "已驳回", tag: "danger" },
|
||||
CANCELLED: { text: "已作废", tag: "info" },
|
||||
};
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ title: "全部", value: "ALL" },
|
||||
{ title: "草稿", value: "DRAFT" },
|
||||
{ title: "待审核", value: "AUDITING" },
|
||||
{ title: "已完成", value: "COMPLETED" },
|
||||
{ title: "已驳回", value: "REJECTED" },
|
||||
{ title: "已作废", value: "CANCELLED" },
|
||||
]);
|
||||
|
||||
function statusText(status) {
|
||||
return STATUS_MAP[status]?.text || status || "-";
|
||||
}
|
||||
|
||||
function statusTagType(status) {
|
||||
return STATUS_MAP[status]?.tag || "info";
|
||||
}
|
||||
|
||||
const {
|
||||
loading,
|
||||
data,
|
||||
total,
|
||||
searchForm,
|
||||
getDataList,
|
||||
handleSearch: search,
|
||||
resetSearch: reset,
|
||||
} = useTable(getStockOutboundPage, {
|
||||
form: { outboundSn: "", status: "" },
|
||||
immediate: false,
|
||||
beforeRequest: (p) => {
|
||||
const params = { ...p };
|
||||
if (params.status === "ALL") {
|
||||
delete params.status;
|
||||
}
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.startDate = dateRange.value[0];
|
||||
params.endDate = dateRange.value[1];
|
||||
}
|
||||
return params;
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getDataList();
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
search();
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
dateRange.value = [];
|
||||
currentStatus.value = "ALL";
|
||||
searchForm.outboundSn = "";
|
||||
searchForm.status = "";
|
||||
reset();
|
||||
}
|
||||
|
||||
function onStatusTabClick(tab) {
|
||||
const name = tab.paneName;
|
||||
if (name === "ALL") {
|
||||
searchForm.status = "";
|
||||
} else {
|
||||
searchForm.status = name;
|
||||
}
|
||||
searchForm.pageNumber = 1;
|
||||
getDataList();
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
customRouterPush({ name: "stock-outbound-operation" });
|
||||
}
|
||||
|
||||
function goEdit(row) {
|
||||
customRouterPush({
|
||||
name: "stock-outbound-operation",
|
||||
query: { id: row.id },
|
||||
});
|
||||
}
|
||||
|
||||
function goDetail(row) {
|
||||
customRouterPush({
|
||||
name: "stock-outbound-operation",
|
||||
query: { id: row.id, readonly: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(row) {
|
||||
Modal.confirm({
|
||||
title: "提交审核",
|
||||
content: `确认提交出库单 [${row.outboundSn}] 进行审核?`,
|
||||
onOk: () => {
|
||||
submitStockOutbound(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
Message.success("提交审核成功");
|
||||
getDataList();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel(row) {
|
||||
Modal.confirm({
|
||||
title: "作废出库单",
|
||||
content: `确认作废出库单 [${row.outboundSn}]?作废后不可恢复。`,
|
||||
onOk: () => {
|
||||
cancelStockOutbound(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
Message.success("作废成功");
|
||||
getDataList();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 审核弹窗相关 ==========
|
||||
const auditDialogVisible = ref(false);
|
||||
const auditLoading = ref(false);
|
||||
const auditFormRef = ref(null);
|
||||
const currentOutbound = ref(null);
|
||||
const auditForm = reactive({
|
||||
pass: true,
|
||||
auditRemark: "",
|
||||
});
|
||||
|
||||
const auditRules = {
|
||||
pass: [{ required: true, message: "请选择审核结果", trigger: "change" }],
|
||||
auditRemark: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (auditForm.pass === false && (!value || !value.trim())) {
|
||||
callback(new Error("驳回时必须填写审核意见"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function openAuditDialog(row) {
|
||||
currentOutbound.value = row;
|
||||
auditForm.pass = true;
|
||||
auditForm.auditRemark = "";
|
||||
auditDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmAudit() {
|
||||
if (!auditFormRef.value) return;
|
||||
auditFormRef.value.validate((valid) => {
|
||||
if (!valid) return;
|
||||
auditLoading.value = true;
|
||||
auditStockOutbound(currentOutbound.value.id, {
|
||||
pass: auditForm.pass,
|
||||
auditRemark: auditForm.auditRemark,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
Message.success(auditForm.pass ? "审核通过,库存已扣减" : "已驳回出库单");
|
||||
auditDialogVisible.value = false;
|
||||
getDataList();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
auditLoading.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.op-split {
|
||||
margin: 0 4px;
|
||||
color: #dcdfe6;
|
||||
}
|
||||
.order-tab :deep(.el-tabs__item) {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
375
seller/src/views/stock/outbound/operation.vue
Normal file
375
seller/src/views/stock/outbound/operation.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<!--
|
||||
~ 商家端出库单详情/新建/编辑页面
|
||||
~ @author Bulbasaur
|
||||
~ @since 2026-11-12
|
||||
-->
|
||||
<template>
|
||||
<div class="stock-outbound-operation">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header flex flex-justify-between flex-align-center">
|
||||
<span class="font-bold text-lg">{{ pageTitle }}</span>
|
||||
<el-button @click="goBack">返回列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 详情只读展示 -->
|
||||
<template v-if="readonly">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="出库单号">{{ detail.outboundSn || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单据状态">
|
||||
<el-tag :type="statusTag(detail.status)">{{ statusText(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="出库原因">{{ detail.reasonName || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="出库总数量">{{ detail.totalQuantity ?? "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="制单人">{{ detail.createBy || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="制单时间">{{ detail.createTime || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核人">{{ detail.auditBy || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核时间">{{ detail.auditTime || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.auditRemark" label="审核备注" :span="2">
|
||||
<span :class="detail.status === 'REJECTED' ? 'text-danger' : ''">{{ detail.auditRemark }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">出库商品明细</el-divider>
|
||||
|
||||
<el-table :data="items" border class="mt-4" style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="goodsName" label="商品名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="skuId" label="SKU ID" width="180" />
|
||||
<el-table-column prop="specs" label="规格" width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.specs || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="出库数量" width="120" align="center" />
|
||||
<el-table-column prop="unit" label="单位" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.unit || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="明细备注" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- 新建/编辑表单 -->
|
||||
<template v-else>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="出库原因" prop="reasonId">
|
||||
<el-select
|
||||
v-model="form.reasonId"
|
||||
placeholder="请选择出库原因"
|
||||
class="w-full"
|
||||
@change="handleReasonChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in reasonOptions"
|
||||
:key="item.id"
|
||||
:label="item.reason"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请输入出库单备注"
|
||||
style="max-width: 600px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="flex flex-justify-between flex-align-center mb-4">
|
||||
<el-divider content-position="left" class="flex-1 mr-4">出库商品明细</el-divider>
|
||||
<el-button type="primary" @click="openSkuPicker">选择商品</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" border style="width: 100%" empty-text="请点击上方按钮添加出库商品">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="goodsName" label="商品名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="skuId" label="SKU ID" width="180" />
|
||||
<el-table-column prop="specs" label="规格" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.specs || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="出库数量" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.quantity"
|
||||
:min="1"
|
||||
:max="999999"
|
||||
:step="1"
|
||||
step-strictly
|
||||
controls-position="right"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.unit" maxlength="10" placeholder="件/个" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="明细备注" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" maxlength="100" placeholder="备注" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="removeItem($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="summary mt-4 flex flex-justify-end font-14">
|
||||
<span>出库品种数:<b class="text-primary">{{ items.length }}</b></span>
|
||||
<span class="ml-6">合计出库数量:<b class="text-primary">{{ totalQuantity }}</b></span>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions mt-6 flex flex-justify-center">
|
||||
<el-button @click="goBack">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSave(false)">保存草稿</el-button>
|
||||
<el-button type="success" :loading="submitLoading" @click="handleSave(true)">保存并提交</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 商品 SKU 选择弹窗 -->
|
||||
<liliDialog ref="liliDialogRef" @selectedGoodsData="onGoodsSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import liliDialog from "@/views/lili-dialog";
|
||||
import {
|
||||
createStockOutbound,
|
||||
getStockOutboundDetail,
|
||||
getStockReasonList,
|
||||
submitStockOutbound,
|
||||
updateStockOutbound,
|
||||
} from "@/api/stock";
|
||||
|
||||
defineOptions({ name: "stock-outbound-operation" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const readonly = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const formRef = ref(null);
|
||||
const liliDialogRef = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
id: "",
|
||||
reasonId: "",
|
||||
reasonName: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
reasonId: [{ required: true, message: "请选择出库原因", trigger: "change" }],
|
||||
};
|
||||
|
||||
const items = ref([]);
|
||||
const detail = ref({});
|
||||
const reasonOptions = ref([]);
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (readonly.value) return "出库单详情";
|
||||
return form.id ? "编辑出库单" : "新建出库单";
|
||||
});
|
||||
|
||||
const totalQuantity = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0)
|
||||
);
|
||||
|
||||
function statusText(status) {
|
||||
const map = {
|
||||
DRAFT: "草稿",
|
||||
AUDITING: "待审核",
|
||||
COMPLETED: "已完成",
|
||||
REJECTED: "已驳回",
|
||||
CANCELLED: "已作废",
|
||||
};
|
||||
return map[status] || status || "-";
|
||||
}
|
||||
|
||||
function statusTag(status) {
|
||||
const map = {
|
||||
DRAFT: "info",
|
||||
AUDITING: "warning",
|
||||
COMPLETED: "success",
|
||||
REJECTED: "danger",
|
||||
CANCELLED: "info",
|
||||
};
|
||||
return map[status] || "";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const isReadonly = route.query.readonly === "1";
|
||||
const id = route.query.id;
|
||||
readonly.value = isReadonly;
|
||||
loadReasons();
|
||||
if (id) {
|
||||
form.id = id;
|
||||
loadDetail(id);
|
||||
}
|
||||
});
|
||||
|
||||
function loadReasons() {
|
||||
getStockReasonList({ category: "OUTBOUND", pageNumber: 1, pageSize: 100 })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
reasonOptions.value = res.result?.records || res.result || [];
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleReasonChange(val) {
|
||||
const found = reasonOptions.value.find((item) => item.id === val);
|
||||
form.reasonName = found ? found.reason : "";
|
||||
}
|
||||
|
||||
function loadDetail(id) {
|
||||
getStockOutboundDetail(id).then((res) => {
|
||||
if (res.success && res.result) {
|
||||
const vo = res.result;
|
||||
const outbound = vo.outbound || vo;
|
||||
detail.value = outbound;
|
||||
form.id = outbound.id;
|
||||
form.reasonId = outbound.reasonId;
|
||||
form.reasonName = outbound.reasonName;
|
||||
form.remark = outbound.remark;
|
||||
items.value = (vo.items || []).map((it) => ({
|
||||
goodsId: it.goodsId,
|
||||
skuId: it.skuId,
|
||||
goodsName: it.goodsName,
|
||||
specs: it.specs,
|
||||
quantity: it.quantity || 1,
|
||||
unit: it.unit,
|
||||
remark: it.remark,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openSkuPicker() {
|
||||
if (liliDialogRef.value) {
|
||||
liliDialogRef.value.goodsData = items.value.map((item) => ({
|
||||
id: item.skuId,
|
||||
goodsId: item.goodsId,
|
||||
goodsName: item.goodsName,
|
||||
}));
|
||||
liliDialogRef.value.open("goods");
|
||||
}
|
||||
}
|
||||
|
||||
function onGoodsSelected(selected) {
|
||||
const existingMap = new Map(items.value.map((item) => [item.skuId, item]));
|
||||
items.value = selected.map((sku) => {
|
||||
const existed = existingMap.get(sku.id);
|
||||
if (existed) return existed;
|
||||
return {
|
||||
goodsId: sku.goodsId,
|
||||
skuId: sku.id,
|
||||
goodsName: sku.goodsName,
|
||||
specs: sku.specs || sku.simpleSpecs || "",
|
||||
quantity: 1,
|
||||
unit: sku.goodsUnit || "件",
|
||||
remark: "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function removeItem(index) {
|
||||
items.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function handleSave(submit = false) {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
if (!items.value.length) {
|
||||
ElMessage.warning("请添加至少一种出库商品");
|
||||
return;
|
||||
}
|
||||
for (const item of items.value) {
|
||||
if (!item.quantity || item.quantity <= 0) {
|
||||
ElMessage.warning(`商品【${item.goodsName}】的出库数量必须大于 0`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
reasonId: form.reasonId,
|
||||
reasonName: form.reasonName,
|
||||
remark: form.remark,
|
||||
items: items.value.map((it) => ({
|
||||
goodsId: it.goodsId,
|
||||
skuId: it.skuId,
|
||||
goodsName: it.goodsName,
|
||||
specs: it.specs,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
remark: it.remark,
|
||||
})),
|
||||
};
|
||||
|
||||
let id = form.id;
|
||||
if (id) {
|
||||
await updateStockOutbound(id, payload);
|
||||
} else {
|
||||
const res = await createStockOutbound(payload);
|
||||
id = res.result?.id || res.result?.outbound?.id || res.result;
|
||||
}
|
||||
|
||||
if (submit && id) {
|
||||
await submitStockOutbound(id);
|
||||
ElMessage.success("出库单保存并提交成功");
|
||||
} else {
|
||||
ElMessage.success("出库单保存成功");
|
||||
}
|
||||
goBack();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: "stockOutboundList" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-outbound-operation {
|
||||
padding: 10px;
|
||||
}
|
||||
.card-header {
|
||||
min-height: 32px;
|
||||
}
|
||||
.summary {
|
||||
font-size: 14px;
|
||||
}
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.text-primary {
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user