feat(project): align product list/detail with prototype
This commit is contained in:
@@ -143,6 +143,7 @@ export interface ProductListItemDto {
|
||||
/** 商品详情。 */
|
||||
export interface ProductDetailDto extends ProductListItemDto {
|
||||
description: string;
|
||||
imageUrls?: string[];
|
||||
notifyManager: boolean;
|
||||
recoverAt: null | string;
|
||||
remainStock: number;
|
||||
@@ -172,6 +173,7 @@ export interface SaveProductDto {
|
||||
categoryId: string;
|
||||
description: string;
|
||||
id?: string;
|
||||
imageUrls?: string[];
|
||||
kind: ProductKind;
|
||||
name: string;
|
||||
originalPrice: null | number;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Mock 数据入口,仅在开发环境下使用
|
||||
// 门店模块与商品分类管理已切换真实 TenantApi,此处仅保留其他业务 mock。
|
||||
import './product';
|
||||
import './product-extensions';
|
||||
// Mock 数据入口,仅在开发环境下使用。
|
||||
// 默认关闭商品相关 mock;需要时可通过环境变量显式开启。
|
||||
if (import.meta.env.VITE_MOCK_PRODUCT === 'true') {
|
||||
void import('./product');
|
||||
console.warn('[Mock] 已启用商品列表 Mock 数据');
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[Mock] 已启用非门店/非分类管理 Mock 数据(分类管理强制走真实 API)',
|
||||
);
|
||||
if (import.meta.env.VITE_MOCK_PRODUCT_EXTENSIONS === 'true') {
|
||||
void import('./product-extensions');
|
||||
console.warn('[Mock] 已启用商品扩展模块 Mock 数据');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { ProductDetailFormState } from '../types';
|
||||
|
||||
import type { ProductDetailDto, ProductStatus } from '#/api/product';
|
||||
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { uploadTenantFileApi } from '#/api/files';
|
||||
import {
|
||||
deleteProductApi,
|
||||
getProductCategoryListApi,
|
||||
getProductDetailApi,
|
||||
saveProductApi,
|
||||
} from '#/api/product';
|
||||
import {
|
||||
tagsToText,
|
||||
textToTags,
|
||||
} from '#/views/product/list/composables/product-list-page/helpers';
|
||||
|
||||
const DEFAULT_FORM: ProductDetailFormState = {
|
||||
id: '',
|
||||
name: '',
|
||||
subtitle: '',
|
||||
categoryId: '',
|
||||
kind: 'single',
|
||||
description: '',
|
||||
price: 0,
|
||||
originalPrice: null,
|
||||
stock: 0,
|
||||
status: 'off_shelf',
|
||||
tagsText: '',
|
||||
imageUrls: [],
|
||||
shelfMode: 'draft',
|
||||
timedOnShelfAt: '',
|
||||
};
|
||||
|
||||
export function useProductDetailPage() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const isUploadingImage = ref(false);
|
||||
const detail = ref<null | ProductDetailDto>(null);
|
||||
const categoryOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const form = reactive<ProductDetailFormState>({ ...DEFAULT_FORM });
|
||||
|
||||
const storeId = computed(() => String(route.query.storeId || ''));
|
||||
const productId = computed(() => String(route.query.productId || ''));
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (form.status === 'on_sale') return '在售';
|
||||
if (form.status === 'sold_out') return '售罄';
|
||||
return '下架';
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (form.status === 'on_sale') return '#52c41a';
|
||||
if (form.status === 'sold_out') return '#ef4444';
|
||||
return '#9ca3af';
|
||||
});
|
||||
|
||||
/** 回填编辑表单。 */
|
||||
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;
|
||||
form.tagsText = tagsToText(data.tags || []);
|
||||
form.imageUrls = [...(data.imageUrls || []), data.imageUrl]
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, source) => source.indexOf(item) === index)
|
||||
.slice(0, 5);
|
||||
form.shelfMode = data.status === 'on_sale' ? 'now' : 'draft';
|
||||
form.timedOnShelfAt = '';
|
||||
}
|
||||
|
||||
/** 解析门店+商品并拉取详情。 */
|
||||
async function loadDetail() {
|
||||
if (!storeId.value || !productId.value) {
|
||||
detail.value = null;
|
||||
categoryOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
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 uploadImage(file: File) {
|
||||
isUploadingImage.value = true;
|
||||
try {
|
||||
const uploaded = await uploadTenantFileApi(file, 'dish_image');
|
||||
const url = String(uploaded.url || '').trim();
|
||||
if (!url) {
|
||||
message.error('图片上传失败');
|
||||
return;
|
||||
}
|
||||
form.imageUrls = [...form.imageUrls, url]
|
||||
.filter((item, index, source) => source.indexOf(item) === index)
|
||||
.slice(0, 5);
|
||||
message.success('图片上传成功');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isUploadingImage.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置主图。 */
|
||||
function setPrimaryImage(index: number) {
|
||||
if (index <= 0 || index >= form.imageUrls.length) return;
|
||||
const current = [...form.imageUrls];
|
||||
const [selected] = current.splice(index, 1);
|
||||
if (!selected) return;
|
||||
form.imageUrls = [selected, ...current];
|
||||
}
|
||||
|
||||
/** 删除图片。 */
|
||||
function removeImage(index: number) {
|
||||
form.imageUrls = form.imageUrls.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
/** 保存详情。 */
|
||||
async function saveDetail() {
|
||||
if (!storeId.value) return;
|
||||
if (!form.id) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请输入商品名称');
|
||||
return;
|
||||
}
|
||||
if (!form.categoryId) {
|
||||
message.warning('请选择商品分类');
|
||||
return;
|
||||
}
|
||||
if (form.shelfMode === 'scheduled' && !form.timedOnShelfAt) {
|
||||
message.warning('请选择定时上架时间');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const saved = await saveProductApi({
|
||||
id: form.id,
|
||||
storeId: storeId.value,
|
||||
categoryId: form.categoryId,
|
||||
kind: form.kind,
|
||||
name: form.name.trim(),
|
||||
subtitle: form.subtitle.trim(),
|
||||
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))),
|
||||
tags: textToTags(form.tagsText),
|
||||
status: form.status,
|
||||
shelfMode: form.shelfMode,
|
||||
timedOnShelfAt:
|
||||
form.shelfMode === 'scheduled' ? form.timedOnShelfAt : undefined,
|
||||
imageUrls: [...form.imageUrls],
|
||||
});
|
||||
detail.value = saved;
|
||||
patchForm(saved);
|
||||
message.success('商品详情已保存');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换在售/下架。 */
|
||||
async function toggleSaleStatus(next: ProductStatus) {
|
||||
if (next !== 'on_sale' && next !== 'off_shelf') return;
|
||||
form.status = next;
|
||||
form.shelfMode = next === 'on_sale' ? 'now' : 'draft';
|
||||
await saveDetail();
|
||||
}
|
||||
|
||||
/** 删除当前商品并返回列表。 */
|
||||
async function deleteCurrentProduct() {
|
||||
if (!storeId.value || !form.id) return;
|
||||
await deleteProductApi({
|
||||
storeId: storeId.value,
|
||||
productId: form.id,
|
||||
});
|
||||
message.success('商品已删除');
|
||||
router.push('/product/list');
|
||||
}
|
||||
|
||||
/** 返回列表页。 */
|
||||
function goBack() {
|
||||
router.push('/product/list');
|
||||
}
|
||||
|
||||
watch([storeId, productId], loadDetail, { immediate: true });
|
||||
|
||||
return {
|
||||
categoryOptions,
|
||||
deleteCurrentProduct,
|
||||
detail,
|
||||
form,
|
||||
goBack,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
isUploadingImage,
|
||||
loadDetail,
|
||||
removeImage,
|
||||
saveDetail,
|
||||
setPrimaryImage,
|
||||
statusColor,
|
||||
statusText,
|
||||
storeId,
|
||||
toggleSaleStatus,
|
||||
uploadImage,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品详情编辑页。
|
||||
* 1. 根据路由参数加载商品详情与分类。
|
||||
* 2. 提供商品基础信息编辑与保存能力。
|
||||
* 文件职责:商品详情页(原型 1:1 结构还原)。
|
||||
* 1. 左侧锚点导航 + 右侧分区编辑布局。
|
||||
* 2. 提供商品基础信息、图片、价格库存、标签和上架设置编辑。
|
||||
*/
|
||||
import type { ProductDetailDto } from '#/api/product';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { ProductDetailSectionItem } from './types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
@@ -18,56 +20,48 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Switch,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getProductCategoryListApi,
|
||||
getProductDetailApi,
|
||||
saveProductApi,
|
||||
} from '#/api/product';
|
||||
import { useProductDetailPage } from './composables/useProductDetailPage';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
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 {
|
||||
categoryOptions,
|
||||
deleteCurrentProduct,
|
||||
detail,
|
||||
form,
|
||||
goBack,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
isUploadingImage,
|
||||
removeImage,
|
||||
saveDetail,
|
||||
setPrimaryImage,
|
||||
statusColor,
|
||||
statusText,
|
||||
toggleSaleStatus,
|
||||
uploadImage,
|
||||
} = useProductDetailPage();
|
||||
|
||||
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 activeSectionId = ref('detail-basic');
|
||||
|
||||
const storeId = computed(() => String(route.query.storeId || ''));
|
||||
const productId = computed(() => String(route.query.productId || ''));
|
||||
|
||||
const statusMeta = computed(() => {
|
||||
if (form.status === 'on_sale') {
|
||||
return { color: 'green', label: '在售' };
|
||||
}
|
||||
return { color: 'default', label: '下架' };
|
||||
});
|
||||
const sectionList: ProductDetailSectionItem[] = [
|
||||
{ id: 'detail-basic', title: '基本信息' },
|
||||
{ id: 'detail-images', title: '商品图片' },
|
||||
{ id: 'detail-pricing', title: '价格库存' },
|
||||
{ id: 'detail-specs', title: '规格做法' },
|
||||
{ id: 'detail-sku', title: 'SKU管理' },
|
||||
{ id: 'detail-addons', title: '加料管理' },
|
||||
{ id: 'detail-tags', title: '商品标签' },
|
||||
{ id: 'detail-shelf', title: '上架设置' },
|
||||
];
|
||||
|
||||
const originalPriceInput = computed<number | undefined>({
|
||||
get: () => form.originalPrice ?? undefined,
|
||||
@@ -76,220 +70,307 @@ const originalPriceInput = computed<number | undefined>({
|
||||
},
|
||||
});
|
||||
|
||||
/** 将详情映射到编辑表单。 */
|
||||
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);
|
||||
const isOnSale = computed(() => form.status === 'on_sale');
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
|
||||
await uploadImage(file as File);
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 滚动到指定区块。 */
|
||||
function scrollToSection(sectionId: string) {
|
||||
activeSectionId.value = sectionId;
|
||||
const target = document.querySelector<HTMLElement>(`#${sectionId}`);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载商品详情与分类。 */
|
||||
async function loadDetail() {
|
||||
if (!storeId.value || !productId.value) {
|
||||
detail.value = null;
|
||||
categoryOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
/** 跳转到规格做法模块。 */
|
||||
function goToSpecsPage() {
|
||||
router.push('/product/specs');
|
||||
}
|
||||
|
||||
/** 保存商品详情。 */
|
||||
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 goToAddonsPage() {
|
||||
router.push('/product/addons');
|
||||
}
|
||||
|
||||
/** 返回商品列表页。 */
|
||||
function goBack() {
|
||||
router.push('/product/list');
|
||||
/** 跳转到商品标签模块。 */
|
||||
function goToLabelsPage() {
|
||||
router.push('/product/labels');
|
||||
}
|
||||
|
||||
watch([storeId, productId], loadDetail, { immediate: true });
|
||||
/** 确认删除商品。 */
|
||||
function confirmDelete() {
|
||||
Modal.confirm({
|
||||
title: '确认删除该商品吗?',
|
||||
content: '删除后不可恢复,请谨慎操作。',
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
await deleteCurrentProduct();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="商品详情" content-class="space-y-4 page-product-detail">
|
||||
<Card :bordered="false">
|
||||
<Space>
|
||||
<Button @click="goBack">返回商品列表</Button>
|
||||
<Button type="primary" :loading="isSubmitting" @click="saveDetail">
|
||||
保存商品
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Spin :spinning="isLoading">
|
||||
<Card v-if="detail" :bordered="false">
|
||||
<div class="product-detail-header">
|
||||
<div class="product-detail-cover">
|
||||
{{ detail.name.slice(0, 1) }}
|
||||
<Spin :spinning="isLoading || isSubmitting">
|
||||
<template v-if="detail">
|
||||
<div class="pd-header">
|
||||
<Button class="pd-back-btn" @click="goBack">←</Button>
|
||||
<div class="pd-head-main">
|
||||
<div class="pd-head-title">{{ form.name || detail.name }}</div>
|
||||
<div class="pd-head-spu">{{ detail.spuCode }}</div>
|
||||
</div>
|
||||
<div class="product-detail-title-wrap">
|
||||
<div class="title">{{ detail.name }}</div>
|
||||
<div class="sub">{{ detail.subtitle || '--' }}</div>
|
||||
<div class="spu">{{ detail.spuCode }}</div>
|
||||
</div>
|
||||
<Tag class="product-detail-status" :color="statusMeta.color">
|
||||
{{ statusMeta.label }}
|
||||
</Tag>
|
||||
<span class="pd-head-status" :style="{ background: statusColor }">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div class="pd-head-spacer"></div>
|
||||
<Button danger @click="confirmDelete">删除商品</Button>
|
||||
<Button v-if="isOnSale" @click="toggleSaleStatus('off_shelf')">
|
||||
下架
|
||||
</Button>
|
||||
<Button v-else type="primary" @click="toggleSaleStatus('on_sale')">
|
||||
上架
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="pd-body">
|
||||
<Card :bordered="false" class="pd-nav-card">
|
||||
<a
|
||||
v-for="item in sectionList"
|
||||
:key="item.id"
|
||||
class="pd-nav-item"
|
||||
:class="{ active: activeSectionId === item.id }"
|
||||
@click="scrollToSection(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<div class="pd-content">
|
||||
<Card id="detail-basic" :bordered="false">
|
||||
<template #title>基本信息</template>
|
||||
<Form layout="vertical">
|
||||
<div class="pd-two-col">
|
||||
<Form.Item label="商品名称" required>
|
||||
<Input
|
||||
v-model:value="form.name"
|
||||
:maxlength="30"
|
||||
show-count
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="副标题">
|
||||
<Input
|
||||
v-model:value="form.subtitle"
|
||||
:maxlength="60"
|
||||
show-count
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="pd-two-col">
|
||||
<Form.Item label="SPU 编码">
|
||||
<Input :value="detail.spuCode" disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="所属分类" required>
|
||||
<Select
|
||||
v-model:value="form.categoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="请选择分类"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Form.Item label="商品描述">
|
||||
<Input.TextArea
|
||||
v-model:value="form.description"
|
||||
:rows="4"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Card id="detail-images" :bordered="false">
|
||||
<template #title>商品图片</template>
|
||||
<Upload
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:disabled="isUploadingImage || form.imageUrls.length >= 5"
|
||||
>
|
||||
<Button :loading="isUploadingImage">
|
||||
{{
|
||||
form.imageUrls.length >= 5 ? '最多 5 张图片' : '上传图片'
|
||||
}}
|
||||
</Button>
|
||||
</Upload>
|
||||
<div class="pd-image-list">
|
||||
<div
|
||||
v-for="(url, index) in form.imageUrls"
|
||||
:key="url"
|
||||
class="pd-image-item"
|
||||
:class="{ primary: index === 0 }"
|
||||
>
|
||||
<img :src="url" alt="product-image" />
|
||||
<div class="pd-image-actions">
|
||||
<Button
|
||||
size="small"
|
||||
@click="setPrimaryImage(index)"
|
||||
:disabled="index === 0"
|
||||
>
|
||||
设为主图
|
||||
</Button>
|
||||
<Button size="small" danger @click="removeImage(index)">
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="detail-extra">
|
||||
<span>商品ID:{{ detail.id }}</span>
|
||||
<span>SPU:{{ detail.spuCode }}</span>
|
||||
<span>月销:{{ detail.salesMonthly }}</span>
|
||||
<span>当前售价:{{ formatCurrency(detail.price) }}</span>
|
||||
<Card id="detail-pricing" :bordered="false">
|
||||
<template #title>价格库存</template>
|
||||
<Form layout="vertical">
|
||||
<div class="pd-three-col">
|
||||
<Form.Item label="售价" required>
|
||||
<InputNumber
|
||||
v-model:value="form.price"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="原价">
|
||||
<InputNumber
|
||||
v-model:value="originalPriceInput"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="库存">
|
||||
<InputNumber
|
||||
v-model:value="form.stock"
|
||||
:min="0"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card id="detail-specs" :bordered="false">
|
||||
<template #title>规格做法</template>
|
||||
<div class="pd-module-hint">
|
||||
规格做法可在专属模块配置,当前商品会自动使用已关联模板。
|
||||
</div>
|
||||
<Button @click="goToSpecsPage">前往规格做法</Button>
|
||||
</Card>
|
||||
|
||||
<Card id="detail-sku" :bordered="false">
|
||||
<template #title>SKU 管理</template>
|
||||
<div class="pd-sku-row">
|
||||
<span class="name">默认 SKU</span>
|
||||
<InputNumber
|
||||
v-model:value="form.price"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
class="pd-sku-price"
|
||||
/>
|
||||
<InputNumber
|
||||
v-model:value="form.stock"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="pd-sku-stock"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card id="detail-addons" :bordered="false">
|
||||
<template #title>加料管理</template>
|
||||
<div class="pd-module-hint">
|
||||
加料组关联与选项在加料管理模块维护,当前页展示主流程编辑入口。
|
||||
</div>
|
||||
<Button @click="goToAddonsPage">前往加料管理</Button>
|
||||
</Card>
|
||||
|
||||
<Card id="detail-tags" :bordered="false">
|
||||
<template #title>商品标签</template>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="标签(英文逗号分隔)">
|
||||
<Input
|
||||
v-model:value="form.tagsText"
|
||||
placeholder="例如:招牌,新品,辣"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Button @click="goToLabelsPage">前往标签管理</Button>
|
||||
</Card>
|
||||
|
||||
<Card id="detail-shelf" :bordered="false">
|
||||
<template #title>上架设置</template>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="商品类型">
|
||||
<Radio.Group v-model:value="form.kind">
|
||||
<Radio.Button value="single">单品</Radio.Button>
|
||||
<Radio.Button value="combo">套餐</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上架方式">
|
||||
<Radio.Group v-model:value="form.shelfMode">
|
||||
<Radio.Button value="draft">存为草稿</Radio.Button>
|
||||
<Radio.Button value="now">立即上架</Radio.Button>
|
||||
<Radio.Button value="scheduled">定时上架</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
v-if="form.shelfMode === 'scheduled'"
|
||||
label="定时上架时间"
|
||||
>
|
||||
<Input
|
||||
v-model:value="form.timedOnShelfAt"
|
||||
placeholder="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="当前在售状态">
|
||||
<Switch
|
||||
:checked="form.status === 'on_sale'"
|
||||
checked-children="在售"
|
||||
un-checked-children="下架"
|
||||
@change="
|
||||
(checked) => {
|
||||
form.status = checked ? 'on_sale' : 'off_shelf';
|
||||
form.shelfMode = checked ? 'now' : 'draft';
|
||||
}
|
||||
"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :bordered="false" class="pd-save-bar">
|
||||
<div class="pd-save-actions">
|
||||
<Button @click="goBack">取消</Button>
|
||||
<Button type="primary" :loading="isSubmitting" @click="saveDetail">
|
||||
保存商品
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
<Empty description="未找到商品详情" />
|
||||
|
||||
@@ -1,73 +1,196 @@
|
||||
/* 文件职责:商品详情页面样式。 */
|
||||
.page-product-detail {
|
||||
.product-detail-header {
|
||||
.pd-header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.product-detail-cover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%);
|
||||
border-radius: 12px;
|
||||
.pd-back-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-detail-title-wrap {
|
||||
flex: 1;
|
||||
.pd-head-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-detail-title-wrap .title {
|
||||
.pd-head-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-detail-title-wrap .sub {
|
||||
.pd-head-spu {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-detail-title-wrap .spu {
|
||||
margin-top: 6px;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
'Liberation Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.product-detail-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
.pd-head-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.detail-extra {
|
||||
.pd-head-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pd-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pd-nav-card {
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
flex-shrink: 0;
|
||||
width: 160px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pd-nav-item {
|
||||
display: block;
|
||||
padding: 8px 14px;
|
||||
margin: 2px 0;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pd-nav-item:hover {
|
||||
color: #1677ff;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.pd-nav-item.active {
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
background: #f3f8ff;
|
||||
border-left-color: #1677ff;
|
||||
}
|
||||
|
||||
.pd-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pd-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pd-three-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pd-image-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pd-image-item {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pd-image-item.primary {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 1px rgb(22 119 255 / 25%);
|
||||
}
|
||||
|
||||
.pd-image-item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
object-fit: cover;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.pd-image-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pd-module-hint {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
.pd-sku-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) 120px 120px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pd-sku-row .name {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.pd-sku-price,
|
||||
.pd-sku-stock {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pd-save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 12;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pd-save-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.page-product-detail {
|
||||
.pd-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pd-nav-card {
|
||||
position: static;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pd-two-col,
|
||||
.pd-three-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
apps/web-antd/src/views/product/detail/types.ts
Normal file
23
apps/web-antd/src/views/product/detail/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ProductKind, ProductStatus } from '#/api/product';
|
||||
|
||||
export interface ProductDetailFormState {
|
||||
categoryId: string;
|
||||
description: string;
|
||||
id: string;
|
||||
imageUrls: string[];
|
||||
kind: ProductKind;
|
||||
name: string;
|
||||
originalPrice: null | number;
|
||||
price: number;
|
||||
shelfMode: 'draft' | 'now' | 'scheduled';
|
||||
status: ProductStatus;
|
||||
stock: number;
|
||||
subtitle: string;
|
||||
tagsText: string;
|
||||
timedOnShelfAt: string;
|
||||
}
|
||||
|
||||
export interface ProductDetailSectionItem {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import type { ProductKind, ProductStatus } from '#/api/product';
|
||||
import type { ProductViewMode } from '#/views/product/list/types';
|
||||
|
||||
import { Button, Card, Input, Select } from 'ant-design-vue';
|
||||
import { Card, Input, Select } from 'ant-design-vue';
|
||||
|
||||
interface StoreOption {
|
||||
label: string;
|
||||
@@ -53,27 +53,33 @@ const emit = defineEmits<{
|
||||
function handleStoreChange(value: unknown) {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
emit('update:selectedStoreId', String(value));
|
||||
emit('search');
|
||||
return;
|
||||
}
|
||||
emit('update:selectedStoreId', '');
|
||||
emit('search');
|
||||
}
|
||||
|
||||
/** 统一解析状态筛选值。 */
|
||||
function handleStatusChange(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
emit('update:status', value as '' | ProductStatus);
|
||||
emit('search');
|
||||
return;
|
||||
}
|
||||
emit('update:status', '');
|
||||
emit('search');
|
||||
}
|
||||
|
||||
/** 统一解析类型筛选值。 */
|
||||
function handleKindChange(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
emit('update:kind', value as '' | ProductKind);
|
||||
emit('search');
|
||||
return;
|
||||
}
|
||||
emit('update:kind', '');
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -113,16 +119,6 @@ function handleKindChange(value: unknown) {
|
||||
@update:value="(value) => handleKindChange(value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isLoading"
|
||||
:disabled="!props.selectedStoreId"
|
||||
@click="emit('search')"
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button :disabled="props.isLoading" @click="emit('reset')">重置</Button>
|
||||
|
||||
<div class="product-filter-spacer"></div>
|
||||
|
||||
<div class="product-view-switch">
|
||||
@@ -132,7 +128,12 @@ function handleKindChange(value: unknown) {
|
||||
:class="{ active: props.viewMode === 'card' }"
|
||||
@click="emit('update:viewMode', 'card')"
|
||||
>
|
||||
卡片
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -140,7 +141,11 @@ function handleKindChange(value: unknown) {
|
||||
:class="{ active: props.viewMode === 'list' }"
|
||||
@click="emit('update:viewMode', 'list')"
|
||||
>
|
||||
列表
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
||||
<rect x="1" y="2" width="14" height="2.5" rx="1" />
|
||||
<rect x="1" y="6.75" width="14" height="2.5" rx="1" />
|
||||
<rect x="1" y="11.5" width="14" height="2.5" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,6 @@ const emit = defineEmits<{
|
||||
(event: 'detail', item: ProductListItemDto): void;
|
||||
(event: 'edit', item: ProductListItemDto): void;
|
||||
(event: 'pageChange', payload: ProductPaginationChangePayload): void;
|
||||
(event: 'quickEdit', item: ProductListItemDto): void;
|
||||
(event: 'soldout', item: ProductListItemDto): void;
|
||||
(
|
||||
event: 'toggleSelect',
|
||||
@@ -74,9 +73,7 @@ function getMoreActionOptions(item: ProductListItemDto) {
|
||||
} else {
|
||||
actions.push({ key: 'on', label: '上架' });
|
||||
}
|
||||
if (item.status !== 'sold_out') {
|
||||
actions.push({ key: 'soldout', label: '沽清' });
|
||||
}
|
||||
actions.push({ key: 'soldout', label: '沽清' });
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -171,8 +168,12 @@ function handlePageSizeChange(current: number, size: number) {
|
||||
</div>
|
||||
|
||||
<div class="product-card-meta">
|
||||
<span :class="resolveStockClass(item.stock)">
|
||||
{{ formatStockText(item.stock) }}
|
||||
<span
|
||||
:class="
|
||||
item.kind === 'combo' ? '' : resolveStockClass(item.stock)
|
||||
"
|
||||
>
|
||||
{{ item.kind === 'combo' ? '—' : formatStockText(item.stock) }}
|
||||
</span>
|
||||
<span>月销 {{ item.salesMonthly }}</span>
|
||||
</div>
|
||||
@@ -192,13 +193,6 @@ function handlePageSizeChange(current: number, size: number) {
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('quickEdit', item)"
|
||||
>
|
||||
快编
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@@ -293,8 +287,12 @@ function handlePageSizeChange(current: number, size: number) {
|
||||
</span>
|
||||
|
||||
<span class="cell">
|
||||
<span :class="resolveStockClass(item.stock)">
|
||||
{{ formatStockText(item.stock) }}
|
||||
<span
|
||||
:class="
|
||||
item.kind === 'combo' ? '' : resolveStockClass(item.stock)
|
||||
"
|
||||
>
|
||||
{{ item.kind === 'combo' ? '—' : formatStockText(item.stock) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -323,13 +321,6 @@ function handlePageSizeChange(current: number, size: number) {
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('quickEdit', item)"
|
||||
>
|
||||
快编
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
|
||||
@@ -102,8 +102,8 @@ export function resolveStatusMeta(status: ProductStatus) {
|
||||
|
||||
/** 按库存返回展示文本。 */
|
||||
export function formatStockText(stock: number) {
|
||||
if (stock <= 0) return '0 售罄';
|
||||
if (stock <= 10) return `${stock} 紧张`;
|
||||
if (stock <= 0) return '0 缺货';
|
||||
if (stock <= 10) return `${stock} 偏低`;
|
||||
return `${stock} 充足`;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const {
|
||||
isSoldoutSubmitting,
|
||||
isStoreLoading,
|
||||
openCreateProductDrawer,
|
||||
openEditDrawer,
|
||||
openProductDetail,
|
||||
openProductDetailById,
|
||||
openQuickEditDrawer,
|
||||
@@ -182,8 +181,7 @@ function onBatchAction(action: ProductBatchAction) {
|
||||
@toggle-select="handleToggleSelect"
|
||||
@toggle-select-all="handleToggleSelectAll"
|
||||
@page-change="handlePageChange"
|
||||
@edit="openEditDrawer"
|
||||
@quick-edit="openQuickEditDrawer"
|
||||
@edit="openQuickEditDrawer"
|
||||
@detail="openProductDetail"
|
||||
@delete="deleteProduct"
|
||||
@change-status="handleSingleStatusChange"
|
||||
|
||||
@@ -29,14 +29,17 @@
|
||||
}
|
||||
|
||||
.product-view-btn {
|
||||
min-width: 62px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user