395 lines
13 KiB
Vue
395 lines
13 KiB
Vue
<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>
|
|
<t-form-item label="套餐说明">
|
|
<t-textarea v-model="form.description" borderless maxlength="500" autosize placeholder="可填写套餐说明" />
|
|
</t-form-item>
|
|
<t-form-item label="标准价(元)" required-mark>
|
|
<t-input v-model.trim="standardAmountText" type="number" align="right" borderless placeholder="例如 1288.00" />
|
|
</t-form-item>
|
|
<t-form-item label="状态" arrow @click="openStatusPicker" content-align="right">
|
|
<span class="picker-text" v-text="getPackageConfigStatusText(form.status)" />
|
|
</t-form-item>
|
|
<t-form-item label="开始时间" arrow @click="openBeginPicker" content-align="right">
|
|
<span class="picker-text" v-text="beginAtText || '不限'" />
|
|
</t-form-item>
|
|
<t-form-item label="结束时间" arrow @click="openEndPicker" content-align="right">
|
|
<span class="picker-text" v-text="endAtText || '不限'" />
|
|
</t-form-item>
|
|
</t-form>
|
|
<card-container
|
|
title="产品权益"
|
|
description="至少一项,配额最多三位小数"
|
|
v-entrance-transition="70"
|
|
>
|
|
<div class="items">
|
|
<t-form
|
|
class="item"
|
|
v-for="(item, index) in form.itemList"
|
|
:key="item.clientKey"
|
|
label-width="6rem"
|
|
>
|
|
<div class="header">
|
|
<strong v-text="item.productNameSnapshot || '未选择产品'" />
|
|
<t-button 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="产品单位"
|
|
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-button
|
|
class="cell-button"
|
|
theme="primary"
|
|
@click="addItem"
|
|
content="添加产品权益"
|
|
/>
|
|
</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>
|
|
<t-popup v-model="beginPickerVisible" placement="bottom">
|
|
<t-date-time-picker v-model="beginDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="开始时间" @cancel="beginPickerVisible = false" @confirm="confirmBegin" />
|
|
</t-popup>
|
|
<t-popup v-model="endPickerVisible" placement="bottom">
|
|
<t-date-time-picker v-model="endDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="结束时间" @cancel="endPickerVisible = false" @confirm="confirmEnd" />
|
|
</t-popup>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Time } from "timi-web";
|
|
import { useUserStore } from "timi-web-api";
|
|
import PackageAPI from "@/api/PackageAPI";
|
|
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 { getErrorMessage } from "@/utils/error";
|
|
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
|
|
import { Message } from "@/utils/message";
|
|
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
|
|
|
type PickerValue = string | number;
|
|
type PackageConfigFormItem = Partial<PackageConfigItem> & { clientKey: string };
|
|
|
|
defineOptions({ name: "PackageConfigEdit" });
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const userStore = useUserStore();
|
|
const navBarStore = useNavBarStore();
|
|
const currentStoreStore = useCurrentStoreStore();
|
|
const routeId = computed(() => String(route.params.id || ""));
|
|
const isCreateMode = computed(() => !routeId.value);
|
|
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.packageConfig.create : permissionCodes.packageConfig.update);
|
|
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
|
const isSaving = ref(false);
|
|
const form = reactive({
|
|
id: "",
|
|
storeId: "",
|
|
name: "",
|
|
description: "",
|
|
status: "DRAFT" as PackageConfigStatus,
|
|
beginAt: undefined as number | undefined,
|
|
endAt: undefined as number | undefined,
|
|
itemList: [] as PackageConfigFormItem[]
|
|
});
|
|
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 beginPickerVisible = ref(false);
|
|
const endPickerVisible = ref(false);
|
|
const beginDraft = ref("");
|
|
const endDraft = ref("");
|
|
const statusOptions = [
|
|
{ label: "草稿", value: "DRAFT" },
|
|
{ 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 {
|
|
...item,
|
|
clientKey: window.crypto.randomUUID(),
|
|
defaultQuantity: item?.defaultQuantity ?? "",
|
|
remark: item?.remark || ""
|
|
};
|
|
}
|
|
|
|
const productCache = new Map<string, Product>();
|
|
|
|
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);
|
|
Object.assign(form.itemList[index], {
|
|
productId: value.productId,
|
|
productUnitId: value.productUnitId,
|
|
productNameSnapshot: product.name,
|
|
unitSnapshot: unit?.name || "",
|
|
baseQuantityPerUnitSnapshot: unit?.baseQuantityPerUnit
|
|
});
|
|
}
|
|
|
|
function addItem(): void {
|
|
form.itemList.push(createItem());
|
|
}
|
|
|
|
function openStorePicker(): void {
|
|
storePickerDraft.value = [form.storeId];
|
|
storePickerVisible.value = true;
|
|
}
|
|
|
|
function openStatusPicker(): void {
|
|
statusPickerDraft.value = [form.status];
|
|
statusPickerVisible.value = true;
|
|
}
|
|
|
|
function removeItem(index: number): void {
|
|
if (1 < form.itemList.length) {
|
|
form.itemList.splice(index, 1);
|
|
return;
|
|
}
|
|
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") {
|
|
form.status = status;
|
|
}
|
|
statusPickerVisible.value = false;
|
|
}
|
|
|
|
function openBeginPicker(): void {
|
|
beginDraft.value = beginAtText.value || Time.formatDateTime(new Date());
|
|
beginPickerVisible.value = true;
|
|
}
|
|
|
|
function openEndPicker(): void {
|
|
endDraft.value = endAtText.value || Time.formatDateTime(new Date());
|
|
endPickerVisible.value = true;
|
|
}
|
|
|
|
function confirmBegin(value: string | number): void {
|
|
beginAtText.value = String(value);
|
|
beginPickerVisible.value = false;
|
|
}
|
|
|
|
function confirmEnd(value: string | number): void {
|
|
endAtText.value = String(value);
|
|
endPickerVisible.value = false;
|
|
}
|
|
|
|
function resolveTimestamp(value: string): number | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
|
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
}
|
|
|
|
function validateForm(): boolean {
|
|
if (isGlobalManager.value && !form.storeId) {
|
|
Message.warning("请选择所属门店");
|
|
return false;
|
|
}
|
|
if (!form.name.trim()) {
|
|
Message.warning("请输入套餐名称");
|
|
return false;
|
|
}
|
|
if (parsePackageAmount(standardAmountText.value) === undefined) {
|
|
Message.warning("标准价必须是合法的元金额,最多两位小数");
|
|
return false;
|
|
}
|
|
if (!form.itemList.length) {
|
|
Message.warning("请至少添加一项产品权益");
|
|
return false;
|
|
}
|
|
const keySet = new Set<string>();
|
|
for (const item of form.itemList) {
|
|
const quantity = normalizePackageQuantity(item.defaultQuantity ?? "");
|
|
const key = `${item.productId}\u0000${item.productUnitId}`;
|
|
if (!item.productId || !item.productUnitId || !quantity || quantity === "0") {
|
|
Message.warning("产品单位和正数量不能为空");
|
|
return false;
|
|
}
|
|
if (keySet.has(key)) {
|
|
Message.warning("产品名称和单位组合不能重复");
|
|
return false;
|
|
}
|
|
keySet.add(key);
|
|
}
|
|
form.beginAt = resolveTimestamp(beginAtText.value);
|
|
form.endAt = resolveTimestamp(endAtText.value);
|
|
if (form.beginAt !== undefined && form.endAt !== undefined && form.endAt < form.beginAt) {
|
|
Message.warning("结束时间不能早于开始时间");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function save(): Promise<void> {
|
|
if (isSaving.value || !validateForm()) {
|
|
return;
|
|
}
|
|
isSaving.value = true;
|
|
try {
|
|
const payload: GaoPackageConfig = {
|
|
id: form.id || undefined,
|
|
storeId: form.storeId || undefined,
|
|
name: form.name.trim(),
|
|
description: form.description.trim(),
|
|
standardAmount: parsePackageAmount(standardAmountText.value)!,
|
|
status: form.status,
|
|
beginAt: form.beginAt ?? null,
|
|
endAt: form.endAt ?? null,
|
|
itemList: form.itemList.map((item, index) => ({
|
|
productId: item.productId!,
|
|
productUnitId: item.productUnitId!,
|
|
defaultQuantity: normalizePackageQuantity(item.defaultQuantity ?? "")!,
|
|
sort: index,
|
|
remark: item.remark?.trim() || null
|
|
}))
|
|
};
|
|
if (isCreateMode.value) {
|
|
const id = await PackageAPI.configCreate(payload);
|
|
Message.success("套餐配置已创建");
|
|
await router.replace(`/management/package-configs/${id}`);
|
|
return;
|
|
}
|
|
await PackageAPI.configUpdate(payload);
|
|
Message.success("套餐配置已保存");
|
|
await router.replace(`/management/package-configs/${routeId.value}`);
|
|
} catch (error) {
|
|
Message.error(getErrorMessage(error, "套餐配置保存失败"));
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadData(): Promise<void> {
|
|
if (isGlobalManager.value) {
|
|
await currentStoreStore.loadStoreList();
|
|
} else {
|
|
await currentStoreStore.loadLoginStore();
|
|
}
|
|
if (!form.storeId) {
|
|
form.storeId = currentStoreStore.currentStoreId;
|
|
}
|
|
if (isCreateMode.value) {
|
|
addItem();
|
|
return;
|
|
}
|
|
const result = await PackageAPI.configDetail(routeId.value);
|
|
Object.assign(form, {
|
|
id: result.id || routeId.value,
|
|
storeId: result.storeId || form.storeId,
|
|
name: result.name || "",
|
|
description: result.description || "",
|
|
status: result.status || "DRAFT",
|
|
beginAt: result.beginAt || undefined,
|
|
endAt: result.endAt || undefined,
|
|
itemList: (result.itemList || []).map(createItem)
|
|
});
|
|
standardAmountText.value = formatPackageAmountInput(result.standardAmount);
|
|
beginAtText.value = result.beginAt ? Time.toDateTime(result.beginAt) : "";
|
|
endAtText.value = result.endAt ? Time.toDateTime(result.endAt) : "";
|
|
if (!form.itemList.length) {
|
|
addItem();
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (userStore.hasPermissions(savePermissionCode.value)) {
|
|
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
|
|
}
|
|
try {
|
|
await loadData();
|
|
} catch (error) {
|
|
Message.error(getErrorMessage(error, "套餐配置加载失败"));
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => navBarStore.clearRight());
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.package-config-edit {
|
|
gap: 1rem;
|
|
display: flex;
|
|
padding: calc(var(--app-nav-height) + 1rem) 0 calc(1rem + env(safe-area-inset-bottom, 0));
|
|
flex-direction: column;
|
|
|
|
.form {
|
|
margin: 0 1rem;
|
|
background: var(--td-bg-color-container);
|
|
box-shadow: var(--td-shadow-1);
|
|
border-radius: 1rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.items {
|
|
padding: 1rem 0;
|
|
|
|
.item {
|
|
gap: .5rem;
|
|
border: 1px dashed var(--td-component-border);
|
|
padding: .5rem 1rem;
|
|
margin-bottom: .75rem;
|
|
border-radius: 1rem;
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
:deep(.t-form__item) {
|
|
padding: .25rem 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
.add-item {
|
|
border-radius: .75rem;
|
|
}
|
|
}
|
|
</style>
|