diff --git a/src/components/ProductSelector.vue b/src/components/ProductSelector.vue
index 31ee31f..8ad29eb 100644
--- a/src/components/ProductSelector.vue
+++ b/src/components/ProductSelector.vue
@@ -67,6 +67,7 @@ import { getProductPhotoURL } from "@/utils/product";
interface Props {
modelValue: { productId: string; productUnitId: string } | undefined;
+ selectedProduct?: Product;
label?: string;
requiredMark?: boolean;
placeholder?: string;
@@ -82,7 +83,7 @@ const selectedText = computed(() => {
if (!props.modelValue) {
return "";
}
- const product = productList.value.find(item => item.id === props.modelValue?.productId);
+ const product = props.selectedProduct || productList.value.find(item => item.id === props.modelValue?.productId);
return product?.name || "";
});
const filteredProductList = computed(() => {
diff --git a/src/pages/package/PackageConfigDetail.vue b/src/pages/package/PackageConfigDetail.vue
index 6d021ca..539f811 100644
--- a/src/pages/package/PackageConfigDetail.vue
+++ b/src/pages/package/PackageConfigDetail.vue
@@ -2,10 +2,23 @@
@@ -13,13 +26,36 @@
-
+
+ >
+
+
+
+
+
+
+
+
{
const id = String(route.params.id || "");
if (!id) {
@@ -157,55 +198,123 @@ onUnmounted(() => navBarStore.clearRight());
.hero {
margin: 0 1rem;
padding: 1rem;
- background: var(--td-bg-color-container);
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(135deg, #ecfeff 0%, #eff6ff 48%, #fff7ed 100%);
+ border: 1px solid rgba(255, 255, 255, .72);
box-shadow: var(--td-shadow-1);
- border-radius: 1rem;
+ border-radius: 1.25rem;
+
+ &::before {
+ content: "";
+ width: 9rem;
+ height: 9rem;
+ right: -3rem;
+ top: -4.5rem;
+ background: rgba(45, 212, 191, .2);
+ border-radius: 50%;
+ filter: blur(1rem);
+ position: absolute;
+ }
+
+ &::after {
+ content: "";
+ width: 7rem;
+ height: 7rem;
+ left: -3.5rem;
+ bottom: -4rem;
+ background: rgba(96, 165, 250, .16);
+ border-radius: 50%;
+ filter: blur(.75rem);
+ position: absolute;
+ }
+
+ > * {
+ z-index: 1;
+ position: relative;
+ }
+
+ .header {
+ display: flex;
+ justify-content: space-between;
+
+ .eyebrow {
+ color: var(--td-brand-color);
+ display: block;
+ font-size: .6875rem;
+ font-weight: 700;
+ letter-spacing: .08em;
+ }
+ }
.title {
- gap: .5rem;
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
-
- .eyebrow {
- color: var(--td-brand-color);
- display: block;
- font-size: .6875rem;
- font-weight: 700;
- letter-spacing: .08em;
- }
-
- h1 {
margin: 0;
font-size: 1.25rem;
line-height: 1.35;
}
- p {
+ .description {
color: var(--td-text-color-secondary);
margin: .5rem 0 0;
line-height: 1.5;
}
- .hero-meta {
+ .items-price {
gap: .75rem;
display: flex;
margin-top: .75rem;
color: var(--td-text-color-secondary);
- align-items: center;
+ align-items: end;
justify-content: space-between;
- }
- strong {
- color: #c2410c;
- font-size: 1.2rem;
- white-space: nowrap;
+ .price {
+
+ .value {
+ font-size: 1.5rem;
+ }
+ }
+ }
+ }
+
+ .product {
+
+ .item {
+ padding: 10px;
+
+ .item-title {
+ gap: .5rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .avatar {
+ width: 3.5rem;
+ height: 3.5rem;
+ }
}
}
}
+:deep(.package-config-detail .avatar .t-image__img) {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: inherit;
+}
+
:global(html.theme-dark) .package-config-detail .hero {
+ background: linear-gradient(135deg, rgba(15, 118, 110, .3), rgba(37, 99, 235, .28) 52%, rgba(194, 65, 12, .24));
+ border-color: rgba(153, 246, 228, .16);
+
+ &::before {
+ background: rgba(45, 212, 191, .14);
+ }
+
+ &::after {
+ background: rgba(96, 165, 250, .12);
+ }
+
.eyebrow {
color: #99f6e4;
}
diff --git a/src/pages/package/PackageConfigEdit.vue b/src/pages/package/PackageConfigEdit.vue
index e1350fc..0063700 100644
--- a/src/pages/package/PackageConfigEdit.vue
+++ b/src/pages/package/PackageConfigEdit.vue
@@ -1,9 +1,6 @@
-
-
-
@@ -36,33 +33,75 @@
label-width="6rem"
>
-
-
+
+
+
+
+
+
+
+
-
+
+
+ 预估价(原价)
+
+
+ >
+
+
+
+
+
+ 产品权益预估价(原价)
+
+
-
-
-
@@ -83,13 +122,13 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import ProductAPI from "@/api/ProductAPI";
-import type { Product } from "@/types/Product";
+import type { Product, ProductUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
+import { formatProductAmount, getProductQuantityStep } from "@/utils/product";
-type PickerValue = string | number;
type PackageConfigFormItem = Partial & { clientKey: string };
defineOptions({ name: "PackageConfigEdit" });
@@ -117,10 +156,8 @@ const form = reactive({
const standardAmountText = ref("");
const beginAtText = ref("");
const endAtText = ref("");
-const storePickerVisible = ref(false);
-const storePickerDraft = ref([]);
const statusPickerVisible = ref(false);
-const statusPickerDraft = ref([]);
+const statusPickerDraft = ref>([]);
const beginPickerVisible = ref(false);
const endPickerVisible = ref(false);
const beginDraft = ref("");
@@ -130,8 +167,6 @@ const statusOptions = [
{ label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" }
];
-const storePickerColumns = computed(() => [[...currentStoreStore.storeList].map(item => ({ label: item.name || item.code || item.id || "未知门店", value: item.id || "" }))]);
-const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === form.storeId)?.name || form.storeId || "请选择门店");
function createItem(item?: Partial): PackageConfigFormItem {
return {
@@ -144,29 +179,87 @@ function createItem(item?: Partial): PackageConfigFormItem {
const productCache = new Map();
+function getProduct(item: PackageConfigFormItem): Product | undefined {
+ return productCache.get(item.productId || "");
+}
+
+function getQuantityStep(item: PackageConfigFormItem): number {
+ return getProductQuantityStep(getProduct(item));
+}
+
+const estimatedOriginalAmount = computed(() => {
+ let amount = 0;
+ for (const item of form.itemList) {
+ const itemAmount = calculateOriginalAmount(item);
+ if (itemAmount === undefined) {
+ return undefined;
+ }
+ amount += itemAmount;
+ }
+ return amount;
+});
+const estimatedOriginalAmountText = computed(() => estimatedOriginalAmount.value === undefined ? "待完善" : `¥${formatProductAmount(estimatedOriginalAmount.value)}`);
+
async function applyProduct(index: number, value?: { productId: string; productUnitId: string }): Promise {
if (!value) {
return;
}
const product = productCache.get(value.productId) || await ProductAPI.detail(value.productId);
productCache.set(value.productId, product);
- const unit = product.unitList?.find(item => item.id === value.productUnitId);
+ const saleableUnits = (product.unitList || []).filter(unit => unit.allowSale !== false && unit.id);
+ const productUnitId = saleableUnits.length < 2 ? saleableUnits[0]?.id || "" : value.productUnitId;
+ const unit = saleableUnits.find(item => item.id === productUnitId);
Object.assign(form.itemList[index], {
productId: value.productId,
- productUnitId: value.productUnitId,
+ productUnitId,
productNameSnapshot: product.name,
unitSnapshot: unit?.name || "",
baseQuantityPerUnitSnapshot: unit?.baseQuantityPerUnit
});
}
-function addItem(): void {
- form.itemList.push(createItem());
+function getSaleableUnits(item: PackageConfigFormItem): ProductUnit[] {
+ return (productCache.get(item.productId || "")?.unitList || []).filter(unit => unit.allowSale !== false && unit.id);
}
-function openStorePicker(): void {
- storePickerDraft.value = [form.storeId];
- storePickerVisible.value = true;
+function selectProductUnit(index: number, unit: ProductUnit): void {
+ const item = form.itemList[index];
+ const product = productCache.get(item.productId || "");
+ if (!product?.id || !unit.id) {
+ return;
+ }
+ Object.assign(item, {
+ productUnitId: unit.id,
+ productNameSnapshot: product.name,
+ unitSnapshot: unit.name,
+ baseQuantityPerUnitSnapshot: unit.baseQuantityPerUnit
+ });
+}
+
+function calculateOriginalAmount(item: PackageConfigFormItem): number | undefined {
+ const quantity = normalizePackageQuantity(item.defaultQuantity ?? "");
+ const salePrice = productCache.get(item.productId || "")?.unitList?.find(unit => unit.id === item.productUnitId)?.salePrice;
+ if (!quantity || quantity === "0" || salePrice === undefined || salePrice === null) {
+ return undefined;
+ }
+ const [integerPart, fractionPart = ""] = quantity.split(".");
+ const quantityScale = 10n ** BigInt(fractionPart.length);
+ const quantityValue = BigInt(`${integerPart}${fractionPart}`);
+ const amount = BigInt(salePrice) * quantityValue;
+ if (amount % quantityScale !== 0n) {
+ return undefined;
+ }
+ const result = Number(amount / quantityScale);
+ return Number.isSafeInteger(result) ? result : undefined;
+}
+
+function getItemOriginalAmountText(item: PackageConfigFormItem): string {
+ const amount = calculateOriginalAmount(item);
+ return amount === undefined ? "待完善" : `¥${formatProductAmount(amount)}`;
+}
+
+function addItem(): void {
+ form.itemList.push(createItem());
}
function openStatusPicker(): void {
@@ -182,14 +275,6 @@ function removeItem(index: number): void {
Message.warning("套餐至少需要一项产品权益");
}
-function confirmStore(value: Array): void {
- const storeId = String(value[0] || "");
- if (storeId) {
- form.storeId = storeId;
- }
- storePickerVisible.value = false;
-}
-
function confirmStatus(value: Array): void {
const status = String(value[0] || "");
if (status === "DRAFT" || status === "ACTIVE" || status === "INACTIVE") {
@@ -227,8 +312,8 @@ function resolveTimestamp(value: string): number | undefined {
}
function validateForm(): boolean {
- if (isGlobalManager.value && !form.storeId) {
- Message.warning("请选择所属门店");
+ if (!form.storeId) {
+ Message.warning("当前门店未就绪");
return false;
}
if (!form.name.trim()) {
@@ -251,6 +336,10 @@ function validateForm(): boolean {
Message.warning("产品单位和正数量不能为空");
return false;
}
+ if (getProduct(item) && !getProduct(item)!.allowDecimalQuantity && quantity.includes(".")) {
+ Message.warning("该产品不允许小数数量");
+ return false;
+ }
if (keySet.has(key)) {
Message.warning("产品名称和单位组合不能重复");
return false;
@@ -311,23 +400,27 @@ async function loadData(): Promise {
} else {
await currentStoreStore.loadLoginStore();
}
- if (!form.storeId) {
- form.storeId = currentStoreStore.currentStoreId;
- }
+ const currentStoreId = currentStoreStore.currentStoreId;
if (isCreateMode.value) {
+ form.storeId = currentStoreId;
addItem();
return;
}
const result = await PackageAPI.configDetail(routeId.value);
+ const itemList = (result.itemList || []).map(createItem);
+ const productIdList = [...new Set(itemList.map(item => item.productId).filter((id): id is string => !!id))];
+ await Promise.all(productIdList.map(async productId => {
+ productCache.set(productId, await ProductAPI.detail(productId));
+ }));
Object.assign(form, {
id: result.id || routeId.value,
- storeId: result.storeId || form.storeId,
+ storeId: result.storeId || currentStoreId,
name: result.name || "",
description: result.description || "",
status: result.status || "DRAFT",
beginAt: result.beginAt || undefined,
endAt: result.endAt || undefined,
- itemList: (result.itemList || []).map(createItem)
+ itemList
});
standardAmountText.value = formatPackageAmountInput(result.standardAmount);
beginAtText.value = result.beginAt ? Time.toDateTime(result.beginAt) : "";
@@ -367,28 +460,89 @@ onUnmounted(() => navBarStore.clearRight());
}
.items {
- padding: 1rem 0;
+ gap: .75rem;
+ display: flex;
+ margin-top: .75rem;
+ flex-direction: column;
.item {
- gap: .5rem;
- border: 1px dashed var(--td-component-border);
- padding: .5rem 1rem;
- margin-bottom: .75rem;
- border-radius: 1rem;
+ border: 1px dashed var(--td-border-level-1-color);
+ padding: .5rem;
+ border-radius: .875rem;
.header {
+ gap: .625rem;
display: flex;
- justify-content: space-between;
+ align-items: center;
+
+ .no {
+ 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;
+ }
+
+ strong {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--td-text-color-primary);
+ font-size: .875rem;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
}
- :deep(.t-form__item) {
- padding: .25rem 0;
+ .unit-list {
+ gap: .5rem;
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+
+ .unit-empty {
+ color: var(--td-text-color-secondary);
+ font-size: .75rem;
+ }
+
+ .price {
+ display: flex;
+ align-items: baseline;
+ justify-content: end;
+
+ .red {
+ font-size: 1.5rem;
+ }
+ }
+ }
+
+ .add {
+ --td-button-border-radius: 1rem;
+
+ gap: .375rem;
+ color: var(--td-brand-color);
+ margin: .75rem 1rem;
+ }
+
+ .summary {
+ margin: 0 1rem;
+ display: flex;
+ align-items: baseline;
+ padding-bottom: 1rem;
+ justify-content: space-between;
+
+ .red {
+ font-size: 1.25rem;
}
}
}
-
- .add-item {
- border-radius: .75rem;
- }
}
diff --git a/src/pages/package/PackageConfigList.vue b/src/pages/package/PackageConfigList.vue
index 3fffec0..a416bab 100644
--- a/src/pages/package/PackageConfigList.vue
+++ b/src/pages/package/PackageConfigList.vue
@@ -25,33 +25,53 @@
>
-
-
+
-
-
-
-
+
-
- 删除
-
-
+
@@ -61,16 +81,16 @@