fix ProductStockDetail.vue style
This commit is contained in:
@@ -7,47 +7,62 @@
|
|||||||
<t-tag :content="getStockStatusText(stock)" variant="light-outline" :theme="getStockTheme(stock)" />
|
<t-tag :content="getStockStatusText(stock)" variant="light-outline" :theme="getStockTheme(stock)" />
|
||||||
</template>
|
</template>
|
||||||
</product-profile>
|
</product-profile>
|
||||||
<card-container title="库存余额">
|
<t-cell-group theme="card" title="库存">
|
||||||
<t-cell-group theme="card" class="summary">
|
<t-cell class="empty-cell-title-margin">
|
||||||
<t-cell title="产品" :note="product.name || '未命名产品'" />
|
<template #title>
|
||||||
<t-cell title="基础单位" :note="getProductBaseUnitName(product) || '-'" />
|
<span></span>
|
||||||
<t-cell title="实际库存" :note="getProductQuantityText(stock.onHandQuantity)" />
|
</template>
|
||||||
<t-cell title="预占库存" :note="getProductQuantityText(stock.reservedQuantity)" />
|
<template #description>
|
||||||
<t-cell title="可用库存" :note="getProductQuantityText(stock.availableQuantity)" />
|
<div class="stock">
|
||||||
|
<div class="main">
|
||||||
|
<span class="label">库存</span>
|
||||||
|
<div class="value">
|
||||||
|
<strong class="text" v-text="getProductQuantityText(stock.onHandQuantity)" />
|
||||||
|
<span class="unit" v-text="getProductBaseUnitName(product) || '基础单位'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="available">
|
||||||
|
<span class="label">可用</span>
|
||||||
|
<strong class="value" v-text="getProductQuantityText(stock.availableQuantity)" />
|
||||||
|
<span class="unit" v-text="getProductBaseUnitName(product) || '基础单位'" />
|
||||||
|
</div>
|
||||||
|
<div class="reserved">
|
||||||
|
<span class="label">预占</span>
|
||||||
|
<strong class="value" v-text="getProductQuantityText(stock.reservedQuantity)" />
|
||||||
|
<span class="unit" v-text="getProductBaseUnitName(product) || '基础单位'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell title="查看该产品流水" arrow @click="openRecordList" />
|
||||||
</t-cell-group>
|
</t-cell-group>
|
||||||
</card-container>
|
<card-container
|
||||||
<card-container v-if="canOperate && product.stockEnabled" title="人工库存操作" description="所有操作必须选择业务单位、上传照片并填写原因或备注">
|
v-if="canOperate && product.stockEnabled"
|
||||||
<div class="operation-list">
|
class="actions-wrap"
|
||||||
<t-button v-for="item in operationList" :key="item.type" :theme="item.theme" variant="outline" @click="openOperation(item.type)">
|
title="库存操作"
|
||||||
|
description="所有操作必须选择业务单位、上传照片并填写原因或备注"
|
||||||
|
>
|
||||||
|
<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" />
|
<span v-text="item.text" />
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
</card-container>
|
</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 || getProductBaseUnitName(product)} · 基础单位 ${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>
|
</template>
|
||||||
<t-empty v-else description="库存或产品不存在" />
|
<t-empty v-else description="库存或产品不存在" />
|
||||||
<product-stock-operation-dialog
|
<product-stock-operation-popup
|
||||||
v-if="product && stock"
|
v-if="product && stock"
|
||||||
v-model:visible="operationVisible"
|
v-model:visible="operationVisible"
|
||||||
:product="product"
|
:product="product"
|
||||||
@@ -59,50 +74,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Time } from "timi-web";
|
|
||||||
import { useUserStore } from "timi-web-api";
|
import { useUserStore } from "timi-web-api";
|
||||||
import ProductAPI from "@/api/ProductAPI";
|
import ProductAPI from "@/api/ProductAPI";
|
||||||
import ProductProfile from "@/pages/product/component/ProductProfile.vue";
|
import ProductProfile from "@/pages/product/component/ProductProfile.vue";
|
||||||
import ProductStockOperationDialog from "@/pages/product/ProductStockOperationDialog.vue";
|
import ProductStockOperationPopup from "@/pages/product/ProductStockOperationPopup.vue";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { Product, ProductStock, StockOperationType, StockRecord } from "@/types/Product";
|
import type { Product, ProductStock, StockOperationType } from "@/types/Product";
|
||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
import { getProductBaseUnitName, getProductQuantityText, getStockOperationText } from "@/utils/product";
|
import { getProductBaseUnitName, getProductQuantityText } from "@/utils/product";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
|
|
||||||
type ManualStockOperation = Extract<StockOperationType, "INITIAL_IN" | "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
|
type ManualStockOperation = Extract<StockOperationType, "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
|
||||||
|
|
||||||
defineOptions({ name: "ProductStockDetail" });
|
defineOptions({ name: "ProductStockDetail" });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const navBarStore = useNavBarStore();
|
const navBarStore = useNavBarStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const product = ref<Product>();
|
const product = ref<Product>();
|
||||||
const stock = ref<ProductStock>();
|
const stock = ref<ProductStock>();
|
||||||
const recordList = ref<StockRecord[]>([]);
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const operationVisible = ref(false);
|
const operationVisible = ref(false);
|
||||||
const operationType = ref<ManualStockOperation>("MANUAL_IN");
|
const operationType = ref<ManualStockOperation>("MANUAL_IN");
|
||||||
const canOperate = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
|
const canOperate = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
|
||||||
const operationList: Array<{ text: string; type: ManualStockOperation; theme: "primary" | "danger" }> = [
|
const operationList: Array<{ text: string; icon: string; type: ManualStockOperation; theme: "primary" | "danger" }> = [
|
||||||
{ text: "期初入库", type: "INITIAL_IN", theme: "primary" },
|
{ text: "手工入库", icon: "import", type: "MANUAL_IN", theme: "primary" },
|
||||||
{ text: "手工入库", type: "MANUAL_IN", theme: "primary" },
|
{ text: "手工出库", icon: "export", type: "MANUAL_OUT", theme: "danger" },
|
||||||
{ text: "手工出库", type: "MANUAL_OUT", theme: "danger" },
|
{ text: "盘盈", icon: "arrow-up", type: "STOCKTAKE_GAIN", theme: "primary" },
|
||||||
{ text: "盘盈", type: "STOCKTAKE_GAIN", theme: "primary" },
|
{ text: "盘亏", icon: "arrow-down", type: "STOCKTAKE_LOSS", theme: "danger" }
|
||||||
{ 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 {
|
function getStockStatusText(item: ProductStock): string {
|
||||||
if (!item.product?.stockEnabled) {
|
if (!item.product?.stockEnabled) {
|
||||||
return "不管理库存";
|
return "不管理库存";
|
||||||
@@ -128,19 +131,23 @@ async function loadAll(): Promise<void> {
|
|||||||
}
|
}
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const [productResult, stockResult, recordResult] = await Promise.all([
|
const [productResult, stockResult] = await Promise.all([
|
||||||
ProductAPI.detail(productId),
|
ProductAPI.detail(productId),
|
||||||
ProductAPI.stockDetail(productId),
|
ProductAPI.stockDetail(productId)
|
||||||
ProductAPI.recordList({ productId, index: 0, size: 100 })
|
|
||||||
]);
|
]);
|
||||||
product.value = productResult;
|
product.value = productResult;
|
||||||
stock.value = { ...stockResult, product: productResult };
|
stock.value = { ...stockResult, product: productResult };
|
||||||
recordList.value = recordResult.list || [];
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openRecordList(): Promise<void> {
|
||||||
|
if (product.value?.id) {
|
||||||
|
await router.push({ path: "/management/product-stock-records", query: { productId: product.value.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openOperation(type: ManualStockOperation): void {
|
function openOperation(type: ManualStockOperation): void {
|
||||||
operationType.value = type;
|
operationType.value = type;
|
||||||
operationVisible.value = true;
|
operationVisible.value = true;
|
||||||
@@ -161,47 +168,98 @@ onUnmounted(() => navBarStore.clearRight());
|
|||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.product-stock-detail {
|
.product-stock-detail {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
display: grid;
|
display: flex;
|
||||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
margin: 3rem auto;
|
margin: 3rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary,
|
.stock {
|
||||||
.records {
|
|
||||||
margin: 0;
|
.label {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
display: block;
|
||||||
|
font-size: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.operation-list {
|
.value {
|
||||||
gap: .75rem;
|
color: var(--td-brand-color);
|
||||||
display: grid;
|
line-height: 1.1;
|
||||||
padding: 1rem;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
|
|
||||||
.t-button {
|
|
||||||
border-radius: .75rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-title {
|
.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;
|
gap: .5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
font-size: 2.25rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-description {
|
.footer {
|
||||||
gap: .125rem;
|
gap: .75rem;
|
||||||
display: grid;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.positive {
|
.value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available {
|
||||||
|
|
||||||
|
.value {
|
||||||
color: var(--td-success-color);
|
color: var(--td-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.negative {
|
|
||||||
color: var(--td-error-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>
|
</style>
|
||||||
|
|||||||
+13
-14
@@ -1,13 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<t-dialog
|
<bottom-popup
|
||||||
v-model:visible="visible"
|
v-model="visible"
|
||||||
:title="getStockOperationText(operation)"
|
:title="getStockOperationText(operation)"
|
||||||
cancel-btn="取消"
|
height="min(88vh, 52rem)"
|
||||||
:confirm-btn="isSaving ? { content: '提交中', loading: true } : '提交'"
|
left-button-text="取消"
|
||||||
@cancel="visible = false"
|
:right-button-text="isSaving ? '提交中' : '提交'"
|
||||||
@confirm="submit"
|
@left-button-click="visible = false"
|
||||||
|
@right-button-click="submit"
|
||||||
>
|
>
|
||||||
<div class="operation-dialog">
|
<div class="operation-popup">
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<strong v-text="props.product.name || '未命名产品'" />
|
<strong v-text="props.product.name || '未命名产品'" />
|
||||||
<span class="gray" v-text="`当前可用库存:${getProductQuantityText(props.stock?.availableQuantity)} ${getProductBaseUnitName(props.product)}`" />
|
<span class="gray" v-text="`当前可用库存:${getProductQuantityText(props.stock?.availableQuantity)} ${getProductBaseUnitName(props.product)}`" />
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
<package-photo-field v-model="photoItems" title="操作照片" :required="true" />
|
<package-photo-field v-model="photoItems" title="操作照片" :required="true" />
|
||||||
</t-form>
|
</t-form>
|
||||||
</div>
|
</div>
|
||||||
</t-dialog>
|
</bottom-popup>
|
||||||
<t-popup v-model="unitPickerVisible" placement="bottom">
|
<t-popup v-model="unitPickerVisible" placement="bottom">
|
||||||
<t-picker v-model="unitDraft" :columns="unitOptions" title="选择业务单位" @cancel="unitPickerVisible = false" @confirm="confirmUnit" />
|
<t-picker v-model="unitDraft" :columns="unitOptions" title="选择业务单位" @cancel="unitPickerVisible = false" @confirm="confirmUnit" />
|
||||||
</t-popup>
|
</t-popup>
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from "vue";
|
import { watch } from "vue";
|
||||||
|
import { BottomPopup } from "timi-tdesign-mobile";
|
||||||
import ProductAPI from "@/api/ProductAPI";
|
import ProductAPI from "@/api/ProductAPI";
|
||||||
import type { Product, ProductStock, ProductUnit, ProductStockChangeRequest, StockOperationType } from "@/types/Product";
|
import type { Product, ProductStock, ProductUnit, ProductStockChangeRequest, StockOperationType } from "@/types/Product";
|
||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
@@ -43,7 +45,7 @@ import { createProductRequestNo, getProductBaseUnitName, getProductQuantityText,
|
|||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
import type { PhotoItem } from "@/types/Photo";
|
import type { PhotoItem } from "@/types/Photo";
|
||||||
|
|
||||||
type ManualStockOperation = Extract<StockOperationType, "INITIAL_IN" | "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
|
type ManualStockOperation = Extract<StockOperationType, "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
|
||||||
type PickerValue = string | number;
|
type PickerValue = string | number;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -67,7 +69,7 @@ const selectedUnitId = ref("");
|
|||||||
const unitPickerVisible = ref(false);
|
const unitPickerVisible = ref(false);
|
||||||
const unitDraft = ref<PickerValue[]>([]);
|
const unitDraft = ref<PickerValue[]>([]);
|
||||||
|
|
||||||
const isInbound = computed(() => props.operation === "INITIAL_IN" || props.operation === "MANUAL_IN" || props.operation === "STOCKTAKE_GAIN");
|
const isInbound = computed(() => props.operation === "MANUAL_IN" || props.operation === "STOCKTAKE_GAIN");
|
||||||
const selectableUnits = computed<ProductUnit[]>(() => (props.product.unitList || []).filter(unit => isInbound.value ? unit.allowPurchase : unit.allowSale));
|
const selectableUnits = computed<ProductUnit[]>(() => (props.product.unitList || []).filter(unit => isInbound.value ? unit.allowPurchase : unit.allowSale));
|
||||||
const unitOptions = computed(() => [selectableUnits.value.map(unit => ({ label: unit.name || "未命名单位", value: unit.id || "" }))]);
|
const unitOptions = computed(() => [selectableUnits.value.map(unit => ({ label: unit.name || "未命名单位", value: unit.id || "" }))]);
|
||||||
const selectedUnitName = computed(() => selectableUnits.value.find(unit => unit.id === selectedUnitId.value)?.name || "请选择业务单位");
|
const selectedUnitName = computed(() => selectableUnits.value.find(unit => unit.id === selectedUnitId.value)?.name || "请选择业务单位");
|
||||||
@@ -137,9 +139,6 @@ async function submit(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const request = buildRequest();
|
const request = buildRequest();
|
||||||
switch (props.operation) {
|
switch (props.operation) {
|
||||||
case "INITIAL_IN":
|
|
||||||
await ProductAPI.initialIn(request);
|
|
||||||
break;
|
|
||||||
case "MANUAL_IN":
|
case "MANUAL_IN":
|
||||||
await ProductAPI.manualIn(request);
|
await ProductAPI.manualIn(request);
|
||||||
break;
|
break;
|
||||||
@@ -171,7 +170,7 @@ watch(() => [visible.value, props.operation] as const, ([isVisible]) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.operation-dialog {
|
.operation-popup {
|
||||||
.summary {
|
.summary {
|
||||||
gap: .25rem;
|
gap: .25rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="permissionCodes.productStock.read" class="product-stock-record-list">
|
||||||
|
<t-cell-group class="summary" theme="card">
|
||||||
|
<t-cell title="查询范围" :note="scopeText" />
|
||||||
|
<t-cell title="记录数量" :note="`共 ${total} 条`" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-pull-down-refresh
|
||||||
|
v-model="isRefreshing"
|
||||||
|
:refresh-timeout="30000"
|
||||||
|
@refresh="reload"
|
||||||
|
@scrolltolower="loadMore"
|
||||||
|
>
|
||||||
|
<t-cell-group v-if="recordList.length" class="list" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-for="(record, index) in recordList"
|
||||||
|
:key="record.id || `${record.occurredAt}-${record.requestNo}-${index}`"
|
||||||
|
class="record"
|
||||||
|
v-entrance-transition="index * 30"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<span class="icon" :class="getDeltaClass(record.onHandDelta)">
|
||||||
|
<t-icon :name="getOperationIcon(record.operationType)" size="1.25rem" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<div class="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="description">
|
||||||
|
<span v-text="getQuantityText(record)" />
|
||||||
|
<span v-text="getBalanceText(record)" />
|
||||||
|
<span v-if="record.reason || record.remark" class="gray" v-text="record.reason || record.remark || ''" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<div v-else class="empty" v-text="loading ? '正在加载库存流水' : '暂无库存流水'" />
|
||||||
|
<div v-if="recordList.length" class="center-tips" v-text="loadStateText" />
|
||||||
|
</t-pull-down-refresh>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Time } from "timi-web";
|
||||||
|
import ProductAPI from "@/api/ProductAPI";
|
||||||
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
|
import type { 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";
|
||||||
|
|
||||||
|
defineOptions({ name: "ProductStockRecordList" });
|
||||||
|
|
||||||
|
const pageSize = 15;
|
||||||
|
const route = useRoute();
|
||||||
|
const navBarStore = useNavBarStore();
|
||||||
|
const recordList = ref<StockRecord[]>([]);
|
||||||
|
const pageIndex = ref(0);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
const productId = computed(() => String(route.query.productId || ""));
|
||||||
|
const scopeText = computed(() => {
|
||||||
|
if (!productId.value) {
|
||||||
|
return "全部产品";
|
||||||
|
}
|
||||||
|
return `指定产品 · ${recordList.value[0]?.productNameSnapshot || "当前产品"}`;
|
||||||
|
});
|
||||||
|
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
|
||||||
|
|
||||||
|
function getOperationIcon(type?: StockOperationType): string {
|
||||||
|
return ({
|
||||||
|
INITIAL_IN: "download",
|
||||||
|
MANUAL_IN: "add-circle",
|
||||||
|
MANUAL_OUT: "remove-circle",
|
||||||
|
ORDER_RESERVE: "lock-on",
|
||||||
|
ORDER_RELEASE: "unlock",
|
||||||
|
ORDER_OUT: "remove-circle",
|
||||||
|
ORDER_REVOKE: "rollback",
|
||||||
|
STOCKTAKE_GAIN: "arrow-up",
|
||||||
|
STOCKTAKE_LOSS: "arrow-down"
|
||||||
|
} as Record<string, string>)[type || ""] || "history";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const amount = Number(value || 0);
|
||||||
|
return 0 < amount ? "positive" : amount < 0 ? "negative" : "gray";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuantityText(record: StockRecord): string {
|
||||||
|
return `${record.productNameSnapshot || "未命名产品"} · ${getProductQuantityText(record.quantity)} ${record.unitSnapshot || "业务单位"} · 基础单位 ${getProductQuantityText(record.baseQuantity)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBalanceText(record: StockRecord): string {
|
||||||
|
const operator = record.operator?.name || record.operatorUserId || "未知操作人";
|
||||||
|
return `库存 ${getProductQuantityText(record.onHandAfter)} · 预占 ${getProductQuantityText(record.reservedAfter)} · ${operator}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore(): Promise<void> {
|
||||||
|
if (loading.value || noMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await ProductAPI.recordList({
|
||||||
|
productId: productId.value || undefined,
|
||||||
|
index: pageIndex.value,
|
||||||
|
size: pageSize
|
||||||
|
});
|
||||||
|
const list = result.list || [];
|
||||||
|
recordList.value = recordList.value.concat(list);
|
||||||
|
total.value = result.total || 0;
|
||||||
|
pageIndex.value += 1;
|
||||||
|
noMore.value = list.length < pageSize || total.value < recordList.value.length || total.value === recordList.value.length;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload(): Promise<void> {
|
||||||
|
if (loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageIndex.value = 0;
|
||||||
|
recordList.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
noMore.value = false;
|
||||||
|
await loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
navBarStore.clearRight();
|
||||||
|
try {
|
||||||
|
await reload();
|
||||||
|
} catch (error) {
|
||||||
|
Message.error(getErrorMessage(error, "库存流水加载失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => navBarStore.clearRight());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.product-stock-record-list {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
padding: var(--app-nav-height) 0 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record {
|
||||||
|
.icon {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
gap: .125rem;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: var(--td-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: var(--td-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -73,7 +73,13 @@ const menuGroupList = computed<MenuGroup[]>(() => [
|
|||||||
{
|
{
|
||||||
icon: "wallet",
|
icon: "wallet",
|
||||||
path: "/management/product-stocks",
|
path: "/management/product-stocks",
|
||||||
title: "库存与库存流水",
|
title: "库存",
|
||||||
|
visible: userStore.hasPermissions(permissionCodes.productStock.read)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "history",
|
||||||
|
path: "/management/product-stock-records",
|
||||||
|
title: "库存流水",
|
||||||
visible: userStore.hasPermissions(permissionCodes.productStock.read)
|
visible: userStore.hasPermissions(permissionCodes.productStock.read)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ProductEdit from "@/pages/product/ProductEdit.vue";
|
|||||||
import ProductList from "@/pages/product/ProductList.vue";
|
import ProductList from "@/pages/product/ProductList.vue";
|
||||||
import ProductStockDetail from "@/pages/product/ProductStockDetail.vue";
|
import ProductStockDetail from "@/pages/product/ProductStockDetail.vue";
|
||||||
import ProductStockList from "@/pages/product/ProductStockList.vue";
|
import ProductStockList from "@/pages/product/ProductStockList.vue";
|
||||||
|
import ProductStockRecordList from "@/pages/product/ProductStockRecordList.vue";
|
||||||
|
|
||||||
function pageMeta(title: string, depth = 3, background = "#F5F5F8"): RouteRecordRaw["meta"] {
|
function pageMeta(title: string, depth = 3, background = "#F5F5F8"): RouteRecordRaw["meta"] {
|
||||||
return {
|
return {
|
||||||
@@ -60,7 +61,13 @@ const productRoutes: RouteRecordRaw[] = [
|
|||||||
path: "/management/product-stocks",
|
path: "/management/product-stocks",
|
||||||
name: "ProductStockList",
|
name: "ProductStockList",
|
||||||
component: ProductStockList,
|
component: ProductStockList,
|
||||||
meta: pageMeta("库存与流水")
|
meta: pageMeta("库存")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/product-stock-records",
|
||||||
|
name: "ProductStockRecordList",
|
||||||
|
component: ProductStockRecordList,
|
||||||
|
meta: pageMeta("库存流水")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/management/product-stocks/:productId",
|
path: "/management/product-stocks/:productId",
|
||||||
|
|||||||
Reference in New Issue
Block a user