Files
gao-customer-h5/src/pages/product/ProductStockDetail.vue
T
2026-09-16 01:39:57 +08:00

365 lines
10 KiB
Vue

<template>
<div v-can="permissionCodes.productStock.read" class="product-stock-detail">
<t-loading v-if="isLoading" class="loading" size="32px" />
<template v-else-if="product && sku && stock">
<product-profile :product="product" v-entrance-transition>
<template #tags>
<t-tag :content="sku?.specSummary || sku?.name || '默认 SKU'" variant="light-outline" theme="primary" />
<t-tag :content="getStockStatusText(stock)" variant="light-outline" :theme="getStockTheme(stock)" />
</template>
</product-profile>
<t-cell-group theme="card" title="库存" v-entrance-transition="70">
<t-cell
v-if="1 < unitList.length"
title="显示单位"
:note="selectedUnitName"
arrow
@click="openUnitPicker"
/>
<t-cell class="empty-cell-title-margin">
<template #title>
<span></span>
</template>
<template #description>
<div class="stock">
<div class="main">
<span class="label">库存</span>
<div class="value">
<strong class="text" v-text="displayStock.onHandQuantity" />
<span class="unit" v-text="selectedUnitName" />
</div>
</div>
<div class="footer">
<div class="available">
<span class="label">可用</span>
<strong class="value" v-text="displayStock.availableQuantity" />
<span class="unit" v-text="selectedUnitName" />
</div>
<div class="reserved">
<span class="label">预占</span>
<strong class="value" v-text="displayStock.reservedQuantity" />
<span class="unit" v-text="selectedUnitName" />
</div>
</div>
</div>
</template>
</t-cell>
<t-cell v-if="0 < Number(stock.reservedQuantity || 0)" title="查看预占流水" arrow @click="openReservedRecordList" />
<t-cell title="查看该产品 SKU 流水" arrow @click="openRecordList" />
</t-cell-group>
<card-container
v-if="canOperate && sku?.status === 'ACTIVE' && sku.stockEnabled"
class="actions-wrap"
title="库存操作"
description="所有操作必须选择业务单位、上传照片并填写备注"
v-entrance-transition="140"
>
<div class="actions">
<t-button
v-for="item in operationList"
:key="item.type"
class="action"
:theme="item.theme"
block
@click="openOperation(item.type)"
>
<template #icon>
<t-icon :name="item.icon" size="1.25rem" />
</template>
<span v-text="item.text" />
</t-button>
</div>
</card-container>
</template>
<t-empty v-else description="库存或产品不存在" />
<product-stock-operation-popup
v-if="product && sku && stock"
v-model:visible="operationVisible"
:product="product"
:sku="sku"
:stock="stock"
:operation="operationType"
@success="loadAll"
/>
<t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker
v-model="unitPickerDraft"
:columns="unitPickerOptions"
title="选择库存展示单位"
@cancel="unitPickerVisible = false"
@confirm="confirmUnit"
/>
</t-popup>
</div>
</template>
<script setup lang="ts">
import { shallowRef } from "vue";
import { useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import ProductProfile from "@/pages/product/component/ProductProfile.vue";
import ProductStockOperationPopup from "@/pages/product/ProductStockOperationPopup.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { Product, ProductSku, ProductStock, ProductSkuUnit, StockOperationType } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { convertBaseQuantityToPackageQuantity } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ManualStockOperation = Extract<StockOperationType, "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
type PickerValue = string | number;
defineOptions({ name: "ProductStockDetail" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const product = ref<Product>();
const sku = ref<ProductSku>();
const stock = ref<ProductStock>();
const isLoading = ref(false);
const operationVisible = ref(false);
const operationType = ref<ManualStockOperation>("MANUAL_IN");
const selectedUnitId = shallowRef("");
const unitPickerVisible = shallowRef(false);
const unitPickerDraft = ref<PickerValue[]>([]);
const canOperate = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
const unitList = computed(() => (sku.value?.unitList || []).filter(unit => !!unit.id));
const selectedUnit = computed<ProductSkuUnit | undefined>(() => unitList.value.find(unit => unit.id === selectedUnitId.value)
|| unitList.value.find(unit => unit.isBase)
|| unitList.value[0]);
const selectedUnitName = computed(() => selectedUnit.value?.name || "基础单位");
const unitPickerOptions = computed(() => [unitList.value.map(unit => ({ label: unit.name || "未命名单位", value: unit.id || "" }))]);
const displayStock = computed(() => ({
onHandQuantity: getStockQuantityText(stock.value?.onHandQuantity),
availableQuantity: getStockQuantityText(stock.value?.availableQuantity),
reservedQuantity: getStockQuantityText(stock.value?.reservedQuantity)
}));
const operationList: Array<{ text: string; icon: string; type: ManualStockOperation; theme: "primary" | "danger" }> = [
{ text: "手工入库", icon: "import", type: "MANUAL_IN", theme: "primary" },
{ text: "手工出库", icon: "export", type: "MANUAL_OUT", theme: "danger" },
{ text: "盘盈", icon: "arrow-up", type: "STOCKTAKE_GAIN", theme: "primary" },
{ text: "盘亏", icon: "arrow-down", type: "STOCKTAKE_LOSS", theme: "danger" }
];
function getStockStatusText(item: ProductStock): string {
if (!item.sku?.stockEnabled) {
return "不管理库存";
}
const available = Number(item.availableQuantity || 0);
const min = Number(item.sku.minStockQuantity || 0);
return 0 < min && available < min ? "库存不足" : "库存正常";
}
function getStockTheme(item: ProductStock): "danger" | "success" | "default" {
if (!item.sku?.stockEnabled) {
return "default";
}
const available = Number(item.availableQuantity || 0);
const min = Number(item.sku.minStockQuantity || 0);
return 0 < min && available < min ? "danger" : "success";
}
function getStockQuantityText(value?: number | string): string {
return convertBaseQuantityToPackageQuantity(value ?? "0", selectedUnit.value?.baseQuantityPerUnit ?? "1") || "0";
}
function setDefaultUnit(nextSku: ProductSku): void {
const nextUnitList = nextSku.unitList || [];
if (selectedUnitId.value && nextUnitList.some(unit => unit.id === selectedUnitId.value)) {
return;
}
selectedUnitId.value = nextUnitList.find(unit => unit.isBase)?.id || nextUnitList[0]?.id || "";
}
function openUnitPicker(): void {
if (1 >= unitList.value.length) {
return;
}
unitPickerDraft.value = [selectedUnit.value?.id || ""];
unitPickerVisible.value = true;
}
function confirmUnit(value: PickerValue[]): void {
const unitId = String(value[0] || "");
if (unitList.value.some(unit => unit.id === unitId)) {
selectedUnitId.value = unitId;
}
unitPickerVisible.value = false;
}
async function loadAll(): Promise<void> {
const skuId = String(route.params.skuId || "");
if (!skuId) {
return;
}
isLoading.value = true;
try {
const skuResult = await ProductAPI.skuDetail(skuId);
const [productResult, stockResult] = await Promise.all([
ProductAPI.detail(skuResult.productId || ""),
ProductAPI.stockDetail(skuId)
]);
product.value = productResult;
sku.value = skuResult;
setDefaultUnit(skuResult);
stock.value = { ...stockResult, sku: skuResult };
} finally {
isLoading.value = false;
}
}
async function openRecordList(): Promise<void> {
if (product.value?.id) {
await router.push({
name: "ProductStockRecordList",
query: {
productId: product.value.id,
transitionDepth: String(getCurrentRouteDepth() + 1)
}
});
}
}
async function openReservedRecordList(): Promise<void> {
if (product.value?.id) {
await router.push({
name: "ProductStockRecordList",
query: {
productId: product.value.id,
operationType: "ORDER_RESERVE",
activeReservationOnly: "true",
transitionDepth: String(getCurrentRouteDepth() + 1)
}
});
}
}
function getCurrentRouteDepth(): number {
const transitionDepth = Array.isArray(route.query.transitionDepth)
? route.query.transitionDepth[0]
: route.query.transitionDepth;
const depth = Number(transitionDepth);
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
return depth;
}
return Number(route.meta.depth ?? 0);
}
function openOperation(type: ManualStockOperation): void {
operationType.value = type;
operationVisible.value = true;
}
onMounted(async () => {
navBarStore.clearRight();
try {
await loadAll();
} catch (error) {
Message.error(getErrorMessage(error, "库存详情加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.product-stock-detail {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
flex-direction: column;
.loading {
margin: 3rem auto;
}
.stock {
.label {
color: var(--td-text-color-secondary);
display: block;
font-size: .75rem;
}
.value {
color: var(--td-brand-color);
line-height: 1.1;
}
.unit {
color: var(--td-text-color-secondary);
font-size: .75rem;
}
.main {
padding: .75rem;
background: var(--td-brand-color-light);
border-radius: .875rem;
.value {
gap: .5rem;
display: flex;
font-size: 2.25rem;
margin-top: .25rem;
align-items: baseline;
}
}
.footer {
gap: .75rem;
display: grid;
margin-top: .75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
.available,
.reserved {
padding: .75rem;
background: var(--td-bg-color-secondarycontainer);
border-radius: .75rem;
}
.value {
display: block;
font-size: 1.375rem;
line-height: 1.25;
}
.available {
.value {
color: var(--td-success-color);
}
}
.reserved {
.value {
color: var(--td-warning-color);
}
}
}
}
.actions-wrap {
:deep(.content) {
background: transparent;
}
.actions {
gap: .75rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
.action {
--td-button-border-radius: 1rem;
font-weight: 600;
}
}
}
}
</style>