add product

This commit is contained in:
Timi
2026-09-03 14:27:40 +08:00
parent 7f669621fb
commit 333999fe21
63 changed files with 5662 additions and 2210 deletions
+268
View File
@@ -0,0 +1,268 @@
<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 && stock">
<section class="hero" v-entrance-transition>
<span class="eyebrow" v-text="'库存中心'" />
<div class="hero-title">
<h1 v-text="product.name || '未命名产品'" />
<t-tag :content="getStockStatusText(stock)" variant="light-outline" :theme="getStockTheme(stock)" />
</div>
<div class="hero-meta">
<span v-text="product.code || '无产品编码'" />
<strong v-text="`${getProductQuantityText(stock.availableQuantity)} ${product.baseUnit || ''}`" />
</div>
</section>
<card-container title="库存余额">
<t-cell-group theme="card" class="summary">
<t-cell title="产品" :note="product.name || '未命名产品'" />
<t-cell title="基础单位" :note="product.baseUnit || '-'" />
<t-cell title="实际库存" :note="getProductQuantityText(stock.onHandQuantity)" />
<t-cell title="预占库存" :note="getProductQuantityText(stock.reservedQuantity)" />
<t-cell title="可用库存" :note="getProductQuantityText(stock.availableQuantity)" />
</t-cell-group>
</card-container>
<card-container v-if="canOperate && product.stockEnabled" title="人工库存操作" description="所有操作必须选择业务单位、上传照片并填写原因或备注">
<div class="operation-list">
<t-button v-for="item in operationList" :key="item.type" :theme="item.theme" variant="outline" @click="openOperation(item.type)">
<span v-text="item.text" />
</t-button>
</div>
</card-container>
<card-container title="库存流水" description="履约产生的预占、释放、出库和冲正流水仅展示,不提供人工修改">
<t-cell-group v-if="recordList.length" theme="card" class="records">
<t-cell v-for="record in recordList" :key="record.id || `${record.occurredAt}-${record.requestNo}`">
<template #title>
<div class="record-title">
<strong v-text="getStockOperationText(record.operationType)" />
<span :class="getDeltaClass(record.onHandDelta)" v-text="getDeltaText(record.onHandDelta)" />
</div>
</template>
<template #note>
<span v-text="record.occurredAt ? Time.toDateTime(record.occurredAt) : '时间未知'" />
</template>
<template #description>
<div class="record-description">
<span v-text="`${record.quantity || 0} ${record.unitSnapshot || product.baseUnit || ''} · 基础单位 ${record.baseQuantity || 0}`" />
<span class="gray" v-text="record.reason || record.remark || '无备注'" />
</div>
</template>
</t-cell>
</t-cell-group>
<div v-else class="center-tips" v-text="'暂无库存流水'" />
</card-container>
</template>
<t-empty v-else description="库存或产品不存在" />
<product-stock-operation-dialog
v-if="product && stock"
v-model:visible="operationVisible"
:product="product"
:stock="stock"
:operation="operationType"
@success="loadAll"
/>
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import ProductStockOperationDialog from "@/pages/product/ProductStockOperationDialog.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { Product, ProductStock, StockOperationType, StockRecord } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { getProductQuantityText, getStockOperationText } from "@/utils/product";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ManualStockOperation = Extract<StockOperationType, "INITIAL_IN" | "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
defineOptions({ name: "ProductStockDetail" });
const route = useRoute();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const product = ref<Product>();
const stock = ref<ProductStock>();
const recordList = ref<StockRecord[]>([]);
const isLoading = ref(false);
const operationVisible = ref(false);
const operationType = ref<ManualStockOperation>("MANUAL_IN");
const canOperate = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
const operationList: Array<{ text: string; type: ManualStockOperation; theme: "primary" | "danger" }> = [
{ text: "期初入库", type: "INITIAL_IN", theme: "primary" },
{ text: "手工入库", type: "MANUAL_IN", theme: "primary" },
{ text: "手工出库", type: "MANUAL_OUT", theme: "danger" },
{ text: "盘盈", type: "STOCKTAKE_GAIN", theme: "primary" },
{ text: "盘亏", type: "STOCKTAKE_LOSS", theme: "danger" }
];
function getDeltaText(value?: number | string): string {
const amount = Number(value || 0);
const text = String(value || "0");
return `${0 < amount ? "+" : ""}${amount < 0 ? getProductQuantityText(text.slice(1)) : getProductQuantityText(text)}`;
}
function getDeltaClass(value?: number | string): string {
return 0 < Number(value || 0) ? "positive" : Number(value || 0) < 0 ? "negative" : "gray";
}
function getStockStatusText(item: ProductStock): string {
if (!item.product?.stockEnabled) {
return "不管理库存";
}
const available = Number(item.availableQuantity || 0);
const min = Number(item.product.minStockQuantity || 0);
return 0 < min && available < min ? "库存不足" : "库存正常";
}
function getStockTheme(item: ProductStock): "danger" | "success" | "default" {
if (!item.product?.stockEnabled) {
return "default";
}
const available = Number(item.availableQuantity || 0);
const min = Number(item.product.minStockQuantity || 0);
return 0 < min && available < min ? "danger" : "success";
}
async function loadAll(): Promise<void> {
const productId = String(route.params.productId || "");
if (!productId) {
return;
}
isLoading.value = true;
try {
const [productResult, stockResult, recordResult] = await Promise.all([
ProductAPI.detail(productId),
ProductAPI.stockDetail(productId),
ProductAPI.recordList({ productId, index: 0, size: 100 })
]);
product.value = productResult;
stock.value = { ...stockResult, product: productResult };
recordList.value = recordResult.list || [];
} finally {
isLoading.value = false;
}
}
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: grid;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.hero {
margin: 0 1rem;
padding: 1rem;
background: linear-gradient(135deg, #eff6ff, #f0fdf4);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
.eyebrow {
color: #2563eb;
display: block;
font-size: .6875rem;
font-weight: 700;
letter-spacing: .08em;
}
.hero-title {
gap: .5rem;
display: flex;
margin-top: .35rem;
align-items: center;
justify-content: space-between;
}
h1 {
margin: 0;
font-size: 1.25rem;
line-height: 1.35;
}
.hero-meta {
gap: .75rem;
display: flex;
margin-top: .6rem;
color: var(--td-text-color-secondary);
align-items: center;
justify-content: space-between;
}
strong {
color: #0f766e;
font-size: 1.2rem;
white-space: nowrap;
}
}
.loading {
margin: 3rem auto;
}
.summary,
.records {
margin: 0;
}
.operation-list {
gap: .75rem;
display: grid;
padding: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
.t-button {
border-radius: .75rem;
}
}
.record-title {
gap: .5rem;
display: flex;
align-items: center;
}
.record-description {
gap: .125rem;
display: grid;
}
.positive {
color: var(--td-success-color);
}
.negative {
color: var(--td-error-color);
}
}
:global(html.theme-dark) .product-stock-detail .hero {
background: linear-gradient(135deg, rgba(37, 99, 235, .2), rgba(15, 118, 110, .18));
.eyebrow {
color: #93c5fd;
}
strong {
color: #99f6e4;
}
}
</style>