feat(project): sync all pending tenant ui changes

This commit is contained in:
2026-02-20 16:50:20 +08:00
parent 937c9b4334
commit 788491ad3d
34 changed files with 7527 additions and 95 deletions

View File

@@ -1,60 +1,171 @@
<script setup lang="ts">
/**
* 文件职责:商品详情骨架页。
* 1. 根据路由参数加载商品详情数据
* 2. 展示基础信息并提供返回入口
* 文件职责:商品详情编辑页。
* 1. 根据路由参数加载商品详情与分类
* 2. 提供商品基础信息编辑与保存能力
*/
import type { ProductDetailDto } from '#/api/product';
import { computed, ref, watch } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Descriptions, Empty, Spin, Tag } from 'ant-design-vue';
import {
Button,
Card,
Empty,
Form,
Input,
InputNumber,
message,
Select,
Space,
Spin,
Tag,
} from 'ant-design-vue';
import { getProductDetailApi } from '#/api/product';
import {
getProductCategoryListApi,
getProductDetailApi,
saveProductApi,
} from '#/api/product';
import {
formatCurrency,
resolveStatusMeta,
tagsToText,
textToTags,
} from '../list/composables/product-list-page/helpers';
const route = useRoute();
const router = useRouter();
const isLoading = ref(false);
const isSubmitting = ref(false);
const detail = ref<null | ProductDetailDto>(null);
const categoryOptions = ref<Array<{ label: string; value: string }>>([]);
const form = reactive({
id: '',
name: '',
subtitle: '',
categoryId: '',
kind: 'single' as 'combo' | 'single',
description: '',
price: 0,
originalPrice: null as null | number,
stock: 0,
status: 'off_shelf' as 'off_shelf' | 'on_sale',
tagsText: '',
});
const storeId = computed(() => String(route.query.storeId || ''));
const productId = computed(() => String(route.query.productId || ''));
const statusMeta = computed(() => {
if (!detail.value) return resolveStatusMeta('off_shelf');
return resolveStatusMeta(detail.value.status);
if (form.status === 'on_sale') {
return { color: 'green', label: '在售' };
}
return { color: 'default', label: '下架' };
});
/** 加载商品详情。 */
const originalPriceInput = computed<number | undefined>({
get: () => form.originalPrice ?? undefined,
set: (value) => {
form.originalPrice = value ?? null;
},
});
/** 将详情映射到编辑表单。 */
function patchForm(data: ProductDetailDto) {
form.id = data.id;
form.name = data.name;
form.subtitle = data.subtitle;
form.categoryId = data.categoryId;
form.kind = data.kind;
form.description = data.description;
form.price = data.price;
form.originalPrice = data.originalPrice;
form.stock = data.stock;
form.status = data.status === 'on_sale' ? 'on_sale' : 'off_shelf';
form.tagsText = tagsToText(data.tags);
}
/** 加载商品详情与分类。 */
async function loadDetail() {
if (!storeId.value || !productId.value) {
detail.value = null;
categoryOptions.value = [];
return;
}
isLoading.value = true;
try {
detail.value = await getProductDetailApi({
storeId: storeId.value,
productId: productId.value,
});
const [detailData, categories] = await Promise.all([
getProductDetailApi({
storeId: storeId.value,
productId: productId.value,
}),
getProductCategoryListApi(storeId.value),
]);
detail.value = detailData;
categoryOptions.value = categories.map((item) => ({
label: item.name,
value: item.id,
}));
patchForm(detailData);
} catch (error) {
console.error(error);
detail.value = null;
categoryOptions.value = [];
} finally {
isLoading.value = false;
}
}
/** 保存商品详情。 */
async function saveDetail() {
if (!storeId.value || !form.id) return;
if (!form.name.trim()) {
message.warning('请输入商品名称');
return;
}
if (!form.categoryId) {
message.warning('请选择商品分类');
return;
}
isSubmitting.value = true;
try {
const saved = await saveProductApi({
storeId: storeId.value,
id: form.id,
name: form.name.trim(),
subtitle: form.subtitle.trim(),
categoryId: form.categoryId,
kind: form.kind,
description: form.description.trim(),
price: Number(form.price || 0),
originalPrice:
form.originalPrice && Number(form.originalPrice) > 0
? Number(form.originalPrice)
: null,
stock: Math.max(0, Math.floor(Number(form.stock || 0))),
status: form.status,
shelfMode: form.status === 'on_sale' ? 'now' : 'draft',
tags: textToTags(form.tagsText),
});
detail.value = saved;
patchForm(saved);
message.success('商品详情已保存');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
/** 返回商品列表页。 */
function goBack() {
router.push('/product/list');
@@ -66,7 +177,12 @@ watch([storeId, productId], loadDetail, { immediate: true });
<template>
<Page title="商品详情" content-class="space-y-4 page-product-detail">
<Card :bordered="false">
<Button @click="goBack">返回商品列表</Button>
<Space>
<Button @click="goBack">返回商品列表</Button>
<Button type="primary" :loading="isSubmitting" @click="saveDetail">
保存商品
</Button>
</Space>
</Card>
<Spin :spinning="isLoading">
@@ -85,51 +201,94 @@ watch([storeId, productId], loadDetail, { immediate: true });
</Tag>
</div>
<Descriptions :column="2" bordered class="product-detail-descriptions">
<Descriptions.Item label="商品ID">{{ detail.id }}</Descriptions.Item>
<Descriptions.Item label="分类">
{{ detail.categoryName || '--' }}
</Descriptions.Item>
<Descriptions.Item label="商品类型">
{{ detail.kind === 'combo' ? '套餐' : '单品' }}
</Descriptions.Item>
<Descriptions.Item label="售价">
{{ formatCurrency(detail.price) }}
</Descriptions.Item>
<Descriptions.Item label="原价">
{{
detail.originalPrice ? formatCurrency(detail.originalPrice) : '--'
}}
</Descriptions.Item>
<Descriptions.Item label="库存">
{{ detail.stock }}
</Descriptions.Item>
<Descriptions.Item label="月销">
{{ detail.salesMonthly }}
</Descriptions.Item>
<Descriptions.Item label="沽清模式">
{{
detail.soldoutMode === 'today'
? '今日沽清'
: detail.soldoutMode === 'timed'
? '定时沽清'
: detail.soldoutMode === 'permanent'
? '永久沽清'
: '--'
}}
</Descriptions.Item>
<Descriptions.Item label="标签" :span="2">
<div class="product-detail-tags">
<Tag v-for="tag in detail.tags" :key="`${detail.id}-${tag}`">
{{ tag }}
</Tag>
<span v-if="detail.tags.length === 0">--</span>
</div>
</Descriptions.Item>
<Descriptions.Item label="描述" :span="2">
{{ detail.description || '--' }}
</Descriptions.Item>
</Descriptions>
<Form layout="vertical">
<Space style="display: flex; width: 100%">
<Form.Item label="商品名称" required style="flex: 1">
<Input v-model:value="form.name" :maxlength="30" show-count />
</Form.Item>
<Form.Item label="副标题" style="flex: 1">
<Input v-model:value="form.subtitle" :maxlength="60" show-count />
</Form.Item>
</Space>
<Space style="display: flex; width: 100%">
<Form.Item label="分类" style="flex: 1">
<Select
v-model:value="form.categoryId"
:options="categoryOptions"
placeholder="请选择分类"
/>
</Form.Item>
<Form.Item label="商品类型" style="flex: 1">
<Select
v-model:value="form.kind"
:options="[
{ label: '单品', value: 'single' },
{ label: '套餐', value: 'combo' },
]"
/>
</Form.Item>
<Form.Item label="状态" style="flex: 1">
<Select
v-model:value="form.status"
:options="[
{ label: '在售', value: 'on_sale' },
{ label: '下架', value: 'off_shelf' },
]"
/>
</Form.Item>
</Space>
<Space style="display: flex; width: 100%">
<Form.Item label="售价" style="flex: 1">
<InputNumber
v-model:value="form.price"
:min="0"
:step="0.5"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="原价" style="flex: 1">
<InputNumber
v-model:value="originalPriceInput"
:min="0"
:step="0.5"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="库存" style="flex: 1">
<InputNumber
v-model:value="form.stock"
:min="0"
:step="1"
style="width: 100%"
/>
</Form.Item>
</Space>
<Form.Item label="标签(逗号分隔)">
<Input
v-model:value="form.tagsText"
placeholder="例如:招牌,新品,辣"
/>
</Form.Item>
<Form.Item label="商品描述">
<Input.TextArea
v-model:value="form.description"
:rows="4"
:maxlength="500"
show-count
/>
</Form.Item>
</Form>
<div class="detail-extra">
<span>商品ID{{ detail.id }}</span>
<span>SPU{{ detail.spuCode }}</span>
<span>月销{{ detail.salesMonthly }}</span>
<span>当前售价{{ formatCurrency(detail.price) }}</span>
</div>
</Card>
<Card v-else :bordered="false">