fix PackageConfig*.vue style

This commit is contained in:
Timi
2026-09-06 01:10:22 +08:00
parent 3698f7151f
commit d929dbe622
7 changed files with 472 additions and 142 deletions
+2 -1
View File
@@ -67,6 +67,7 @@ import { getProductPhotoURL } from "@/utils/product";
interface Props { interface Props {
modelValue: { productId: string; productUnitId: string } | undefined; modelValue: { productId: string; productUnitId: string } | undefined;
selectedProduct?: Product;
label?: string; label?: string;
requiredMark?: boolean; requiredMark?: boolean;
placeholder?: string; placeholder?: string;
@@ -82,7 +83,7 @@ const selectedText = computed(() => {
if (!props.modelValue) { if (!props.modelValue) {
return ""; 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 || ""; return product?.name || "";
}); });
const filteredProductList = computed(() => { const filteredProductList = computed(() => {
+142 -33
View File
@@ -2,10 +2,23 @@
<div v-can="permissionCodes.packageConfig.read" class="package-config-detail"> <div v-can="permissionCodes.packageConfig.read" class="package-config-detail">
<template v-if="config"> <template v-if="config">
<section class="hero" v-entrance-transition> <section class="hero" v-entrance-transition>
<span class="eyebrow" v-text="'套餐管理'" /> <div class="header">
<div class="title"><h1 v-text="config.name || '未命名套餐'" /><t-tag :content="getPackageConfigStatusText(config.status)" variant="light-outline" :theme="getStatusTheme(config.status)" /></div> <span class="eyebrow" v-text="'套餐管理'" />
<p v-text="config.description || '暂无套餐说明'" /> <t-tag
<div class="hero-meta"><span v-text="`${config.itemList?.length || 0} 项产品权益`" /><strong v-text="formatPackageAmount(config.standardAmount)" /></div> :content="getPackageConfigStatusText(config.status)"
variant="light-outline"
:theme="getStatusTheme(config.status)"
/>
</div>
<h1 class="title" v-text="config.name || '未命名套餐'" />
<p class="description" v-text="config.description || '暂无套餐说明'" />
<div class="items-price">
<span v-text="`${config.itemList?.length || 0} 项产品权益`" />
<div class="price text-bold">
<span class="red">¥</span>
<span class="red value" v-text="formatPackageAmount(config.standardAmount)" />
</div>
</div>
</section> </section>
<t-cell-group title="基本信息" theme="card" v-entrance-transition="70"> <t-cell-group title="基本信息" theme="card" v-entrance-transition="70">
<t-cell title="标准价" :note="formatPackageAmount(config.standardAmount)" /> <t-cell title="标准价" :note="formatPackageAmount(config.standardAmount)" />
@@ -13,13 +26,36 @@
<t-cell title="终止日期" :note="config.endAt ? Time.toDateTime(config.endAt) : '不限'" /> <t-cell title="终止日期" :note="config.endAt ? Time.toDateTime(config.endAt) : '不限'" />
<t-cell title="最后修改人" :note="config.updater?.name || config.updater?.nick || '未知'" /> <t-cell title="最后修改人" :note="config.updater?.name || config.updater?.nick || '未知'" />
</t-cell-group> </t-cell-group>
<t-cell-group title="产品权益" theme="card" v-entrance-transition="140"> <t-cell-group class="product" title="产品权益" theme="card" v-entrance-transition="140">
<t-cell <t-cell
class="item"
v-for="item in config.itemList || []" v-for="item in config.itemList || []"
:title="item.productNameSnapshot"
:key="item.id || `${item.productId}-${item.productUnitId}`" :key="item.id || `${item.productId}-${item.productUnitId}`"
:title="item.productNameSnapshot || '未命名产品'"
:note="`${item.defaultQuantity} ${item.unitSnapshot || ''}${item.remark ? `|${item.remark}` : ''}`" :note="`${item.defaultQuantity} ${item.unitSnapshot || ''}${item.remark ? `|${item.remark}` : ''}`"
/> >
<template #leftIcon>
<t-image
v-if="getItemPhotoURL(item)"
:src="getItemPhotoURL(item)"
:alt="item.productNameSnapshot || '产品图片'"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
/>
</template>
<template #description>
<t-tag
:content="getProductTypeText(item.product?.productType)"
theme="primary"
variant="light"
/>
</template>
</t-cell>
<div v-if="!config.itemList?.length" class="empty" v-text="'暂无产品权益'" /> <div v-if="!config.itemList?.length" class="empty" v-text="'暂无产品权益'" />
</t-cell-group> </t-cell-group>
<t-button <t-button
@@ -48,11 +84,12 @@ import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI"; import PackageAPI from "@/api/PackageAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package"; import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package"; import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { getProductPhotoURL as getProductPhotoUrl, getProductTypeText } from "@/utils/product";
defineOptions({ name: "PackageConfigDetail" }); defineOptions({ name: "PackageConfigDetail" });
@@ -75,6 +112,10 @@ function getStatusTheme(status?: PackageConfigStatus): "success" | "warning" | "
return status === "ACTIVE" ? "success" : status === "INACTIVE" ? "warning" : "default"; return status === "ACTIVE" ? "success" : status === "INACTIVE" ? "warning" : "default";
} }
function getItemPhotoURL(item: PackageConfigItem): string {
return getProductPhotoUrl(item.product || {});
}
async function loadData(): Promise<void> { async function loadData(): Promise<void> {
const id = String(route.params.id || ""); const id = String(route.params.id || "");
if (!id) { if (!id) {
@@ -157,55 +198,123 @@ onUnmounted(() => navBarStore.clearRight());
.hero { .hero {
margin: 0 1rem; margin: 0 1rem;
padding: 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); 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 { .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; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.35; line-height: 1.35;
} }
p { .description {
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
margin: .5rem 0 0; margin: .5rem 0 0;
line-height: 1.5; line-height: 1.5;
} }
.hero-meta { .items-price {
gap: .75rem; gap: .75rem;
display: flex; display: flex;
margin-top: .75rem; margin-top: .75rem;
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
align-items: center; align-items: end;
justify-content: space-between; justify-content: space-between;
}
strong { .price {
color: #c2410c;
font-size: 1.2rem; .value {
white-space: nowrap; 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 { :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 { .eyebrow {
color: #99f6e4; color: #99f6e4;
} }
+211 -57
View File
@@ -1,9 +1,6 @@
<template> <template>
<div v-can="savePermissionCode" class="package-config-edit"> <div v-can="savePermissionCode" class="package-config-edit">
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition> <t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
<t-form-item v-if="isGlobalManager" label="所属门店" arrow @click="openStorePicker">
<span class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="套餐名称" required-mark> <t-form-item label="套餐名称" required-mark>
<t-input v-model="form.name" borderless maxlength="100" placeholder="请输入套餐名称" /> <t-input v-model="form.name" borderless maxlength="100" placeholder="请输入套餐名称" />
</t-form-item> </t-form-item>
@@ -36,33 +33,75 @@
label-width="6rem" label-width="6rem"
> >
<div class="header"> <div class="header">
<span class="no" v-text="String(index + 1).padStart(2, '0')" />
<strong v-text="item.productNameSnapshot || '未选择产品'" /> <strong v-text="item.productNameSnapshot || '未选择产品'" />
<t-button theme="danger" variant="text" size="small" @click="removeItem(index)">删除</t-button> <t-button
v-if="1 < form.itemList.length"
theme="danger"
variant="text"
size="small"
@click="removeItem(index)"
>删除</t-button>
</div> </div>
<product-selector <product-selector
:model-value="item.productId && item.productUnitId ? {productId: item.productId, productUnitId: item.productUnitId} : undefined" :model-value="item.productId ? {productId: item.productId, productUnitId: item.productUnitId || ''} : undefined"
label="产品单位" :selected-product="getProduct(item)"
label="产品"
placeholder="请选择产品"
required-mark required-mark
@update:model-value="applyProduct(index, $event)" @update:model-value="applyProduct(index, $event)"
/> />
<t-form-item label="默认配额" required-mark> <t-form-item v-if="item.productId" label="产品单位" required-mark>
<t-input v-model.trim="item.defaultQuantity" type="number" borderless /> <div v-if="getSaleableUnits(item).length" class="unit-list">
<t-button
v-for="unit in getSaleableUnits(item)"
:key="unit.id"
:theme="item.productUnitId === unit.id ? 'primary' : 'default'"
:variant="item.productUnitId === unit.id ? 'base' : 'outline'"
size="extra-small"
:content="unit.name"
@click="selectProductUnit(index, unit)"
/>
</div>
<span v-else class="unit-empty" v-text="'暂无可售单位'" />
</t-form-item>
<t-form-item label="默认配额" content-align="right" required-mark>
<t-stepper
v-model="item.defaultQuantity"
size="large"
:max="Number.MAX_SAFE_INTEGER"
:min="getQuantityStep(item)"
:step="getQuantityStep(item)"
:integer="!getProduct(item)?.allowDecimalQuantity"
theme="outline"
/>
</t-form-item> </t-form-item>
<t-form-item label="备注"> <t-form-item label="备注">
<t-input v-model="item.remark" borderless /> <t-input v-model.trim="item.remark" borderless placeholder="选填" />
</t-form-item> </t-form-item>
<div class="price">
<span>预估价(原价)</span>
<strong class="red" v-text="getItemOriginalAmountText(item)" />
</div>
</t-form> </t-form>
<t-button <t-button
class="cell-button" class="add"
theme="primary" type="button"
theme="light"
variant="dashed"
@click="addItem" @click="addItem"
content="添加产品权益" content="添加产品权益"
/> >
<template #icon>
<t-icon name="add" size="1.125rem" />
</template>
</t-button>
<div class="summary">
<span>产品权益预估价(原价)</span>
<strong class="red" v-text="estimatedOriginalAmountText" />
</div>
</div> </div>
</card-container> </card-container>
<t-popup v-model="storePickerVisible" placement="bottom">
<t-picker v-model="storePickerDraft" :columns="storePickerColumns" title="选择门店" @cancel="storePickerVisible = false" @confirm="confirmStore" />
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom"> <t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker v-model="statusPickerDraft" :columns="statusOptions" title="状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" /> <t-picker v-model="statusPickerDraft" :columns="statusOptions" title="状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" />
</t-popup> </t-popup>
@@ -83,13 +122,13 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package"; import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import type { Product } from "@/types/Product"; import type { Product, ProductUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package"; import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { formatProductAmount, getProductQuantityStep } from "@/utils/product";
type PickerValue = string | number;
type PackageConfigFormItem = Partial<PackageConfigItem> & { clientKey: string }; type PackageConfigFormItem = Partial<PackageConfigItem> & { clientKey: string };
defineOptions({ name: "PackageConfigEdit" }); defineOptions({ name: "PackageConfigEdit" });
@@ -117,10 +156,8 @@ const form = reactive({
const standardAmountText = ref(""); const standardAmountText = ref("");
const beginAtText = ref(""); const beginAtText = ref("");
const endAtText = ref(""); const endAtText = ref("");
const storePickerVisible = ref(false);
const storePickerDraft = ref<PickerValue[]>([]);
const statusPickerVisible = ref(false); const statusPickerVisible = ref(false);
const statusPickerDraft = ref<PickerValue[]>([]); const statusPickerDraft = ref<Array<string | number>>([]);
const beginPickerVisible = ref(false); const beginPickerVisible = ref(false);
const endPickerVisible = ref(false); const endPickerVisible = ref(false);
const beginDraft = ref(""); const beginDraft = ref("");
@@ -130,8 +167,6 @@ const statusOptions = [
{ label: "启用", value: "ACTIVE" }, { label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" } { 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<PackageConfigItem>): PackageConfigFormItem { function createItem(item?: Partial<PackageConfigItem>): PackageConfigFormItem {
return { return {
@@ -144,29 +179,87 @@ function createItem(item?: Partial<PackageConfigItem>): PackageConfigFormItem {
const productCache = new Map<string, Product>(); const productCache = new Map<string, Product>();
function getProduct(item: PackageConfigFormItem): Product | undefined {
return productCache.get(item.productId || "");
}
function getQuantityStep(item: PackageConfigFormItem): number {
return getProductQuantityStep(getProduct(item));
}
const estimatedOriginalAmount = computed<number | undefined>(() => {
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<void> { async function applyProduct(index: number, value?: { productId: string; productUnitId: string }): Promise<void> {
if (!value) { if (!value) {
return; return;
} }
const product = productCache.get(value.productId) || await ProductAPI.detail(value.productId); const product = productCache.get(value.productId) || await ProductAPI.detail(value.productId);
productCache.set(value.productId, product); 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], { Object.assign(form.itemList[index], {
productId: value.productId, productId: value.productId,
productUnitId: value.productUnitId, productUnitId,
productNameSnapshot: product.name, productNameSnapshot: product.name,
unitSnapshot: unit?.name || "", unitSnapshot: unit?.name || "",
baseQuantityPerUnitSnapshot: unit?.baseQuantityPerUnit baseQuantityPerUnitSnapshot: unit?.baseQuantityPerUnit
}); });
} }
function addItem(): void { function getSaleableUnits(item: PackageConfigFormItem): ProductUnit[] {
form.itemList.push(createItem()); return (productCache.get(item.productId || "")?.unitList || []).filter(unit => unit.allowSale !== false && unit.id);
} }
function openStorePicker(): void { function selectProductUnit(index: number, unit: ProductUnit): void {
storePickerDraft.value = [form.storeId]; const item = form.itemList[index];
storePickerVisible.value = true; 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 { function openStatusPicker(): void {
@@ -182,14 +275,6 @@ function removeItem(index: number): void {
Message.warning("套餐至少需要一项产品权益"); Message.warning("套餐至少需要一项产品权益");
} }
function confirmStore(value: Array<string | number>): void {
const storeId = String(value[0] || "");
if (storeId) {
form.storeId = storeId;
}
storePickerVisible.value = false;
}
function confirmStatus(value: Array<string | number>): void { function confirmStatus(value: Array<string | number>): void {
const status = String(value[0] || ""); const status = String(value[0] || "");
if (status === "DRAFT" || status === "ACTIVE" || status === "INACTIVE") { if (status === "DRAFT" || status === "ACTIVE" || status === "INACTIVE") {
@@ -227,8 +312,8 @@ function resolveTimestamp(value: string): number | undefined {
} }
function validateForm(): boolean { function validateForm(): boolean {
if (isGlobalManager.value && !form.storeId) { if (!form.storeId) {
Message.warning("请选择所属门店"); Message.warning("当前门店未就绪");
return false; return false;
} }
if (!form.name.trim()) { if (!form.name.trim()) {
@@ -251,6 +336,10 @@ function validateForm(): boolean {
Message.warning("产品单位和正数量不能为空"); Message.warning("产品单位和正数量不能为空");
return false; return false;
} }
if (getProduct(item) && !getProduct(item)!.allowDecimalQuantity && quantity.includes(".")) {
Message.warning("该产品不允许小数数量");
return false;
}
if (keySet.has(key)) { if (keySet.has(key)) {
Message.warning("产品名称和单位组合不能重复"); Message.warning("产品名称和单位组合不能重复");
return false; return false;
@@ -311,23 +400,27 @@ async function loadData(): Promise<void> {
} else { } else {
await currentStoreStore.loadLoginStore(); await currentStoreStore.loadLoginStore();
} }
if (!form.storeId) { const currentStoreId = currentStoreStore.currentStoreId;
form.storeId = currentStoreStore.currentStoreId;
}
if (isCreateMode.value) { if (isCreateMode.value) {
form.storeId = currentStoreId;
addItem(); addItem();
return; return;
} }
const result = await PackageAPI.configDetail(routeId.value); 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, { Object.assign(form, {
id: result.id || routeId.value, id: result.id || routeId.value,
storeId: result.storeId || form.storeId, storeId: result.storeId || currentStoreId,
name: result.name || "", name: result.name || "",
description: result.description || "", description: result.description || "",
status: result.status || "DRAFT", status: result.status || "DRAFT",
beginAt: result.beginAt || undefined, beginAt: result.beginAt || undefined,
endAt: result.endAt || undefined, endAt: result.endAt || undefined,
itemList: (result.itemList || []).map(createItem) itemList
}); });
standardAmountText.value = formatPackageAmountInput(result.standardAmount); standardAmountText.value = formatPackageAmountInput(result.standardAmount);
beginAtText.value = result.beginAt ? Time.toDateTime(result.beginAt) : ""; beginAtText.value = result.beginAt ? Time.toDateTime(result.beginAt) : "";
@@ -367,28 +460,89 @@ onUnmounted(() => navBarStore.clearRight());
} }
.items { .items {
padding: 1rem 0; gap: .75rem;
display: flex;
margin-top: .75rem;
flex-direction: column;
.item { .item {
gap: .5rem; border: 1px dashed var(--td-border-level-1-color);
border: 1px dashed var(--td-component-border); padding: .5rem;
padding: .5rem 1rem; border-radius: .875rem;
margin-bottom: .75rem;
border-radius: 1rem;
.header { .header {
gap: .625rem;
display: flex; 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) { .unit-list {
padding: .25rem 0; 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;
}
} }
</style> </style>
+96 -45
View File
@@ -25,33 +25,53 @@
> >
<t-cell-group <t-cell-group
v-if="configList.length" v-if="configList.length"
class="list fix-last-cell-line" class="list fix-last-cell-line empty-cell-title-margin"
theme="card" theme="card"
> >
<t-swipe-cell <div
class="item" class="item"
v-for="(item, index) in configList" v-for="(item, index) in configList"
:key="item.id" :key="item.id"
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
> >
<t-cell :arrow="!!item.id" :hover="!!item.id" @click="toDetail(item)"> <t-cell class="cell">
<template #title> <template #title>
<div class="title"> <div class="title">
<strong v-text="item.name || '未命名套餐'" /> <strong v-text="item.name || '未命名套餐'" />
<t-tag :content="getPackageConfigStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" /> <t-tag :content="getPackageConfigStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div> </div>
</template> </template>
<template #note>
<span class="price orange" v-text="formatPackageAmount(item.standardAmount)" />
</template>
<template #description> <template #description>
<span v-text="`${item.itemList?.length || 0} 项权益`" /> <div class="description">
<div class="products">
<t-tag
v-for="productItem in item.itemList || []"
:key="productItem.id || `${productItem.productId}-${productItem.productUnitId}`"
:content="getProductRightText(productItem)"
theme="primary"
variant="light"
/>
<t-tag v-if="!item.itemList?.length" content="暂无产品权益" />
</div>
<div class="price-actions">
<div class="price">
<span class="red">¥</span>
<span class="value red" v-text="formatPackageAmount(item.standardAmount)" />
</div>
<div v-if="item.id" class="actions" @click.stop>
<t-button
class="detail"
theme="primary"
size="extra-small"
content="详情"
@click.stop="toDetail(item)"
/>
</div>
</div>
</div>
</template> </template>
</t-cell> </t-cell>
<template v-if="canDelete && item.id" #right> </div>
<t-button class="swipe-cell-button" theme="danger" @click.stop="confirmRemove(item)">删除</t-button>
</template>
</t-swipe-cell>
</t-cell-group> </t-cell-group>
<div v-else class="center-tips" v-text="loading ? '正在加载套餐配置' : '暂无套餐配置'" /> <div v-else class="center-tips" v-text="loading ? '正在加载套餐配置' : '暂无套餐配置'" />
<div class="center-tips" v-text="loadStateText" /> <div class="center-tips" v-text="loadStateText" />
@@ -61,16 +81,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DialogPlugin, type DropdownValue } from "tdesign-mobile-vue"; import { type DropdownValue } from "tdesign-mobile-vue";
import { Toolkit } from "timi-web"; import { Toolkit } from "timi-web";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI"; import PackageAPI from "@/api/PackageAPI";
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package"; import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import type { NavBarAction } from "@/types/NavBarAction"; import type { NavBarAction } from "@/types/NavBarAction";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue"; import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package"; import { formatPackageAmount, getPackageConfigStatusText, getPackageQuantityText } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -80,7 +100,6 @@ const router = useRouter();
const navBarStore = useNavBarStore(); const navBarStore = useNavBarStore();
const userStore = useUserStore(); const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.create)); const canCreate = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.create));
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.delete));
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{ const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
key: "create", key: "create",
text: "新增套餐配置", text: "新增套餐配置",
@@ -172,27 +191,9 @@ function getStatusTheme(statusValue?: PackageConfigStatus): "success" | "warning
return statusValue === "ACTIVE" ? "success" : statusValue === "INACTIVE" ? "warning" : "default"; return statusValue === "ACTIVE" ? "success" : statusValue === "INACTIVE" ? "warning" : "default";
} }
function confirmRemove(item: GaoPackageConfig): void { function getProductRightText(item: PackageConfigItem): string {
if (!item.id || !canDelete.value) { const quantity = getPackageQuantityText(item.defaultQuantity);
return; return `${item.productNameSnapshot || "未命名产品"} × ${quantity}${item.unitSnapshot ? ` ${item.unitSnapshot}` : ""}`;
}
const dialog = DialogPlugin.confirm({
title: "删除套餐配置",
content: `确定删除“${item.name || "未命名套餐"}”?删除后不可再用于购买。`,
confirmBtn: { content: "删除", theme: "danger" },
onConfirm: async () => {
try {
await PackageAPI.configRemove(item.id!);
configList.value = configList.value.filter(config => config.id !== item.id);
total.value = Math.max(0, total.value - 1);
Message.success("套餐配置已删除");
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置删除失败"));
} finally {
dialog.destroy();
}
}
});
} }
onMounted(async () => { onMounted(async () => {
@@ -228,17 +229,22 @@ onUnmounted(() => {
box-shadow: var(--td-shadow-1); box-shadow: var(--td-shadow-1);
.keyword { .keyword {
--td-input-vertical-padding: .25rem 1rem;
flex: 1; flex: 1;
border: 1px solid var(--td-component-border); border: 1px solid var(--td-component-border);
z-index: 1; z-index: 1;
padding: .5rem 1rem;
background: var(--td-bg-color-container); background: var(--td-bg-color-container);
border-radius: 1rem; border-radius: 1rem;
} }
.status { .status {
--td-spacer: 4px;
--td-spacer-1: 6px;
border: 1px solid rgba(148, 163, 184, .18);
margin-left: -2rem; margin-left: -2rem;
padding-left: 1.5rem; padding-left: 2rem;
border-radius: 1rem; border-radius: 1rem;
&:after { &:after {
@@ -251,15 +257,60 @@ onUnmounted(() => {
padding-top: 1rem; padding-top: 1rem;
.item { .item {
background: var(--td-bg-color-container);
border-radius: 1rem;
overflow: hidden;
.title { .cell {
gap: .5rem; padding: 10px;
display: flex;
align-items: center;
}
.price { .title {
font-size: 1.25rem; gap: .5rem;
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
}
.description {
display: flex;
flex-direction: column;
.products {
gap: .35rem;
display: flex;
max-width: 100%;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
:deep(.t-tag) {
flex: 0 0 auto;
}
&::-webkit-scrollbar {
display: none;
}
}
.price-actions {
gap: .5rem;
display: flex;
margin-top: .5rem;
align-items: center;
justify-content: end;
.price {
.value {
font-size: 1.25rem;
}
}
}
}
} }
} }
} }
+14 -6
View File
@@ -90,9 +90,9 @@
v-model="item.quantity" v-model="item.quantity"
size="large" size="large"
:max="maxQuantity" :max="maxQuantity"
:min="1" :min="getQuantityStep(item)"
:step="1" :step="getQuantityStep(item)"
:integer="false" :integer="!getProduct(item)?.allowDecimalQuantity"
theme="outline" theme="outline"
/> />
</t-form-item> </t-form-item>
@@ -184,7 +184,7 @@ import type { Product, ProductUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
import type { DirectProductOrderItemRequest } from "@/types/SalesOrder"; import type { DirectProductOrderItemRequest } from "@/types/SalesOrder";
import { createPackageRequestNo, normalizePackageQuantity, parsePackageAmount } from "@/utils/package"; import { createPackageRequestNo, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { formatProductAmount } from "@/utils/product"; import { formatProductAmount, getProductQuantityStep } from "@/utils/product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -219,6 +219,15 @@ const scanCode = ref("");
const scannerVisible = ref(false); const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。"); const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = ""; let latestDecodedText = "";
function getProduct(item: FormItem): Product | undefined {
return productCache.get(item.productId || "");
}
function getQuantityStep(item: FormItem): number {
return getProductQuantityStep(getProduct(item));
}
const totalAmount = computed(() => { const totalAmount = computed(() => {
let amount = 0; let amount = 0;
for (const item of items.value) { for (const item of items.value) {
@@ -380,6 +389,7 @@ function validate(): boolean {
const quantity = normalizePackageQuantity(item.quantity ?? ""); const quantity = normalizePackageQuantity(item.quantity ?? "");
const key = `${item.productId}\u0000${item.productUnitId}`; const key = `${item.productId}\u0000${item.productUnitId}`;
if (!item.productId || !item.productUnitId || !quantity || quantity === "0") { Message.warning("产品单位和正数量不能为空"); return false; } if (!item.productId || !item.productUnitId || !quantity || quantity === "0") { Message.warning("产品单位和正数量不能为空"); return false; }
if (getProduct(item) && !getProduct(item)!.allowDecimalQuantity && quantity.includes(".")) { Message.warning("该产品不允许小数数量"); return false; }
if (keys.has(key)) { Message.warning("产品单位不能重复"); return false; } if (keys.has(key)) { Message.warning("产品单位不能重复"); return false; }
keys.add(key); keys.add(key);
if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; } if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; }
@@ -499,7 +509,6 @@ onUnmounted(() => navBarStore.clearRight());
gap: .625rem; gap: .625rem;
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: .375rem;
.no { .no {
width: 1.75rem; width: 1.75rem;
@@ -560,7 +569,6 @@ onUnmounted(() => navBarStore.clearRight());
} }
} }
.textarea { .textarea {
width: 100%; width: 100%;
} }
+2
View File
@@ -1,6 +1,7 @@
import type { Attachment, Model, PageResult, User } from "timi-web-api"; import type { Attachment, Model, PageResult, User } from "timi-web-api";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { OrderFulfillment } from "@/types/Fulfillment"; import type { OrderFulfillment } from "@/types/Fulfillment";
import type { Product } from "@/types/Product";
export type PackageConfigStatus = "DRAFT" | "ACTIVE" | "INACTIVE"; export type PackageConfigStatus = "DRAFT" | "ACTIVE" | "INACTIVE";
export type CustomerPackageStatus = "ACTIVE" | "FULFILLED" | "CANCELLED"; export type CustomerPackageStatus = "ACTIVE" | "FULFILLED" | "CANCELLED";
@@ -15,6 +16,7 @@ export type PackageConfigItem = {
defaultQuantity: number | string; defaultQuantity: number | string;
sort?: number; sort?: number;
remark?: string | null; remark?: string | null;
product?: Product;
} & Model; } & Model;
export type GaoPackageConfig = { export type GaoPackageConfig = {
+5
View File
@@ -43,6 +43,11 @@ export function normalizeProductQuantity(value: number | string): string | undef
return fraction ? `${integer}.${fraction}` : integer; return fraction ? `${integer}.${fraction}` : integer;
} }
/** 根据产品数量规则返回数量控件步长。允许小数时固定步进 0.1。 */
export function getProductQuantityStep(product?: Pick<Product, "allowDecimalQuantity">): number {
return product?.allowDecimalQuantity ? .1 : 1;
}
export function getProductTypeText(type?: ProductType): string { export function getProductTypeText(type?: ProductType): string {
return ({ SERVICE: "服务", GOODS: "实物", CONSUMABLE: "消耗品" } as Record<string, string>)[type || ""] || "未知"; return ({ SERVICE: "服务", GOODS: "实物", CONSUMABLE: "消耗品" } as Record<string, string>)[type || ""] || "未知";
} }