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 {
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(() => {
+129 -20
View File
@@ -2,10 +2,23 @@
<div v-can="permissionCodes.packageConfig.read" class="package-config-detail">
<template v-if="config">
<section class="hero" v-entrance-transition>
<div class="header">
<span class="eyebrow" v-text="'套餐管理'" />
<div class="title"><h1 v-text="config.name || '未命名套餐'" /><t-tag :content="getPackageConfigStatusText(config.status)" variant="light-outline" :theme="getStatusTheme(config.status)" /></div>
<p v-text="config.description || '暂无套餐说明'" />
<div class="hero-meta"><span v-text="`${config.itemList?.length || 0} 项产品权益`" /><strong v-text="formatPackageAmount(config.standardAmount)" /></div>
<t-tag
: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>
<t-cell-group title="基本信息" theme="card" v-entrance-transition="70">
<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.updater?.name || config.updater?.nick || '未知'" />
</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
class="item"
v-for="item in config.itemList || []"
:title="item.productNameSnapshot"
:key="item.id || `${item.productId}-${item.productUnitId}`"
:title="item.productNameSnapshot || '未命名产品'"
: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="'暂无产品权益'" />
</t-cell-group>
<t-button
@@ -48,11 +84,12 @@ import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
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 { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { getProductPhotoURL as getProductPhotoUrl, getProductTypeText } from "@/utils/product";
defineOptions({ name: "PackageConfigDetail" });
@@ -75,6 +112,10 @@ function getStatusTheme(status?: PackageConfigStatus): "success" | "warning" | "
return status === "ACTIVE" ? "success" : status === "INACTIVE" ? "warning" : "default";
}
function getItemPhotoURL(item: PackageConfigItem): string {
return getProductPhotoUrl(item.product || {});
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
@@ -157,17 +198,46 @@ 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;
.title {
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
&::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;
@@ -175,37 +245,76 @@ onUnmounted(() => navBarStore.clearRight());
font-weight: 700;
letter-spacing: .08em;
}
}
h1 {
.title {
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: end;
justify-content: space-between;
.price {
.value {
font-size: 1.5rem;
}
}
}
}
.product {
.item {
padding: 10px;
.item-title {
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
strong {
color: #c2410c;
font-size: 1.2rem;
white-space: nowrap;
.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;
}
+216 -62
View File
@@ -1,9 +1,6 @@
<template>
<div v-can="savePermissionCode" class="package-config-edit">
<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-input v-model="form.name" borderless maxlength="100" placeholder="请输入套餐名称" />
</t-form-item>
@@ -36,33 +33,75 @@
label-width="6rem"
>
<div class="header">
<span class="no" v-text="String(index + 1).padStart(2, '0')" />
<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>
<product-selector
:model-value="item.productId && item.productUnitId ? {productId: item.productId, productUnitId: item.productUnitId} : undefined"
label="产品单位"
:model-value="item.productId ? {productId: item.productId, productUnitId: item.productUnitId || ''} : undefined"
:selected-product="getProduct(item)"
label="产品"
placeholder="请选择产品"
required-mark
@update:model-value="applyProduct(index, $event)"
/>
<t-form-item label="默认配额" required-mark>
<t-input v-model.trim="item.defaultQuantity" type="number" borderless />
</t-form-item>
<t-form-item label="备注">
<t-input v-model="item.remark" borderless />
</t-form-item>
</t-form>
<t-form-item v-if="item.productId" label="产品单位" required-mark>
<div v-if="getSaleableUnits(item).length" class="unit-list">
<t-button
class="cell-button"
theme="primary"
@click="addItem"
content="添加产品权益"
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 label="备注">
<t-input v-model.trim="item.remark" borderless placeholder="选填" />
</t-form-item>
<div class="price">
<span>预估价(原价)</span>
<strong class="red" v-text="getItemOriginalAmountText(item)" />
</div>
</t-form>
<t-button
class="add"
type="button"
theme="light"
variant="dashed"
@click="addItem"
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>
</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-picker v-model="statusPickerDraft" :columns="statusOptions" title="状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" />
</t-popup>
@@ -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<PackageConfigItem> & { 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<PickerValue[]>([]);
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<PickerValue[]>([]);
const statusPickerDraft = ref<Array<string | number>>([]);
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<PackageConfigItem>): PackageConfigFormItem {
return {
@@ -144,29 +179,87 @@ function createItem(item?: Partial<PackageConfigItem>): PackageConfigFormItem {
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> {
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<string | number>): void {
const storeId = String(value[0] || "");
if (storeId) {
form.storeId = storeId;
}
storePickerVisible.value = false;
}
function confirmStatus(value: Array<string | number>): 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<void> {
} 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;
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;
}
}
.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;
}
:deep(.t-form__item) {
padding: .25rem 0;
.red {
font-size: 1.25rem;
}
}
}
.add-item {
border-radius: .75rem;
}
}
</style>
+89 -38
View File
@@ -25,33 +25,53 @@
>
<t-cell-group
v-if="configList.length"
class="list fix-last-cell-line"
class="list fix-last-cell-line empty-cell-title-margin"
theme="card"
>
<t-swipe-cell
<div
class="item"
v-for="(item, index) in configList"
:key="item.id"
v-entrance-transition="index * 30"
>
<t-cell :arrow="!!item.id" :hover="!!item.id" @click="toDetail(item)">
<t-cell class="cell">
<template #title>
<div class="title">
<strong v-text="item.name || '未命名套餐'" />
<t-tag :content="getPackageConfigStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div>
</template>
<template #note>
<span class="price orange" v-text="formatPackageAmount(item.standardAmount)" />
</template>
<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>
</t-cell>
<template v-if="canDelete && item.id" #right>
<t-button class="swipe-cell-button" theme="danger" @click.stop="confirmRemove(item)">删除</t-button>
</template>
</t-swipe-cell>
</div>
</t-cell-group>
<div v-else class="center-tips" v-text="loading ? '正在加载套餐配置' : '暂无套餐配置'" />
<div class="center-tips" v-text="loadStateText" />
@@ -61,16 +81,16 @@
</template>
<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 { useUserStore } from "timi-web-api";
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 NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
import { formatPackageAmount, getPackageConfigStatusText, getPackageQuantityText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
@@ -80,7 +100,6 @@ const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.create));
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.delete));
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
key: "create",
text: "新增套餐配置",
@@ -172,27 +191,9 @@ function getStatusTheme(statusValue?: PackageConfigStatus): "success" | "warning
return statusValue === "ACTIVE" ? "success" : statusValue === "INACTIVE" ? "warning" : "default";
}
function confirmRemove(item: GaoPackageConfig): void {
if (!item.id || !canDelete.value) {
return;
}
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();
}
}
});
function getProductRightText(item: PackageConfigItem): string {
const quantity = getPackageQuantityText(item.defaultQuantity);
return `${item.productNameSnapshot || "未命名产品"} × ${quantity}${item.unitSnapshot ? ` ${item.unitSnapshot}` : ""}`;
}
onMounted(async () => {
@@ -228,17 +229,22 @@ onUnmounted(() => {
box-shadow: var(--td-shadow-1);
.keyword {
--td-input-vertical-padding: .25rem 1rem;
flex: 1;
border: 1px solid var(--td-component-border);
z-index: 1;
padding: .5rem 1rem;
background: var(--td-bg-color-container);
border-radius: 1rem;
}
.status {
--td-spacer: 4px;
--td-spacer-1: 6px;
border: 1px solid rgba(148, 163, 184, .18);
margin-left: -2rem;
padding-left: 1.5rem;
padding-left: 2rem;
border-radius: 1rem;
&:after {
@@ -251,17 +257,62 @@ onUnmounted(() => {
padding-top: 1rem;
.item {
background: var(--td-bg-color-container);
border-radius: 1rem;
overflow: hidden;
.cell {
padding: 10px;
.title {
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;
}
}
}
}
}
}
}
}
</style>
+14 -6
View File
@@ -90,9 +90,9 @@
v-model="item.quantity"
size="large"
:max="maxQuantity"
:min="1"
:step="1"
:integer="false"
:min="getQuantityStep(item)"
:step="getQuantityStep(item)"
:integer="!getProduct(item)?.allowDecimalQuantity"
theme="outline"
/>
</t-form-item>
@@ -184,7 +184,7 @@ import type { Product, ProductUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo";
import type { DirectProductOrderItemRequest } from "@/types/SalesOrder";
import { createPackageRequestNo, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { formatProductAmount } from "@/utils/product";
import { formatProductAmount, getProductQuantityStep } from "@/utils/product";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
@@ -219,6 +219,15 @@ const scanCode = ref("");
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
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(() => {
let amount = 0;
for (const item of items.value) {
@@ -380,6 +389,7 @@ function validate(): boolean {
const quantity = normalizePackageQuantity(item.quantity ?? "");
const key = `${item.productId}\u0000${item.productUnitId}`;
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; }
keys.add(key);
if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; }
@@ -499,7 +509,6 @@ onUnmounted(() => navBarStore.clearRight());
gap: .625rem;
display: flex;
align-items: center;
margin-bottom: .375rem;
.no {
width: 1.75rem;
@@ -560,7 +569,6 @@ onUnmounted(() => navBarStore.clearRight());
}
}
.textarea {
width: 100%;
}
+2
View File
@@ -1,6 +1,7 @@
import type { Attachment, Model, PageResult, User } from "timi-web-api";
import type { Customer } from "@/types/Customer";
import type { OrderFulfillment } from "@/types/Fulfillment";
import type { Product } from "@/types/Product";
export type PackageConfigStatus = "DRAFT" | "ACTIVE" | "INACTIVE";
export type CustomerPackageStatus = "ACTIVE" | "FULFILLED" | "CANCELLED";
@@ -15,6 +16,7 @@ export type PackageConfigItem = {
defaultQuantity: number | string;
sort?: number;
remark?: string | null;
product?: Product;
} & Model;
export type GaoPackageConfig = {
+5
View File
@@ -43,6 +43,11 @@ export function normalizeProductQuantity(value: number | string): string | undef
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 {
return ({ SERVICE: "服务", GOODS: "实物", CONSUMABLE: "消耗品" } as Record<string, string>)[type || ""] || "未知";
}