add product sku

This commit is contained in:
Timi
2026-09-07 12:59:27 +08:00
parent 9841723177
commit 1d70aebd83
49 changed files with 3700 additions and 756 deletions
@@ -1,7 +1,7 @@
<template>
<gao-chart-card
title="履约进度排行"
caption="购买数量的当前履约快照"
caption="基础单位购买数量的当前履约快照"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
@@ -33,19 +33,19 @@ const chartOption = computed<EChartsOption>(() => {
name: "待履约",
type: "bar",
stack: "progress",
data: list.map(item => getNumber(item.reservedQuantity))
data: list.map(item => getNumber(item.reservedBaseQuantity))
},
{
name: "已履约",
type: "bar",
stack: "progress",
data: list.map(item => getNumber(item.fulfilledQuantity))
data: list.map(item => getNumber(item.fulfilledBaseQuantity))
},
{
name: "可履约",
type: "bar",
stack: "progress",
data: list.map(item => getNumber(item.availableQuantity))
data: list.map(item => getNumber(item.availableBaseQuantity))
}
]
};
@@ -1,7 +1,7 @@
<template>
<gao-chart-card
title="每日履约趋势"
caption="冲正数量允许为负值"
caption="基础单位,冲正数量允许为负值"
:date-text-list="dateTextList"
:option="buildOption"
:loading="loading"
@@ -41,16 +41,16 @@ function buildOption(visibleDateTextList: string[]): EChartsOption {
name: "直接履约",
type: "bar",
stack: "fulfillment",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.directQuantity))
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.directBaseQuantity))
},
{
name: "套餐履约",
type: "bar",
stack: "fulfillment",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.packageQuantity))
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.packageBaseQuantity))
},
{
name: "基础单位变化",
name: "履约基础单位",
type: "line",
smooth: true,
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.baseQuantity))
@@ -1,7 +1,7 @@
<template>
<gao-chart-card
title="产品销售排行"
caption="按销量取前 10"
caption="按基础单位销量取前 10"
:option="chartOption"
:loading="loading"
:has-data="list.length > 0"
@@ -33,13 +33,13 @@ const chartOption = computed<EChartsOption>(() => {
name: "直接销量",
type: "bar",
stack: "quantity",
data: list.map(item => getNumber(item.directQuantity))
data: list.map(item => getNumber(item.directBaseQuantity))
},
{
name: "套餐权益",
type: "bar",
stack: "quantity",
data: list.map(item => getNumber(item.packageQuantity))
data: list.map(item => getNumber(item.packageBaseQuantity))
}
]
};
@@ -1,7 +1,7 @@
<template>
<gao-chart-card
title="产品销售趋势"
caption="直接产品与套餐权益分开展示"
caption="基础单位,直接产品与套餐权益分开展示"
:date-text-list="dateTextList"
:option="buildOption"
:loading="loading"
@@ -41,13 +41,13 @@ function buildOption(visibleDateTextList: string[]): EChartsOption {
name: "直接销量",
type: "bar",
stack: "quantity",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.directQuantity))
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.directBaseQuantity))
},
{
name: "套餐权益",
type: "bar",
stack: "quantity",
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.packageQuantity))
data: visibleDateTextList.map(dateText => getNumber(getDailyItem(props.list, dateText)?.packageBaseQuantity))
},
{
name: "订单数",
@@ -0,0 +1,415 @@
<template>
<t-swipe-cell class="sku-cell">
<t-cell role="button" tabindex="0" arrow @click="openEditor">
<template #title>
<div class="title">
<span class="code gray" v-text="sku.code" />
<strong class="clip-text" v-text="skuTitle" />
<t-tag v-if="sku.id" size="small" variant="light-outline" :content="sku.status === 'ACTIVE' ? '已建立' : '已停用'" />
</div>
</template>
<template #note>
<t-tag
size="small"
variant="light-outline"
:theme="sku.status === 'ACTIVE' ? 'success' : 'warning'"
:content="sku.status === 'ACTIVE' ? '启用' : '停用'"
/>
</template>
<template #description>
<div class="tags">
<t-tag
v-for="entry in skuSpecEntries"
:key="`${entry.specName}-${entry.valueName}`"
size="small"
:content="entry.valueName"
/>
<t-tag
v-if="sku.stockEnabled"
theme="warning"
size="small"
content="库存"
/>
<t-tag
v-for="unit in sku.unitList"
:key="unit.clientKey"
class="description-tag"
size="small"
:content="unit.name || '未命名单位'"
/>
</div>
</template>
</t-cell>
<template #right>
<t-button class="delete-action" theme="danger" @click.stop="requestRemove">
删除
</t-button>
</template>
</t-swipe-cell>
<bottom-popup
v-model="editorVisible"
title="配置 SKU 明细"
height="min(90vh, 52rem)"
:left-button-text="sku.id ? '关闭' : '取消'"
right-button-text="完成"
:z-index="skuEditorZIndex"
:overlay-props="{ zIndex: skuEditorZIndex - 1 }"
@left-button-click="closeEditor"
@right-button-click="closeEditor"
>
<div class="sku-popup">
<div class="sku-popup-header">
<div class="sku-heading">
<span class="sku-index" v-text="String(props.skuIndex + 1).padStart(2, '0')" />
<strong v-text="skuTitle" />
</div>
</div>
<div class="sku-spec-panel">
<div class="section-title">
<strong>销售规格</strong>
<span v-if="sku.specSummary" v-text="`摘要:${sku.specSummary}`" />
</div>
<div v-if="skuSpecEntries.length" class="sku-spec-list">
<div v-for="entry in skuSpecEntries" :key="`${entry.specName}-${entry.valueName}`" class="sku-spec-item">
<span class="spec-name" v-text="entry.specName" />
<strong v-text="entry.valueName" />
</div>
</div>
<div v-else class="sku-spec-empty" v-text="props.specList.length ? '请重新生成 SKU 明细以匹配规格项和规格值' : '无多规格产品,使用默认 SKU'" />
</div>
<t-form class="sku-form" label-align="left" label-width="5rem">
<t-form-item label="SKU 编码" required-mark help="门店内唯一;已有 SKU 修改失败时请保留原编码">
<t-input v-model.trim="sku.code" borderless maxlength="64" placeholder="如 TSHIRT-RED-M" />
</t-form-item>
<t-form-item label="明细名称" required-mark>
<t-input v-model.trim="sku.name" borderless maxlength="128" placeholder="如 黑色运动鞋;男;36" />
</t-form-item>
<t-form-item label="SKU 条码">
<t-input v-model.trim="sku.barcode" borderless maxlength="64" placeholder="选填" />
</t-form-item>
<div class="section-title setting-title">
<strong>库存设置</strong>
<span>每条 SKU 明细独立生效</span>
</div>
<t-form-item label="库存管理" content-align="right">
<t-switch v-model="sku.stockEnabled" size="small" :disabled="props.productType === 'SERVICE'" />
</t-form-item>
<t-form-item label="允许小数" content-align="right">
<t-switch v-model="sku.allowDecimalQuantity" size="small" />
</t-form-item>
<t-form-item v-if="sku.stockEnabled" label="最低库存" help="基础单位数量,最多三位小数">
<t-input v-model.trim="sku.minStockQuantityText" type="number" align="right" borderless placeholder="0" />
</t-form-item>
<t-form-item label="明细状态" content-align="right">
<t-switch :model-value="sku.status === 'ACTIVE'" size="small" @change="setSkuStatus" />
</t-form-item>
<t-form-item label="备注">
<t-input v-model.trim="sku.remark" borderless maxlength="255" placeholder="选填" />
</t-form-item>
</t-form>
<photo-scroller
:model-value="photoUrlList"
v-model:photo-items="sku.photoItemList"
title="SKU 图片(选填)"
:edit-mode="true"
:upload="uploadPhotoList"
/>
<div class="unit-section">
<div class="unit-heading">
<strong>销售单位</strong>
<span>采购、销售和库存操作共用</span>
</div>
<div class="unit-summary-list">
<div v-for="unit in sku.unitList" :key="unit.clientKey" class="unit-summary">
<strong v-text="unit.name || '未命名单位'" />
<span v-text="getUnitSummary(unit)" />
</div>
</div>
<t-button class="unit-button" variant="dashed" size="small" content="配置销售单位" @click="requestConfigureUnits" />
</div>
</div>
</bottom-popup>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import type { ProductType } from "@/types/Product";
import type { ProductSkuForm, ProductSpecForm, ProductSkuUnitForm } from "@/types/ProductEditor";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { getProductSkuSpecEntries } from "@/utils/product";
import { uploadPhotoList } from "@/utils/photo";
defineOptions({ name: "ProductSkuEditor" });
const props = defineProps<{
productType: ProductType;
skuIndex: number;
specList: ProductSpecForm[];
}>();
const sku = defineModel<ProductSkuForm>("sku", { required: true });
const emit = defineEmits<{
configureUnits: [];
remove: [];
}>();
const editorVisible = ref(false);
const skuEditorZIndex = 12000;
const photoUrlList = computed(() => sku.value.photoItemList.map(item => item.url));
const skuSpecEntries = computed(() => getProductSkuSpecEntries(sku.value, props.specList));
const skuTitle = computed(() => props.specList.length ? `SKU 明细 ${props.skuIndex + 1}` : "默认 SKU");
function openEditor(): void {
editorVisible.value = true;
}
function closeEditor(): void {
editorVisible.value = false;
}
function requestRemove(): void {
editorVisible.value = false;
emit("remove");
}
function requestConfigureUnits(): void {
emit("configureUnits");
}
function setSkuStatus(value: boolean | string | number): void {
const active = value === true || value === "true" || value === 1 || value === "1";
sku.value.status = active ? "ACTIVE" : "INACTIVE";
}
function getUnitSummary(unit: ProductSkuUnitForm): string {
const quantityText = unit.isBase ? "基础单位" : `1 ${unit.name || "单位"} = ${unit.baseQuantityPerUnit || "?"} 基础单位`;
const priceText = unit.salePriceText ? `¥${unit.salePriceText}` : "未定价";
const saleText = unit.allowSale ? "可销售" : "停售";
const purchaseText = unit.allowPurchase ? "可采购" : "禁采购";
return `${quantityText} · ${priceText} · ${saleText} · ${purchaseText}`;
}
</script>
<style scoped lang="less">
.sku-cell {
.title {
}
.cell-title {
gap: .5rem;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
strong {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.description-tags {
gap: .375rem;
display: flex;
flex-wrap: wrap;
}
.description-tag {
max-width: 100%;
}
.delete-action {
width: 4.5rem;
height: 100%;
border-radius: 0;
}
}
.sku-popup {
max-height: 100%;
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0px));
overflow-y: auto;
.sku-popup-header {
gap: .5rem;
display: flex;
padding: 0 1rem .75rem;
align-items: center;
justify-content: space-between;
}
.sku-heading {
gap: .5rem;
min-width: 0;
display: flex;
align-items: center;
.sku-index {
width: 1.75rem;
height: 1.75rem;
color: var(--td-brand-color);
flex: none;
display: flex;
align-items: center;
justify-content: center;
background: var(--td-brand-color-1);
border-radius: .625rem;
font-size: .6875rem;
font-weight: 700;
}
}
.sku-spec-panel {
margin: 0 .75rem .75rem;
padding: .75rem;
background: var(--td-bg-color-secondarycontainer);
border-radius: .875rem;
}
.section-title {
gap: .5rem;
display: flex;
align-items: baseline;
justify-content: space-between;
span {
color: var(--td-text-color-secondary);
font-size: .75rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.sku-spec-list {
gap: .5rem;
display: grid;
margin-top: .625rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sku-spec-item {
gap: .375rem;
min-width: 0;
display: flex;
padding: .5rem .625rem;
align-items: baseline;
background: var(--td-bg-color-container);
border-radius: .625rem;
.spec-name {
color: var(--td-text-color-secondary);
font-size: .75rem;
flex: none;
}
strong {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.sku-spec-empty {
margin-top: .5rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
}
.sku-form {
margin: 0 .75rem;
.setting-title {
margin-top: .5rem;
padding: .75rem 0 .25rem;
border-top: 1px solid var(--td-component-border);
}
}
:deep(.t-form-item) {
padding-right: 0;
padding-left: 0;
}
:deep(.photo-scroller) {
margin: .5rem .75rem 0;
}
.unit-section {
margin-top: .75rem;
border-top: 1px solid var(--td-component-border);
}
.unit-heading {
gap: .5rem;
display: flex;
padding: .75rem 1rem .5rem;
align-items: baseline;
justify-content: space-between;
span {
color: var(--td-text-color-secondary);
font-size: .75rem;
text-align: right;
}
}
.unit-summary-list {
gap: .5rem;
display: flex;
padding: 0 1rem .5rem;
flex-direction: column;
}
.unit-summary {
gap: .5rem;
min-width: 0;
display: flex;
padding: .625rem .75rem;
align-items: center;
background: var(--td-bg-color-secondarycontainer);
border-radius: .75rem;
justify-content: space-between;
strong {
max-width: 5rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
span {
min-width: 0;
color: var(--td-text-color-secondary);
font-size: .75rem;
text-align: right;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.unit-button {
margin: 0 1rem .75rem;
}
}
:global(html.theme-dark) {
.sku-popup {
.sku-spec-panel,
.unit-summary {
background: rgba(148, 163, 184, .12);
}
}
}
</style>
@@ -0,0 +1,198 @@
<template>
<bottom-popup
v-model="visible"
title="统一配置 SKU 单位"
height="min(88vh, 48rem)"
left-button-text="关闭"
right-button-text="应用到全部"
:z-index="batchEditorZIndex"
:overlay-props="{ zIndex: batchEditorZIndex - 1 }"
@left-button-click="close"
@right-button-click="applyUnits"
>
<div class="batch-editor">
<div class="editor-context">
<strong>批量配置销售单位</strong>
<span v-text="`以下单位将应用到全部 ${props.skuCount} 个 SKU 明细`" />
</div>
<div class="unit-list">
<div v-for="(unit, unitIndex) in unitList" :key="unit.clientKey" class="unit-card">
<div class="unit-card-header">
<strong v-text="`单位 ${unitIndex + 1}`" />
<t-button v-if="1 < unitList.length" theme="danger" variant="text" size="extra-small" content="删除" @click="removeUnit(unitIndex)" />
</div>
<t-form class="unit-form" label-align="left" label-width="6rem">
<t-form-item label="单位名称" required-mark>
<t-input v-model.trim="unit.name" borderless maxlength="32" placeholder="如 件、盒、箱" />
</t-form-item>
<t-form-item label="基础单位" content-align="right">
<t-switch v-model="unit.isBase" size="small" :disabled="unitList.length === 1" @change="setBaseUnit(unitIndex)" />
</t-form-item>
<t-form-item label="换算数量" help="1 个当前单位对应的基础单位数量" required-mark>
<t-input v-model.trim="unit.baseQuantityPerUnit" type="number" align="right" borderless :disabled="unit.isBase" placeholder="1" />
</t-form-item>
<t-form-item label="售价(元)">
<t-input v-model.trim="unit.salePriceText" type="number" align="right" borderless placeholder="选填" />
</t-form-item>
<t-form-item label="允许采购" content-align="right">
<t-switch v-model="unit.allowPurchase" size="small" :disabled="props.productType === 'SERVICE'" />
</t-form-item>
<t-form-item label="允许销售" content-align="right">
<t-switch v-model="unit.allowSale" size="small" />
</t-form-item>
</t-form>
</div>
<t-button class="add-button" variant="dashed" size="small" content="添加销售单位" @click="addUnit" />
</div>
</div>
</bottom-popup>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import type { ProductType } from "@/types/Product";
import type { ProductSkuUnitForm } from "@/types/ProductEditor";
import { Message } from "@/utils/message";
defineOptions({ name: "ProductSkuUnitBatchEditor" });
const props = defineProps<{
modelValue: boolean;
productType: ProductType;
skuCount: number;
}>();
const unitList = defineModel<ProductSkuUnitForm[]>("unitList", { required: true });
const emit = defineEmits<{
"update:modelValue": [value: boolean];
apply: [];
close: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: value => handleVisibleChange(value)
});
const batchEditorZIndex = 12000;
function handleVisibleChange(value: boolean): void {
emit("update:modelValue", value);
if (!value) {
emit("close");
}
}
function close(): void {
visible.value = false;
}
function applyUnits(): void {
if (!unitList.value.length) {
Message.warning("至少需要配置一个销售单位");
return;
}
emit("apply");
close();
}
function addUnit(): void {
unitList.value.push({
clientKey: window.crypto.randomUUID(),
name: unitList.value[0]?.name || "件",
baseQuantityPerUnit: "",
salePriceText: "",
isBase: false,
allowPurchase: props.productType !== "SERVICE",
allowSale: true,
sort: unitList.value.length
});
}
function removeUnit(index: number): void {
if (unitList.value.length < 2) {
Message.warning("每个 SKU 至少需要一个单位");
return;
}
const wasBase = unitList.value[index]?.isBase;
unitList.value.splice(index, 1);
if (wasBase) {
setBaseUnit(0);
}
}
function setBaseUnit(index: number): void {
unitList.value.forEach((unit, unitIndex) => { unit.isBase = unitIndex === index; });
const unit = unitList.value[index];
if (unit) {
unit.baseQuantityPerUnit = "1";
}
}
</script>
<style scoped lang="less">
.batch-editor {
max-height: 100%;
overflow-y: auto;
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0px));
.editor-context {
gap: .25rem;
display: flex;
margin: 0 1rem .75rem;
padding: .75rem;
background: var(--td-bg-color-secondarycontainer);
border-radius: .875rem;
flex-direction: column;
span {
color: var(--td-text-color-secondary);
font-size: .75rem;
}
}
.unit-list {
gap: .75rem;
display: flex;
padding: 0 1rem 1rem;
flex-direction: column;
}
.unit-card {
border: 1px solid var(--td-component-border);
padding: .5rem 0;
border-radius: .75rem;
}
.unit-card-header {
gap: .5rem;
display: flex;
padding: 0 .75rem .5rem;
align-items: center;
justify-content: space-between;
}
.unit-form {
margin: 0 .75rem;
:deep(.t-form-item) {
padding-right: 0;
padding-left: 0;
}
}
.add-button {
align-self: flex-start;
}
}
:global(html.theme-dark) .batch-editor {
.editor-context {
background: rgba(148, 163, 184, .12);
}
.unit-card {
border-color: rgba(148, 163, 184, .3);
}
}
</style>
@@ -0,0 +1,252 @@
<template>
<bottom-popup
v-model="visible"
title="配置 SKU 单位"
height="min(88vh, 48rem)"
left-button-text="关闭"
right-button-text="完成"
:z-index="unitEditorZIndex"
:overlay-props="{ zIndex: unitEditorZIndex - 1 }"
@left-button-click="close"
@right-button-click="close"
>
<div v-if="sku" class="unit-editor">
<div class="editor-context">
<span class="context-label">当前 SKU 明细</span>
<strong class="context-title" v-text="sku.name || sku.specSummary || '默认 SKU'" />
<div v-if="skuSpecEntries.length" class="context-spec-list">
<div v-for="entry in skuSpecEntries" :key="`${entry.specName}-${entry.valueName}`" class="context-spec">
<span v-text="entry.specName" />
<strong v-text="entry.valueName" />
</div>
</div>
<span v-else class="context-empty">无多规格</span>
<div class="context-count" v-text="`${sku.unitList.length} 个单位`" />
</div>
<div class="unit-list">
<div v-for="(unit, unitIndex) in sku.unitList" :key="unit.clientKey" class="unit-card">
<div class="unit-card-header">
<strong v-text="`单位 ${unitIndex + 1}`" />
<t-button v-if="1 < sku.unitList.length" theme="danger" variant="text" size="extra-small" content="删除" @click="removeUnit(unitIndex)" />
</div>
<t-form class="unit-form" label-align="left" label-width="6rem">
<t-form-item label="单位名称" required-mark>
<t-input v-model.trim="unit.name" borderless maxlength="32" placeholder="如 件、盒、箱" />
</t-form-item>
<t-form-item label="基础单位" content-align="right">
<t-switch v-model="unit.isBase" size="small" :disabled="sku.unitList.length === 1" @change="setBaseUnit(unitIndex)" />
</t-form-item>
<t-form-item label="换算数量" help="1 个当前单位对应的基础单位数量" required-mark>
<t-input v-model.trim="unit.baseQuantityPerUnit" type="number" align="right" borderless :disabled="unit.isBase" placeholder="1" />
</t-form-item>
<t-form-item label="售价(元)">
<t-input v-model.trim="unit.salePriceText" type="number" align="right" borderless placeholder="选填" />
</t-form-item>
<t-form-item label="允许采购" content-align="right">
<t-switch v-model="unit.allowPurchase" size="small" :disabled="props.productType === 'SERVICE'" />
</t-form-item>
<t-form-item label="允许销售" content-align="right">
<t-switch v-model="unit.allowSale" size="small" />
</t-form-item>
</t-form>
</div>
<t-button class="add-button" variant="dashed" size="small" content="添加 SKU 单位" @click="addUnit" />
</div>
</div>
</bottom-popup>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import type { ProductSkuUnit, ProductType } from "@/types/Product";
import type { ProductSkuForm, ProductSkuUnitForm, ProductSpecForm } from "@/types/ProductEditor";
import { Message } from "@/utils/message";
import { formatProductAmount, getProductSkuSpecEntries } from "@/utils/product";
defineOptions({ name: "ProductSkuUnitEditor" });
const props = defineProps<{
modelValue: boolean;
productType: ProductType;
specList: ProductSpecForm[];
}>();
const sku = defineModel<ProductSkuForm | undefined>("sku");
const emit = defineEmits<{
"update:modelValue": [value: boolean];
close: [];
}>();
const visible = computed({
get: () => props.modelValue,
set: value => handleVisibleChange(value)
});
const unitEditorZIndex = 12100;
const skuSpecEntries = computed(() => sku.value ? getProductSkuSpecEntries(sku.value, props.specList) : []);
function handleVisibleChange(value: boolean): void {
emit("update:modelValue", value);
if (!value) {
emit("close");
}
}
function close(): void {
visible.value = false;
}
function createUnit(unit?: Partial<ProductSkuUnit>, defaultName = "件"): ProductSkuUnitForm {
return {
...unit,
id: unit?.id,
clientKey: window.crypto.randomUUID(),
name: unit?.name || defaultName,
baseQuantityPerUnit: unit?.baseQuantityPerUnit ?? "1",
salePriceText: unit?.salePrice === null || unit?.salePrice === undefined ? "" : formatProductAmount(unit.salePrice),
isBase: unit?.isBase ?? true,
allowPurchase: unit?.allowPurchase ?? props.productType !== "SERVICE",
allowSale: unit?.allowSale ?? true,
sort: unit?.sort ?? 0
};
}
function addUnit(): void {
if (!sku.value) {
return;
}
sku.value.unitList.push(createUnit({ isBase: false, baseQuantityPerUnit: "" }, sku.value.unitList[0]?.name || "件"));
}
function removeUnit(index: number): void {
if (!sku.value) {
return;
}
if (sku.value.unitList.length < 2) {
Message.warning("每个 SKU 至少需要一个单位");
return;
}
const wasBase = sku.value.unitList[index]?.isBase;
sku.value.unitList.splice(index, 1);
if (wasBase && sku.value.unitList[0]) {
sku.value.unitList[0].isBase = true;
sku.value.unitList[0].baseQuantityPerUnit = "1";
}
}
function setBaseUnit(index: number): void {
if (!sku.value) {
return;
}
sku.value.unitList.forEach((unit, unitIndex) => { unit.isBase = unitIndex === index; });
const unit = sku.value.unitList[index];
if (unit) {
unit.baseQuantityPerUnit = "1";
}
}
</script>
<style scoped lang="less">
.unit-editor {
max-height: 100%;
overflow-y: auto;
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0px));
.editor-context {
margin: 0 1rem .75rem;
padding: .75rem;
background: var(--td-bg-color-secondarycontainer);
border-radius: .875rem;
.context-label,
.context-empty {
color: var(--td-text-color-secondary);
font-size: .75rem;
}
.context-label {
display: block;
margin-bottom: .25rem;
}
.context-title {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.context-spec-list {
gap: .375rem;
display: flex;
margin-top: .625rem;
flex-wrap: wrap;
}
.context-spec {
gap: .25rem;
display: flex;
padding: .25rem .5rem;
background: var(--td-bg-color-container);
border-radius: .5rem;
font-size: .75rem;
span {
color: var(--td-text-color-secondary);
}
}
.context-count {
margin-top: .625rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
}
}
.unit-list {
gap: .75rem;
display: flex;
padding: 0 1rem 1rem;
flex-direction: column;
}
.unit-card {
border: 1px solid var(--td-component-border);
padding: .5rem 0;
border-radius: .75rem;
}
.unit-card-header {
gap: .5rem;
display: flex;
padding: 0 .75rem .5rem;
align-items: center;
justify-content: space-between;
}
.unit-form {
margin: 0 .75rem;
}
:deep(.t-form-item) {
padding-right: 0;
padding-left: 0;
}
.add-button {
align-self: flex-start;
}
}
:global(html.theme-dark) .unit-editor {
.editor-context,
.context-spec {
background: rgba(148, 163, 184, .12);
}
.unit-card {
border-color: rgba(148, 163, 184, .3);
}
}
</style>
@@ -22,12 +22,18 @@
<td>
<div class="product-name" v-text="item.productName || '未命名产品'" />
<div class="product-type" v-text="getProductTypeText(item.productType)" />
<div v-if="hasLowStockSku(item)" class="sku-list">
<div v-for="sku in getLowStockSkuList(item)" :key="`${sku.skuId}-${sku.skuCode}`" class="sku-item">
<span v-text="getSkuName(sku)" />
<span v-text="`可用 ${formatQuantity(sku.availableQuantity)}`" />
</div>
</div>
</td>
<td class="number" v-text="formatQuantity(item.availableQuantity)" />
<td class="number" v-text="formatQuantity(item.reservedQuantity)" />
<td class="number" v-text="formatQuantity(item.minStockQuantity)" />
<td>
<t-tag v-if="item.lowStock === 1" theme="danger" variant="light" content="低库存" />
<t-tag v-if="hasLowStockSku(item)" theme="danger" variant="light" :content="`低库存 ${formatQuantity(item.lowStockSkuCount)} 个规格`" />
<t-tag v-else theme="success" variant="light" content="正常" />
</td>
</tr>
@@ -38,7 +44,7 @@
</template>
<script setup lang="ts">
import type { GaoProductStockHealthView } from "@/types/GaoChart";
import type { GaoProductSkuStockHealthView, GaoProductStockHealthView } from "@/types/GaoChart";
import { formatQuantity, getNumber } from "@/pages/product/component/chartUtils";
defineOptions({
@@ -47,14 +53,27 @@ defineOptions({
const props = defineProps<{
list: GaoProductStockHealthView[];
skuHealthList: GaoProductSkuStockHealthView[];
loading: boolean;
}>();
const sortedList = computed(() => [...props.list].sort((left, right) => {
const lowStockDiff = getNumber(right.lowStock) - getNumber(left.lowStock);
const lowStockDiff = getNumber(right.lowStockSkuCount) - getNumber(left.lowStockSkuCount);
return lowStockDiff || getNumber(left.availableQuantity) - getNumber(right.availableQuantity);
}));
function hasLowStockSku(item: GaoProductStockHealthView): boolean {
return 0 < getNumber(item.lowStockSkuCount);
}
function getLowStockSkuList(item: GaoProductStockHealthView): GaoProductSkuStockHealthView[] {
return props.skuHealthList.filter(sku => sku.productId === item.productId);
}
function getSkuName(item: GaoProductSkuStockHealthView): string {
return [item.skuName || "未命名规格", item.specSummary].filter(Boolean).join(" · ");
}
function getProductTypeText(value?: string): string {
return ({ SERVICE: "服务", GOODS: "商品", CONSUMABLE: "耗材" } as Record<string, string>)[value || ""] || "未分类";
}
@@ -127,6 +146,21 @@ function getProductTypeText(value?: string): string {
font-size: .6875rem;
}
.sku-list {
gap: .25rem;
margin-top: .375rem;
display: flex;
flex-direction: column;
}
.sku-item {
gap: .5rem;
color: #b42318;
display: flex;
font-size: .6875rem;
justify-content: space-between;
}
.table-state {
padding: 1.5rem 0;
display: flex;
@@ -148,6 +182,10 @@ function getProductTypeText(value?: string): string {
color: #cbd5e1;
border-top-color: rgba(148, 163, 184, .2);
}
.sku-item {
color: #fda29b;
}
}
}
</style>