feat(project): align product list/detail with prototype

This commit is contained in:
2026-02-21 17:25:47 +08:00
parent e5365710b0
commit 3647bebf19
11 changed files with 814 additions and 335 deletions

View File

@@ -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;

View File

@@ -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 数据');
}

View File

@@ -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,
};
}

View File

@@ -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="未找到商品详情" />

View File

@@ -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;
}
}
}

View 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;
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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} 充足`;
}

View File

@@ -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"

View File

@@ -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;
}