feat(project): align store delivery pages with live APIs and geocode status
This commit is contained in:
144
apps/web-antd/src/views/product/detail/index.vue
Normal file
144
apps/web-antd/src/views/product/detail/index.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品详情骨架页。
|
||||
* 1. 根据路由参数加载商品详情数据。
|
||||
* 2. 展示基础信息并提供返回入口。
|
||||
*/
|
||||
import type { ProductDetailDto } from '#/api/product';
|
||||
|
||||
import { computed, 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 { getProductDetailApi } from '#/api/product';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
resolveStatusMeta,
|
||||
} from '../list/composables/product-list-page/helpers';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const detail = ref<null | ProductDetailDto>(null);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
/** 加载商品详情。 */
|
||||
async function loadDetail() {
|
||||
if (!storeId.value || !productId.value) {
|
||||
detail.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
detail.value = await getProductDetailApi({
|
||||
storeId: storeId.value,
|
||||
productId: productId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
detail.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回商品列表页。 */
|
||||
function goBack() {
|
||||
router.push('/product/list');
|
||||
}
|
||||
|
||||
watch([storeId, productId], loadDetail, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="商品详情" content-class="space-y-4 page-product-detail">
|
||||
<Card :bordered="false">
|
||||
<Button @click="goBack">返回商品列表</Button>
|
||||
</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) }}
|
||||
</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>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
<Empty description="未找到商品详情" />
|
||||
</Card>
|
||||
</Spin>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
74
apps/web-antd/src/views/product/detail/styles/index.less
Normal file
74
apps/web-antd/src/views/product/detail/styles/index.less
Normal file
@@ -0,0 +1,74 @@
|
||||
/* 文件职责:商品详情页面样式。 */
|
||||
.page-product-detail {
|
||||
.product-detail-header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.product-detail-title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-detail-title-wrap .title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-detail-title-wrap .sub {
|
||||
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;
|
||||
}
|
||||
|
||||
.product-detail-descriptions .ant-descriptions-item-label {
|
||||
width: 120px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.product-detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表动作条。
|
||||
* 1. 提供批量操作入口与添加商品按钮。
|
||||
* 2. 展示当前勾选数量并支持一键清空。
|
||||
*/
|
||||
import type { ProductBatchAction } from '../types';
|
||||
|
||||
import { Button, Dropdown, Menu } from 'ant-design-vue';
|
||||
|
||||
interface BatchActionOption {
|
||||
label: string;
|
||||
value: ProductBatchAction;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
batchActionOptions: BatchActionOption[];
|
||||
batchDisabled: boolean;
|
||||
selectedCount: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'add'): void;
|
||||
(event: 'batchAction', action: ProductBatchAction): void;
|
||||
(event: 'clearSelection'): void;
|
||||
}>();
|
||||
|
||||
/** 透传批量动作点击。 */
|
||||
function handleBatchMenuClick(payload: { key: number | string }) {
|
||||
emit('batchAction', String(payload.key) as ProductBatchAction);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-action-bar">
|
||||
<div class="product-action-left">
|
||||
<Dropdown :disabled="props.batchDisabled" :trigger="['click']">
|
||||
<Button :disabled="props.batchDisabled"> 批量操作 ▾ </Button>
|
||||
<template #overlay>
|
||||
<Menu @click="handleBatchMenuClick">
|
||||
<Menu.Item
|
||||
v-for="item in props.batchActionOptions"
|
||||
:key="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
<Button type="primary" @click="emit('add')">+ 添加商品</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.selectedCount > 0" class="product-action-selected">
|
||||
<span>已选择 {{ props.selectedCount }} 项</span>
|
||||
<Button type="link" @click="emit('clearSelection')">清空</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品分类侧栏。
|
||||
* 1. 展示分类与数量统计。
|
||||
* 2. 透出分类切换事件。
|
||||
*/
|
||||
import type { ProductCategorySidebarItem } from '../types';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
categories: ProductCategorySidebarItem[];
|
||||
isLoading: boolean;
|
||||
selectedCategoryId: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'select', categoryId: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="product-category-sidebar-card">
|
||||
<template #title>
|
||||
<span class="section-title">商品分类</span>
|
||||
</template>
|
||||
|
||||
<Spin :spinning="props.isLoading">
|
||||
<div v-if="props.categories.length > 0" class="product-category-sidebar">
|
||||
<button
|
||||
v-for="item in props.categories"
|
||||
:key="item.id || 'all'"
|
||||
type="button"
|
||||
class="product-category-item"
|
||||
:class="{ active: props.selectedCategoryId === item.id }"
|
||||
@click="emit('select', item.id)"
|
||||
>
|
||||
<span class="category-name">{{ item.name }}</span>
|
||||
<span class="category-count">{{ item.productCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Empty v-else description="暂无分类" />
|
||||
</Spin>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
/**
|
||||
* 文件职责:商品添加/编辑抽屉。
|
||||
* 1. 展示商品核心信息与上架方式表单。
|
||||
* 2. 通过回调更新父级状态并触发提交。
|
||||
*/
|
||||
import type { ProductEditorFormState } from '../types';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface CategoryOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categoryOptions: CategoryOption[];
|
||||
form: ProductEditorFormState;
|
||||
isSaving: boolean;
|
||||
onSetCategoryId: (value: string) => void;
|
||||
onSetDescription: (value: string) => void;
|
||||
onSetKind: (value: 'combo' | 'single') => void;
|
||||
onSetName: (value: string) => void;
|
||||
onSetOriginalPrice: (value: null | number) => void;
|
||||
onSetPrice: (value: number) => void;
|
||||
onSetShelfMode: (value: 'draft' | 'now' | 'scheduled') => void;
|
||||
onSetStock: (value: number) => void;
|
||||
onSetSubtitle: (value: string) => void;
|
||||
onSetTagsText: (value: string) => void;
|
||||
onSetTimedOnShelfAt: (value: string) => void;
|
||||
open: boolean;
|
||||
showDetailLink: boolean;
|
||||
submitText: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'detail'): void;
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 数值输入归一化。 */
|
||||
function toNumber(value: null | number | string, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** 解析商品类型。 */
|
||||
function handleKindChange(value: unknown) {
|
||||
if (value === 'combo' || value === 'single') {
|
||||
props.onSetKind(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析分类选择。 */
|
||||
function handleCategoryChange(value: unknown) {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
props.onSetCategoryId(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析上架方式。 */
|
||||
function handleShelfModeChange(value: unknown) {
|
||||
if (value === 'draft' || value === 'now' || value === 'scheduled') {
|
||||
props.onSetShelfMode(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析定时上架时间。 */
|
||||
function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
if (!value || !dayjs(value).isValid()) {
|
||||
props.onSetTimedOnShelfAt('');
|
||||
return;
|
||||
}
|
||||
props.onSetTimedOnShelfAt(dayjs(value).format('YYYY-MM-DD HH:mm:ss'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="product-editor-drawer"
|
||||
:open="props.open"
|
||||
:title="props.title"
|
||||
:width="560"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">商品类型</div>
|
||||
<Radio.Group
|
||||
:value="props.form.kind"
|
||||
class="product-kind-radio-group"
|
||||
@update:value="(value) => handleKindChange(value)"
|
||||
>
|
||||
<Radio.Button value="single">单品</Radio.Button>
|
||||
<Radio.Button value="combo">套餐</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">商品信息</div>
|
||||
<div class="drawer-form-grid">
|
||||
<div class="drawer-form-item full">
|
||||
<label class="drawer-form-label required">商品名称</label>
|
||||
<Input
|
||||
:value="props.form.name"
|
||||
placeholder="请输入商品名称"
|
||||
@update:value="(value) => props.onSetName(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label required">分类</label>
|
||||
<Select
|
||||
:value="props.form.categoryId"
|
||||
:options="props.categoryOptions"
|
||||
placeholder="请选择分类"
|
||||
@update:value="(value) => handleCategoryChange(value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">副标题</label>
|
||||
<Input
|
||||
:value="props.form.subtitle"
|
||||
placeholder="选填"
|
||||
@update:value="(value) => props.onSetSubtitle(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item full">
|
||||
<label class="drawer-form-label">商品简介</label>
|
||||
<Input.TextArea
|
||||
:value="props.form.description"
|
||||
:rows="3"
|
||||
:maxlength="120"
|
||||
placeholder="请输入商品简介"
|
||||
@update:value="
|
||||
(value) => props.onSetDescription(String(value || ''))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">价格与库存</div>
|
||||
<div class="drawer-form-grid">
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label required">售价</label>
|
||||
<InputNumber
|
||||
:value="props.form.price"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
@update:value="
|
||||
(value) => props.onSetPrice(toNumber(value, props.form.price))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">原价</label>
|
||||
<InputNumber
|
||||
:value="props.form.originalPrice ?? undefined"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
placeholder="选填"
|
||||
@update:value="
|
||||
(value) =>
|
||||
props.onSetOriginalPrice(
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: toNumber(value, 0),
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label required">库存</label>
|
||||
<InputNumber
|
||||
:value="props.form.stock"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
@update:value="
|
||||
(value) => props.onSetStock(toNumber(value, props.form.stock))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">标签</label>
|
||||
<Input
|
||||
:value="props.form.tagsText"
|
||||
placeholder="多个标签用英文逗号分隔"
|
||||
@update:value="(value) => props.onSetTagsText(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">上架方式</div>
|
||||
<Radio.Group
|
||||
:value="props.form.shelfMode"
|
||||
class="product-shelf-radio-group"
|
||||
@update:value="(value) => handleShelfModeChange(value)"
|
||||
>
|
||||
<Radio class="shelf-radio-item" value="draft">存为草稿</Radio>
|
||||
<Radio class="shelf-radio-item" value="now">立即上架</Radio>
|
||||
<Radio class="shelf-radio-item" value="scheduled">定时上架</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<div v-if="props.form.shelfMode === 'scheduled'" class="shelf-time-row">
|
||||
<DatePicker
|
||||
:value="
|
||||
props.form.timedOnShelfAt
|
||||
? dayjs(props.form.timedOnShelfAt)
|
||||
: undefined
|
||||
"
|
||||
class="full-width"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择上架时间"
|
||||
@update:value="
|
||||
(value) =>
|
||||
handleTimedOnShelfAtChange(value as Dayjs | null | undefined)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="product-drawer-footer">
|
||||
<Button v-if="props.showDetailLink" type="link" @click="emit('detail')">
|
||||
前往详情页
|
||||
</Button>
|
||||
<div class="product-drawer-footer-right">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.submitText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表顶部筛选条。
|
||||
* 1. 提供门店、关键词、状态、类型筛选。
|
||||
* 2. 提供卡片/列表视图切换。
|
||||
*/
|
||||
import type { ProductKind, ProductStatus } from '#/api/product';
|
||||
import type { ProductViewMode } from '#/views/product/list/types';
|
||||
|
||||
import { Button, Card, Input, Select } from 'ant-design-vue';
|
||||
|
||||
interface StoreOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface KindOption {
|
||||
label: string;
|
||||
value: '' | ProductKind;
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
label: string;
|
||||
value: '' | ProductStatus;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
isStoreLoading: boolean;
|
||||
kind: '' | ProductKind;
|
||||
kindOptions: KindOption[];
|
||||
keyword: string;
|
||||
selectedStoreId: string;
|
||||
status: '' | ProductStatus;
|
||||
statusOptions: StatusOption[];
|
||||
storeOptions: StoreOption[];
|
||||
viewMode: ProductViewMode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'reset'): void;
|
||||
(event: 'search'): void;
|
||||
(event: 'update:kind', value: '' | ProductKind): void;
|
||||
(event: 'update:keyword', value: string): void;
|
||||
(event: 'update:selectedStoreId', value: string): void;
|
||||
(event: 'update:status', value: '' | ProductStatus): void;
|
||||
(event: 'update:viewMode', value: ProductViewMode): void;
|
||||
}>();
|
||||
|
||||
/** 统一解析门店选择值。 */
|
||||
function handleStoreChange(value: unknown) {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
emit('update:selectedStoreId', String(value));
|
||||
return;
|
||||
}
|
||||
emit('update:selectedStoreId', '');
|
||||
}
|
||||
|
||||
/** 统一解析状态筛选值。 */
|
||||
function handleStatusChange(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
emit('update:status', value as '' | ProductStatus);
|
||||
return;
|
||||
}
|
||||
emit('update:status', '');
|
||||
}
|
||||
|
||||
/** 统一解析类型筛选值。 */
|
||||
function handleKindChange(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
emit('update:kind', value as '' | ProductKind);
|
||||
return;
|
||||
}
|
||||
emit('update:kind', '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="product-filter-toolbar-card">
|
||||
<div class="product-filter-toolbar">
|
||||
<Select
|
||||
:value="props.selectedStoreId"
|
||||
class="product-filter-select product-filter-store-select"
|
||||
:loading="props.isStoreLoading"
|
||||
:options="props.storeOptions"
|
||||
placeholder="请选择门店"
|
||||
:disabled="props.isStoreLoading || props.storeOptions.length === 0"
|
||||
@update:value="(value) => handleStoreChange(value)"
|
||||
/>
|
||||
|
||||
<Input
|
||||
:value="props.keyword"
|
||||
class="product-filter-input"
|
||||
allow-clear
|
||||
placeholder="搜索商品名称/编码"
|
||||
@press-enter="emit('search')"
|
||||
@update:value="(value) => emit('update:keyword', String(value || ''))"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:value="props.status"
|
||||
class="product-filter-select"
|
||||
:options="props.statusOptions"
|
||||
@update:value="(value) => handleStatusChange(value)"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:value="props.kind"
|
||||
class="product-filter-select"
|
||||
:options="props.kindOptions"
|
||||
@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">
|
||||
<button
|
||||
type="button"
|
||||
class="product-view-btn"
|
||||
:class="{ active: props.viewMode === 'card' }"
|
||||
@click="emit('update:viewMode', 'card')"
|
||||
>
|
||||
卡片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-view-btn"
|
||||
:class="{ active: props.viewMode === 'list' }"
|
||||
@click="emit('update:viewMode', 'list')"
|
||||
>
|
||||
列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表主区域。
|
||||
* 1. 根据视图模式渲染卡片或列表。
|
||||
* 2. 透出勾选、分页、编辑、详情、删除、状态动作事件。
|
||||
*/
|
||||
import type { ProductListItemDto, ProductStatus } from '#/api/product';
|
||||
import type {
|
||||
ProductPaginationChangePayload,
|
||||
ProductViewMode,
|
||||
} from '#/views/product/list/types';
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Menu,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
formatStockText,
|
||||
resolveStatusMeta,
|
||||
resolveStockClass,
|
||||
} from '../composables/product-list-page/helpers';
|
||||
|
||||
interface Props {
|
||||
allChecked: boolean;
|
||||
indeterminate: boolean;
|
||||
isLoading: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions: string[];
|
||||
rows: ProductListItemDto[];
|
||||
selectedProductIds: string[];
|
||||
total: number;
|
||||
viewMode: ProductViewMode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: 'changeStatus',
|
||||
payload: { item: ProductListItemDto; status: ProductStatus },
|
||||
): void;
|
||||
(event: 'delete', item: ProductListItemDto): void;
|
||||
(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',
|
||||
payload: { checked: boolean; productId: string },
|
||||
): void;
|
||||
(event: 'toggleSelectAll', checked: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 当前商品是否被勾选。 */
|
||||
function isChecked(productId: string) {
|
||||
return props.selectedProductIds.includes(productId);
|
||||
}
|
||||
|
||||
/** 解析“更多”动作菜单。 */
|
||||
function getMoreActionOptions(item: ProductListItemDto) {
|
||||
const actions: Array<{ key: string; label: string }> = [];
|
||||
if (item.status === 'on_sale') {
|
||||
actions.push({ key: 'off', label: '下架' });
|
||||
} else {
|
||||
actions.push({ key: 'on', label: '上架' });
|
||||
}
|
||||
if (item.status !== 'sold_out') {
|
||||
actions.push({ key: 'soldout', label: '沽清' });
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** 执行“更多”菜单动作。 */
|
||||
function handleMoreAction(
|
||||
item: ProductListItemDto,
|
||||
payload: { key: number | string },
|
||||
) {
|
||||
const key = String(payload.key);
|
||||
if (key === 'on') {
|
||||
emit('changeStatus', { item, status: 'on_sale' });
|
||||
return;
|
||||
}
|
||||
if (key === 'off') {
|
||||
emit('changeStatus', { item, status: 'off_shelf' });
|
||||
return;
|
||||
}
|
||||
if (key === 'soldout') {
|
||||
emit('soldout', item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理单项勾选变化。 */
|
||||
function handleSingleCheck(productId: string, event: unknown) {
|
||||
const checked = Boolean(
|
||||
(event as null | { target?: { checked?: boolean } })?.target?.checked,
|
||||
);
|
||||
emit('toggleSelect', { productId, checked });
|
||||
}
|
||||
|
||||
/** 处理全选变化。 */
|
||||
function handleCheckAll(event: unknown) {
|
||||
const checked = Boolean(
|
||||
(event as null | { target?: { checked?: boolean } })?.target?.checked,
|
||||
);
|
||||
emit('toggleSelectAll', checked);
|
||||
}
|
||||
|
||||
/** 处理分页变化。 */
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
emit('pageChange', { page, pageSize });
|
||||
}
|
||||
|
||||
/** 处理分页尺寸变化。 */
|
||||
function handlePageSizeChange(current: number, size: number) {
|
||||
emit('pageChange', { page: current, pageSize: size });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="props.isLoading">
|
||||
<Empty v-if="props.rows.length === 0" description="暂无商品,请先添加" />
|
||||
|
||||
<div v-else class="product-list-section">
|
||||
<div v-if="props.viewMode === 'card'" class="product-card-grid">
|
||||
<article
|
||||
v-for="item in props.rows"
|
||||
:key="item.id"
|
||||
class="product-card-item"
|
||||
:class="{
|
||||
'is-soldout': item.status === 'sold_out',
|
||||
'is-offshelf': item.status === 'off_shelf',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="product-card-status-ribbon"
|
||||
:style="{ background: resolveStatusMeta(item.status).color }"
|
||||
>
|
||||
{{ resolveStatusMeta(item.status).label }}
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
class="product-card-check"
|
||||
:checked="isChecked(item.id)"
|
||||
@change="(event) => handleSingleCheck(item.id, event)"
|
||||
/>
|
||||
|
||||
<div class="product-card-cover">
|
||||
<span>{{ item.name.slice(0, 1) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="product-card-body">
|
||||
<div class="product-card-name">{{ item.name }}</div>
|
||||
<div class="product-card-subtitle">{{ item.subtitle || '--' }}</div>
|
||||
<div class="product-card-spu">{{ item.spuCode }}</div>
|
||||
|
||||
<div class="product-card-price-row">
|
||||
<span class="price-now">{{ formatCurrency(item.price) }}</span>
|
||||
<span v-if="item.originalPrice" class="price-old">
|
||||
{{ formatCurrency(item.originalPrice) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-card-meta">
|
||||
<span :class="resolveStockClass(item.stock)">
|
||||
{{ formatStockText(item.stock) }}
|
||||
</span>
|
||||
<span>月销 {{ item.salesMonthly }}</span>
|
||||
</div>
|
||||
|
||||
<div class="product-card-tags">
|
||||
<Tag v-for="tag in item.tags" :key="`${item.id}-${tag}`">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('quickEdit', item)"
|
||||
>
|
||||
快编
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('detail', item)"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品吗?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<button type="button" class="product-link-btn danger">
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
<Dropdown :trigger="['click']">
|
||||
<button type="button" class="product-link-btn">更多</button>
|
||||
<template #overlay>
|
||||
<Menu @click="(payload) => handleMoreAction(item, payload)">
|
||||
<Menu.Item
|
||||
v-for="action in getMoreActionOptions(item)"
|
||||
:key="action.key"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div v-if="item.status === 'sold_out'" class="product-card-mask">
|
||||
售罄
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="product-list-table">
|
||||
<div class="product-list-header">
|
||||
<span class="cell check-cell">
|
||||
<Checkbox
|
||||
:checked="props.allChecked"
|
||||
:indeterminate="props.indeterminate"
|
||||
@change="(event) => handleCheckAll(event)"
|
||||
/>
|
||||
</span>
|
||||
<span class="cell">图片</span>
|
||||
<span class="cell">商品信息</span>
|
||||
<span class="cell">分类</span>
|
||||
<span class="cell">价格</span>
|
||||
<span class="cell">库存</span>
|
||||
<span class="cell">销量</span>
|
||||
<span class="cell">标签</span>
|
||||
<span class="cell">状态</span>
|
||||
<span class="cell">操作</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in props.rows"
|
||||
:key="item.id"
|
||||
class="product-list-row"
|
||||
:class="{
|
||||
'is-soldout': item.status === 'sold_out',
|
||||
'is-offshelf': item.status === 'off_shelf',
|
||||
}"
|
||||
>
|
||||
<span class="cell check-cell">
|
||||
<Checkbox
|
||||
:checked="isChecked(item.id)"
|
||||
@change="(event) => handleSingleCheck(item.id, event)"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="cell image-cell">
|
||||
<span class="list-cover">{{ item.name.slice(0, 1) }}</span>
|
||||
</span>
|
||||
|
||||
<span class="cell info-cell">
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="subtitle">{{ item.subtitle || '--' }}</span>
|
||||
<span class="spu">{{ item.spuCode }}</span>
|
||||
</span>
|
||||
|
||||
<span class="cell">{{ item.categoryName || '--' }}</span>
|
||||
|
||||
<span class="cell price-cell">
|
||||
<span class="price-now">{{ formatCurrency(item.price) }}</span>
|
||||
<span v-if="item.originalPrice" class="price-old">
|
||||
{{ formatCurrency(item.originalPrice) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="cell">
|
||||
<span :class="resolveStockClass(item.stock)">
|
||||
{{ formatStockText(item.stock) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="cell">月销 {{ item.salesMonthly }}</span>
|
||||
|
||||
<span class="cell tags-cell">
|
||||
<Tag v-for="tag in item.tags" :key="`${item.id}-${tag}`">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
</span>
|
||||
|
||||
<span class="cell">
|
||||
<span
|
||||
class="product-status-pill"
|
||||
:class="resolveStatusMeta(item.status).badgeClass"
|
||||
>
|
||||
{{ resolveStatusMeta(item.status).label }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="cell actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('quickEdit', item)"
|
||||
>
|
||||
快编
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('detail', item)"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品吗?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<button type="button" class="product-link-btn danger">
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
<Dropdown :trigger="['click']">
|
||||
<button type="button" class="product-link-btn">更多</button>
|
||||
<template #overlay>
|
||||
<Menu @click="(payload) => handleMoreAction(item, payload)">
|
||||
<Menu.Item
|
||||
v-for="action in getMoreActionOptions(item)"
|
||||
:key="action.key"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-pagination">
|
||||
<Pagination
|
||||
:current="props.page"
|
||||
:page-size="props.pageSize"
|
||||
:total="props.total"
|
||||
:page-size-options="props.pageSizeOptions"
|
||||
show-size-changer
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品快速编辑抽屉。
|
||||
* 1. 快速调整价格、库存、在售状态。
|
||||
* 2. 支持跳转商品详情页。
|
||||
*/
|
||||
import type { ProductQuickEditFormState } from '../types';
|
||||
|
||||
import type { ProductListItemDto } from '#/api/product';
|
||||
|
||||
import { Button, Drawer, InputNumber, Switch } from 'ant-design-vue';
|
||||
|
||||
import { resolveStatusMeta } from '../composables/product-list-page/helpers';
|
||||
|
||||
interface Props {
|
||||
form: ProductQuickEditFormState;
|
||||
isSaving: boolean;
|
||||
onSetIsOnSale: (value: boolean) => void;
|
||||
onSetOriginalPrice: (value: null | number) => void;
|
||||
onSetPrice: (value: number) => void;
|
||||
onSetStock: (value: number) => void;
|
||||
open: boolean;
|
||||
product: null | ProductListItemDto;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'detail'): void;
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 数值输入归一化。 */
|
||||
function toNumber(value: null | number | string, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="product-quick-edit-drawer"
|
||||
:open="props.open"
|
||||
title="快速编辑"
|
||||
:width="500"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div v-if="props.product" class="product-quick-card">
|
||||
<div class="product-quick-cover">
|
||||
{{ props.product.name.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="product-quick-meta">
|
||||
<div class="name">{{ props.product.name }}</div>
|
||||
<div class="sub">
|
||||
{{ props.product.spuCode }} · {{ props.product.categoryName }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="product-status-pill"
|
||||
:class="resolveStatusMeta(props.product.status).badgeClass"
|
||||
>
|
||||
{{ resolveStatusMeta(props.product.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">价格</div>
|
||||
<div class="drawer-form-grid">
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">售价</label>
|
||||
<InputNumber
|
||||
:value="props.form.price"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
@update:value="
|
||||
(value) => props.onSetPrice(toNumber(value, props.form.price))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">原价</label>
|
||||
<InputNumber
|
||||
:value="props.form.originalPrice ?? undefined"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
placeholder="选填"
|
||||
@update:value="
|
||||
(value) =>
|
||||
props.onSetOriginalPrice(
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: toNumber(value, 0),
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">库存</div>
|
||||
<div class="drawer-form-item compact">
|
||||
<InputNumber
|
||||
:value="props.form.stock"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
class="stock-input"
|
||||
@update:value="
|
||||
(value) => props.onSetStock(toNumber(value, props.form.stock))
|
||||
"
|
||||
/>
|
||||
<span class="unit">份</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">商品状态</div>
|
||||
<div class="switch-row">
|
||||
<Switch
|
||||
:checked="props.form.isOnSale"
|
||||
checked-children="在售"
|
||||
un-checked-children="下架"
|
||||
@update:checked="(value) => props.onSetIsOnSale(Boolean(value))"
|
||||
/>
|
||||
<span class="hint">关闭后商品将下架</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="product-drawer-footer">
|
||||
<Button v-if="props.product" type="link" @click="emit('detail')">
|
||||
前往详情页
|
||||
</Button>
|
||||
<div class="product-drawer-footer-right">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
/**
|
||||
* 文件职责:商品沽清抽屉。
|
||||
* 1. 配置沽清方式、恢复时间、可售数量与通知选项。
|
||||
* 2. 透出提交流程与关闭事件。
|
||||
*/
|
||||
import type { ProductSoldoutFormState } from '../types';
|
||||
|
||||
import type { ProductListItemDto, ProductSoldoutMode } from '#/api/product';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Switch,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { resolveStatusMeta } from '../composables/product-list-page/helpers';
|
||||
|
||||
interface Props {
|
||||
form: ProductSoldoutFormState;
|
||||
isSaving: boolean;
|
||||
onSetMode: (value: ProductSoldoutMode) => void;
|
||||
onSetNotifyManager: (value: boolean) => void;
|
||||
onSetReason: (value: string) => void;
|
||||
onSetRecoverAt: (value: string) => void;
|
||||
onSetRemainStock: (value: number) => void;
|
||||
onSetSyncToPlatform: (value: boolean) => void;
|
||||
open: boolean;
|
||||
product: null | ProductListItemDto;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 数值输入归一化。 */
|
||||
function toNumber(value: null | number | string, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** 解析沽清方式。 */
|
||||
function handleModeChange(value: unknown) {
|
||||
if (value === 'today' || value === 'timed' || value === 'permanent') {
|
||||
props.onSetMode(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析恢复时间。 */
|
||||
function handleRecoverAtChange(value: Dayjs | null | undefined) {
|
||||
if (!value || !dayjs(value).isValid()) {
|
||||
props.onSetRecoverAt('');
|
||||
return;
|
||||
}
|
||||
props.onSetRecoverAt(dayjs(value).format('YYYY-MM-DD HH:mm:ss'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="product-soldout-drawer"
|
||||
:open="props.open"
|
||||
title="商品沽清"
|
||||
:width="460"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div v-if="props.product" class="product-soldout-card">
|
||||
<div class="product-quick-cover">
|
||||
{{ props.product.name.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="product-quick-meta">
|
||||
<div class="name">{{ props.product.name }}</div>
|
||||
<div class="sub">{{ props.product.spuCode }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="product-status-pill"
|
||||
:class="resolveStatusMeta(props.product.status).badgeClass"
|
||||
>
|
||||
{{ resolveStatusMeta(props.product.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">沽清方式</div>
|
||||
<Radio.Group
|
||||
:value="props.form.mode"
|
||||
class="soldout-mode-group"
|
||||
@update:value="(value) => handleModeChange(value)"
|
||||
>
|
||||
<Radio class="soldout-mode-item" value="today">今日沽清</Radio>
|
||||
<Radio class="soldout-mode-item" value="timed">定时沽清</Radio>
|
||||
<Radio class="soldout-mode-item" value="permanent">永久沽清</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<DatePicker
|
||||
v-if="props.form.mode === 'timed'"
|
||||
:value="props.form.recoverAt ? dayjs(props.form.recoverAt) : undefined"
|
||||
class="full-width"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择恢复时间"
|
||||
@update:value="
|
||||
(value) => handleRecoverAtChange(value as Dayjs | null | undefined)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">剩余可售</div>
|
||||
<div class="drawer-form-item compact">
|
||||
<InputNumber
|
||||
:value="props.form.remainStock"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
class="stock-input"
|
||||
@update:value="
|
||||
(value) =>
|
||||
props.onSetRemainStock(toNumber(value, props.form.remainStock))
|
||||
"
|
||||
/>
|
||||
<span class="unit">份</span>
|
||||
</div>
|
||||
<div class="hint">设为 0 表示完全沽清,不再接单</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">沽清原因</div>
|
||||
<Input.TextArea
|
||||
:value="props.form.reason"
|
||||
:rows="3"
|
||||
:maxlength="120"
|
||||
placeholder="选填,如:食材用完、设备检修等"
|
||||
@update:value="(value) => props.onSetReason(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">通知设置</div>
|
||||
<div class="switch-row">
|
||||
<Switch
|
||||
:checked="props.form.syncToPlatform"
|
||||
@update:checked="(value) => props.onSetSyncToPlatform(Boolean(value))"
|
||||
/>
|
||||
<span>同步通知外卖平台</span>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<Switch
|
||||
:checked="props.form.notifyManager"
|
||||
@update:checked="(value) => props.onSetNotifyManager(Boolean(value))"
|
||||
/>
|
||||
<span>通知店长(短信提醒)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="product-drawer-footer">
|
||||
<div class="product-drawer-footer-right">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
确认沽清
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 文件职责:商品批量动作。
|
||||
* 1. 处理批量上架、下架、沽清、删除。
|
||||
* 2. 对批量动作做参数校验、确认与结果提示。
|
||||
*/
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { ProductBatchAction } from '#/views/product/list/types';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { batchProductActionApi } from '#/api/product';
|
||||
|
||||
interface CreateBatchActionsOptions {
|
||||
clearSelection: () => void;
|
||||
reloadCurrentStoreData: () => Promise<void>;
|
||||
selectedProductIds: Ref<string[]>;
|
||||
selectedStoreId: Ref<string>;
|
||||
}
|
||||
|
||||
export function createBatchActions(options: CreateBatchActionsOptions) {
|
||||
/** 执行批量操作。 */
|
||||
async function handleBatchAction(action: ProductBatchAction) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
if (options.selectedProductIds.value.length === 0) {
|
||||
message.warning('请先勾选商品');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'batch_delete') {
|
||||
Modal.confirm({
|
||||
title: '确认批量删除吗?',
|
||||
content: `将删除 ${options.selectedProductIds.value.length} 个商品`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await submitBatchAction('batch_delete');
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await submitBatchAction(action);
|
||||
}
|
||||
|
||||
/** 提交批量动作并刷新页面。 */
|
||||
async function submitBatchAction(action: ProductBatchAction) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
const payload = {
|
||||
action,
|
||||
storeId: options.selectedStoreId.value,
|
||||
productIds: [...options.selectedProductIds.value],
|
||||
remainStock: 0,
|
||||
reason: '批量沽清',
|
||||
recoverAt: '',
|
||||
syncToPlatform: true,
|
||||
notifyManager: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await batchProductActionApi(payload);
|
||||
message.success(`已处理 ${result.successCount}/${result.totalCount} 项`);
|
||||
options.clearSelection();
|
||||
await options.reloadCurrentStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleBatchAction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ProductKind, ProductStatus } from '#/api/product';
|
||||
/**
|
||||
* 文件职责:商品列表页面常量配置。
|
||||
* 1. 提供筛选选项、默认状态、批量动作定义。
|
||||
* 2. 统一维护编辑/快速编辑/沽清表单默认值。
|
||||
*/
|
||||
import type {
|
||||
ProductBatchAction,
|
||||
ProductEditorFormState,
|
||||
ProductFilterState,
|
||||
ProductQuickEditFormState,
|
||||
ProductSoldoutFormState,
|
||||
ProductStatusMeta,
|
||||
ProductViewMode,
|
||||
} from '#/views/product/list/types';
|
||||
|
||||
/** 分页尺寸选项。 */
|
||||
export const PAGE_SIZE_OPTIONS = ['12', '24', '48'];
|
||||
|
||||
/** 视图模式切换选项。 */
|
||||
export const PRODUCT_VIEW_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: ProductViewMode;
|
||||
}> = [
|
||||
{ label: '卡片', value: 'card' },
|
||||
{ label: '列表', value: 'list' },
|
||||
];
|
||||
|
||||
/** 商品状态筛选。 */
|
||||
export const PRODUCT_STATUS_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: '' | ProductStatus;
|
||||
}> = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '在售', value: 'on_sale' },
|
||||
{ label: '下架', value: 'off_shelf' },
|
||||
{ label: '售罄', value: 'sold_out' },
|
||||
];
|
||||
|
||||
/** 商品类型筛选。 */
|
||||
export const PRODUCT_KIND_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: '' | ProductKind;
|
||||
}> = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '单品', value: 'single' },
|
||||
{ label: '套餐', value: 'combo' },
|
||||
];
|
||||
|
||||
/** 商品状态展示映射。 */
|
||||
export const PRODUCT_STATUS_META_MAP: Record<ProductStatus, ProductStatusMeta> =
|
||||
{
|
||||
on_sale: {
|
||||
label: '在售',
|
||||
color: '#52c41a',
|
||||
badgeClass: 'status-on-sale',
|
||||
},
|
||||
off_shelf: {
|
||||
label: '下架',
|
||||
color: '#9ca3af',
|
||||
badgeClass: 'status-off-shelf',
|
||||
},
|
||||
sold_out: {
|
||||
label: '售罄',
|
||||
color: '#ef4444',
|
||||
badgeClass: 'status-sold-out',
|
||||
},
|
||||
};
|
||||
|
||||
/** 默认筛选状态。 */
|
||||
export const DEFAULT_FILTER_STATE: ProductFilterState = {
|
||||
categoryId: '',
|
||||
keyword: '',
|
||||
status: '',
|
||||
kind: '',
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
};
|
||||
|
||||
/** 默认编辑表单。 */
|
||||
export const DEFAULT_EDITOR_FORM: ProductEditorFormState = {
|
||||
id: '',
|
||||
name: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
kind: 'single',
|
||||
price: 0,
|
||||
originalPrice: null,
|
||||
stock: 0,
|
||||
tagsText: '',
|
||||
status: 'off_shelf',
|
||||
shelfMode: 'draft',
|
||||
timedOnShelfAt: '',
|
||||
};
|
||||
|
||||
/** 默认快速编辑表单。 */
|
||||
export const DEFAULT_QUICK_EDIT_FORM: ProductQuickEditFormState = {
|
||||
id: '',
|
||||
price: 0,
|
||||
originalPrice: null,
|
||||
stock: 0,
|
||||
isOnSale: false,
|
||||
};
|
||||
|
||||
/** 默认沽清表单。 */
|
||||
export const DEFAULT_SOLDOUT_FORM: ProductSoldoutFormState = {
|
||||
mode: 'today',
|
||||
remainStock: 0,
|
||||
reason: '',
|
||||
recoverAt: '',
|
||||
syncToPlatform: true,
|
||||
notifyManager: false,
|
||||
};
|
||||
|
||||
/** 批量操作选项。 */
|
||||
export const PRODUCT_BATCH_ACTION_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: ProductBatchAction;
|
||||
}> = [
|
||||
{ label: '批量上架', value: 'batch_on' },
|
||||
{ label: '批量下架', value: 'batch_off' },
|
||||
{ label: '批量沽清', value: 'batch_soldout' },
|
||||
{ label: '批量删除', value: 'batch_delete' },
|
||||
];
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 文件职责:商品列表数据加载动作。
|
||||
* 1. 加载门店、分类、列表数据。
|
||||
* 2. 管理列表与分类的 loading 状态。
|
||||
*/
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { ProductCategoryDto, ProductListItemDto } from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type { ProductFilterState } from '#/views/product/list/types';
|
||||
|
||||
import { getProductCategoryListApi, getProductListApi } from '#/api/product';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
interface CreateDataActionsOptions {
|
||||
categories: Ref<ProductCategoryDto[]>;
|
||||
filters: ProductFilterState;
|
||||
isCategoryLoading: Ref<boolean>;
|
||||
isListLoading: Ref<boolean>;
|
||||
isStoreLoading: Ref<boolean>;
|
||||
products: Ref<ProductListItemDto[]>;
|
||||
selectedStoreId: Ref<string>;
|
||||
stores: Ref<StoreListItemDto[]>;
|
||||
total: Ref<number>;
|
||||
}
|
||||
|
||||
export function createDataActions(options: CreateDataActionsOptions) {
|
||||
/** 加载门店列表并设置默认门店。 */
|
||||
async function loadStores() {
|
||||
options.isStoreLoading.value = true;
|
||||
try {
|
||||
const result = await getStoreListApi({
|
||||
keyword: undefined,
|
||||
businessStatus: undefined,
|
||||
auditStatus: undefined,
|
||||
serviceType: undefined,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
});
|
||||
options.stores.value = result.items ?? [];
|
||||
|
||||
if (options.stores.value.length === 0) {
|
||||
options.selectedStoreId.value = '';
|
||||
options.categories.value = [];
|
||||
options.products.value = [];
|
||||
options.total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelected = options.stores.value.some(
|
||||
(item) => item.id === options.selectedStoreId.value,
|
||||
);
|
||||
if (!hasSelected) {
|
||||
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isStoreLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载商品分类(全量口径,不受筛选影响)。 */
|
||||
async function loadCategories() {
|
||||
if (!options.selectedStoreId.value) {
|
||||
options.categories.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
options.isCategoryLoading.value = true;
|
||||
try {
|
||||
options.categories.value = await getProductCategoryListApi(
|
||||
options.selectedStoreId.value,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
options.categories.value = [];
|
||||
} finally {
|
||||
options.isCategoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载商品列表。 */
|
||||
async function loadProducts() {
|
||||
if (!options.selectedStoreId.value) {
|
||||
options.products.value = [];
|
||||
options.total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
options.isListLoading.value = true;
|
||||
try {
|
||||
const result = await getProductListApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
categoryId: options.filters.categoryId || undefined,
|
||||
keyword: options.filters.keyword.trim() || undefined,
|
||||
status: options.filters.status || undefined,
|
||||
kind: options.filters.kind || undefined,
|
||||
page: options.filters.page,
|
||||
pageSize: options.filters.pageSize,
|
||||
});
|
||||
|
||||
options.products.value = result.items ?? [];
|
||||
options.total.value = result.total ?? 0;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
options.products.value = [];
|
||||
options.total.value = 0;
|
||||
} finally {
|
||||
options.isListLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步刷新当前门店分类与列表。 */
|
||||
async function reloadCurrentStoreData() {
|
||||
await Promise.all([loadCategories(), loadProducts()]);
|
||||
}
|
||||
|
||||
return {
|
||||
loadCategories,
|
||||
loadProducts,
|
||||
loadStores,
|
||||
reloadCurrentStoreData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 文件职责:商品抽屉与单项动作。
|
||||
* 1. 维护添加/编辑、快速编辑、沽清抽屉打开与提交流程。
|
||||
* 2. 处理单商品删除与状态切换动作。
|
||||
*/
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { ProductListItemDto, ProductStatus } from '#/api/product';
|
||||
import type {
|
||||
ProductEditorDrawerMode,
|
||||
ProductEditorFormState,
|
||||
ProductQuickEditFormState,
|
||||
ProductSoldoutFormState,
|
||||
} from '#/views/product/list/types';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
changeProductStatusApi,
|
||||
deleteProductApi,
|
||||
getProductDetailApi,
|
||||
saveProductApi,
|
||||
soldoutProductApi,
|
||||
} from '#/api/product';
|
||||
|
||||
import {
|
||||
formatDateTime,
|
||||
mapDetailToEditorForm,
|
||||
mapListItemToEditorForm,
|
||||
mapListItemToQuickEditForm,
|
||||
mapListItemToSoldoutForm,
|
||||
toSavePayload,
|
||||
} from './helpers';
|
||||
|
||||
interface CreateDrawerActionsOptions {
|
||||
clearSelection: () => void;
|
||||
currentQuickEditProduct: Ref<null | ProductListItemDto>;
|
||||
currentSoldoutProduct: Ref<null | ProductListItemDto>;
|
||||
editorDrawerMode: Ref<ProductEditorDrawerMode>;
|
||||
editorForm: ProductEditorFormState;
|
||||
isEditorDrawerOpen: Ref<boolean>;
|
||||
isEditorSubmitting: Ref<boolean>;
|
||||
isQuickEditDrawerOpen: Ref<boolean>;
|
||||
isQuickEditSubmitting: Ref<boolean>;
|
||||
isSoldoutDrawerOpen: Ref<boolean>;
|
||||
isSoldoutSubmitting: Ref<boolean>;
|
||||
quickEditForm: ProductQuickEditFormState;
|
||||
reloadCurrentStoreData: () => Promise<void>;
|
||||
selectedStoreId: Ref<string>;
|
||||
soldoutForm: ProductSoldoutFormState;
|
||||
}
|
||||
|
||||
export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||
/** 打开添加商品抽屉。 */
|
||||
function openCreateDrawer(defaultCategoryId: string) {
|
||||
options.editorDrawerMode.value = 'create';
|
||||
options.editorForm.id = '';
|
||||
options.editorForm.name = '';
|
||||
options.editorForm.subtitle = '';
|
||||
options.editorForm.description = '';
|
||||
options.editorForm.categoryId = defaultCategoryId;
|
||||
options.editorForm.kind = 'single';
|
||||
options.editorForm.price = 0;
|
||||
options.editorForm.originalPrice = null;
|
||||
options.editorForm.stock = 0;
|
||||
options.editorForm.tagsText = '';
|
||||
options.editorForm.status = 'off_shelf';
|
||||
options.editorForm.shelfMode = 'draft';
|
||||
options.editorForm.timedOnShelfAt = '';
|
||||
options.isEditorDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 打开编辑商品抽屉并尽量补全详情。 */
|
||||
async function openEditDrawer(item: ProductListItemDto) {
|
||||
options.editorDrawerMode.value = 'edit';
|
||||
Object.assign(options.editorForm, mapListItemToEditorForm(item));
|
||||
options.isEditorDrawerOpen.value = true;
|
||||
|
||||
if (!options.selectedStoreId.value) return;
|
||||
try {
|
||||
const detail = await getProductDetailApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
productId: item.id,
|
||||
});
|
||||
Object.assign(options.editorForm, mapDetailToEditorForm(detail));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交添加/编辑商品。 */
|
||||
async function submitEditor() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
|
||||
if (!options.editorForm.name.trim()) {
|
||||
message.warning('请输入商品名称');
|
||||
return;
|
||||
}
|
||||
if (!options.editorForm.categoryId) {
|
||||
message.warning('请选择商品分类');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options.editorForm.shelfMode === 'scheduled' &&
|
||||
!options.editorForm.timedOnShelfAt
|
||||
) {
|
||||
message.warning('请选择定时上架时间');
|
||||
return;
|
||||
}
|
||||
|
||||
options.isEditorSubmitting.value = true;
|
||||
try {
|
||||
await saveProductApi(
|
||||
toSavePayload(options.editorForm, options.selectedStoreId.value),
|
||||
);
|
||||
message.success(
|
||||
options.editorDrawerMode.value === 'edit' ? '商品已保存' : '商品已添加',
|
||||
);
|
||||
options.isEditorDrawerOpen.value = false;
|
||||
options.clearSelection();
|
||||
await options.reloadCurrentStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isEditorSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开快速编辑抽屉。 */
|
||||
function openQuickEditDrawer(item: ProductListItemDto) {
|
||||
options.currentQuickEditProduct.value = item;
|
||||
Object.assign(options.quickEditForm, mapListItemToQuickEditForm(item));
|
||||
options.isQuickEditDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 提交快速编辑。 */
|
||||
async function submitQuickEdit() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
if (!options.currentQuickEditProduct.value) return;
|
||||
|
||||
const current = options.currentQuickEditProduct.value;
|
||||
options.isQuickEditSubmitting.value = true;
|
||||
try {
|
||||
await saveProductApi({
|
||||
id: current.id,
|
||||
storeId: options.selectedStoreId.value,
|
||||
categoryId: current.categoryId,
|
||||
kind: current.kind,
|
||||
name: current.name,
|
||||
subtitle: current.subtitle,
|
||||
description: current.subtitle,
|
||||
price: Number(options.quickEditForm.price || 0),
|
||||
originalPrice:
|
||||
Number(options.quickEditForm.originalPrice || 0) > 0
|
||||
? Number(options.quickEditForm.originalPrice)
|
||||
: null,
|
||||
stock: Math.max(
|
||||
0,
|
||||
Math.floor(Number(options.quickEditForm.stock || 0)),
|
||||
),
|
||||
tags: [...current.tags],
|
||||
status: options.quickEditForm.isOnSale ? 'on_sale' : 'off_shelf',
|
||||
shelfMode: options.quickEditForm.isOnSale ? 'now' : 'draft',
|
||||
spuCode: current.spuCode,
|
||||
});
|
||||
message.success('商品已更新');
|
||||
options.isQuickEditDrawerOpen.value = false;
|
||||
options.clearSelection();
|
||||
await options.reloadCurrentStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isQuickEditSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开沽清抽屉。 */
|
||||
function openSoldoutDrawer(item: ProductListItemDto) {
|
||||
options.currentSoldoutProduct.value = item;
|
||||
Object.assign(options.soldoutForm, mapListItemToSoldoutForm(item));
|
||||
options.isSoldoutDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 提交沽清设置。 */
|
||||
async function submitSoldout() {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
if (!options.currentSoldoutProduct.value) return;
|
||||
|
||||
if (
|
||||
options.soldoutForm.mode === 'timed' &&
|
||||
!options.soldoutForm.recoverAt
|
||||
) {
|
||||
message.warning('请选择恢复时间');
|
||||
return;
|
||||
}
|
||||
|
||||
options.isSoldoutSubmitting.value = true;
|
||||
try {
|
||||
await soldoutProductApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
productId: options.currentSoldoutProduct.value.id,
|
||||
mode: options.soldoutForm.mode,
|
||||
remainStock: Math.max(0, Math.floor(options.soldoutForm.remainStock)),
|
||||
reason: options.soldoutForm.reason.trim(),
|
||||
recoverAt:
|
||||
options.soldoutForm.mode === 'timed' && options.soldoutForm.recoverAt
|
||||
? formatDateTime(options.soldoutForm.recoverAt)
|
||||
: undefined,
|
||||
syncToPlatform: options.soldoutForm.syncToPlatform,
|
||||
notifyManager: options.soldoutForm.notifyManager,
|
||||
});
|
||||
message.success('商品已沽清');
|
||||
options.isSoldoutDrawerOpen.value = false;
|
||||
options.clearSelection();
|
||||
await options.reloadCurrentStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
options.isSoldoutSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单个商品。 */
|
||||
async function deleteProduct(item: ProductListItemDto) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
try {
|
||||
await deleteProductApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
productId: item.id,
|
||||
});
|
||||
message.success('商品已删除');
|
||||
options.clearSelection();
|
||||
await options.reloadCurrentStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换单个商品状态。 */
|
||||
async function changeProductStatus(
|
||||
item: ProductListItemDto,
|
||||
status: ProductStatus,
|
||||
) {
|
||||
if (!options.selectedStoreId.value) return;
|
||||
try {
|
||||
await changeProductStatusApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
productId: item.id,
|
||||
status,
|
||||
});
|
||||
message.success(status === 'on_sale' ? '商品已上架' : '商品已下架');
|
||||
options.clearSelection();
|
||||
await options.reloadCurrentStoreData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换添加/编辑抽屉显隐。 */
|
||||
function setEditorDrawerOpen(value: boolean) {
|
||||
options.isEditorDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
/** 切换快速编辑抽屉显隐。 */
|
||||
function setQuickEditDrawerOpen(value: boolean) {
|
||||
options.isQuickEditDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
/** 切换沽清抽屉显隐。 */
|
||||
function setSoldoutDrawerOpen(value: boolean) {
|
||||
options.isSoldoutDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
return {
|
||||
changeProductStatus,
|
||||
deleteProduct,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openQuickEditDrawer,
|
||||
openSoldoutDrawer,
|
||||
setEditorDrawerOpen,
|
||||
setQuickEditDrawerOpen,
|
||||
setSoldoutDrawerOpen,
|
||||
submitEditor,
|
||||
submitQuickEdit,
|
||||
submitSoldout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 文件职责:商品列表页面纯函数工具。
|
||||
* 1. 处理表单克隆、DTO 映射、字段归一化。
|
||||
* 2. 提供状态/库存展示与日期格式化工具。
|
||||
*/
|
||||
import type {
|
||||
ProductDetailDto,
|
||||
ProductListItemDto,
|
||||
ProductStatus,
|
||||
SaveProductDto,
|
||||
} from '#/api/product';
|
||||
import type {
|
||||
ProductEditorFormState,
|
||||
ProductFilterState,
|
||||
ProductQuickEditFormState,
|
||||
ProductSoldoutFormState,
|
||||
} from '#/views/product/list/types';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
DEFAULT_EDITOR_FORM,
|
||||
DEFAULT_FILTER_STATE,
|
||||
DEFAULT_QUICK_EDIT_FORM,
|
||||
DEFAULT_SOLDOUT_FORM,
|
||||
PRODUCT_STATUS_META_MAP,
|
||||
} from './constants';
|
||||
|
||||
/** 克隆筛选状态,避免引用污染。 */
|
||||
export function cloneFilterState(
|
||||
source: ProductFilterState = DEFAULT_FILTER_STATE,
|
||||
): ProductFilterState {
|
||||
return {
|
||||
categoryId: source.categoryId,
|
||||
keyword: source.keyword,
|
||||
status: source.status,
|
||||
kind: source.kind,
|
||||
page: source.page,
|
||||
pageSize: source.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** 克隆编辑表单。 */
|
||||
export function cloneEditorForm(
|
||||
source: ProductEditorFormState = DEFAULT_EDITOR_FORM,
|
||||
): ProductEditorFormState {
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
subtitle: source.subtitle,
|
||||
description: source.description,
|
||||
categoryId: source.categoryId,
|
||||
kind: source.kind,
|
||||
price: source.price,
|
||||
originalPrice: source.originalPrice,
|
||||
stock: source.stock,
|
||||
tagsText: source.tagsText,
|
||||
status: source.status,
|
||||
shelfMode: source.shelfMode,
|
||||
timedOnShelfAt: source.timedOnShelfAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 克隆快速编辑表单。 */
|
||||
export function cloneQuickEditForm(
|
||||
source: ProductQuickEditFormState = DEFAULT_QUICK_EDIT_FORM,
|
||||
): ProductQuickEditFormState {
|
||||
return {
|
||||
id: source.id,
|
||||
price: source.price,
|
||||
originalPrice: source.originalPrice,
|
||||
stock: source.stock,
|
||||
isOnSale: source.isOnSale,
|
||||
};
|
||||
}
|
||||
|
||||
/** 克隆沽清表单。 */
|
||||
export function cloneSoldoutForm(
|
||||
source: ProductSoldoutFormState = DEFAULT_SOLDOUT_FORM,
|
||||
): ProductSoldoutFormState {
|
||||
return {
|
||||
mode: source.mode,
|
||||
remainStock: source.remainStock,
|
||||
reason: source.reason,
|
||||
recoverAt: source.recoverAt,
|
||||
syncToPlatform: source.syncToPlatform,
|
||||
notifyManager: source.notifyManager,
|
||||
};
|
||||
}
|
||||
|
||||
/** 货币格式化。 */
|
||||
export function formatCurrency(value: null | number) {
|
||||
const amount = Number(value ?? 0);
|
||||
if (!Number.isFinite(amount)) return '¥0.00';
|
||||
return `¥${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 状态元信息解析。 */
|
||||
export function resolveStatusMeta(status: ProductStatus) {
|
||||
return PRODUCT_STATUS_META_MAP[status] ?? PRODUCT_STATUS_META_MAP.off_shelf;
|
||||
}
|
||||
|
||||
/** 按库存返回展示文本。 */
|
||||
export function formatStockText(stock: number) {
|
||||
if (stock <= 0) return '0 售罄';
|
||||
if (stock <= 10) return `${stock} 紧张`;
|
||||
return `${stock} 充足`;
|
||||
}
|
||||
|
||||
/** 按库存返回样式类。 */
|
||||
export function resolveStockClass(stock: number) {
|
||||
if (stock <= 0) return 'stock-out';
|
||||
if (stock <= 10) return 'stock-low';
|
||||
return 'stock-ok';
|
||||
}
|
||||
|
||||
/** 标签数组转输入框文本。 */
|
||||
export function tagsToText(tags: string[]) {
|
||||
return tags.join(', ');
|
||||
}
|
||||
|
||||
/** 输入框文本转标签数组。 */
|
||||
export function textToTags(source: string) {
|
||||
return [
|
||||
...new Set(
|
||||
source
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 统一格式化为 yyyy-MM-dd HH:mm:ss。 */
|
||||
export function formatDateTime(value: Date | dayjs.Dayjs | string) {
|
||||
const parsed = dayjs(value);
|
||||
if (!parsed.isValid()) return '';
|
||||
return parsed.format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
/** 列表项映射到编辑表单(兜底)。 */
|
||||
export function mapListItemToEditorForm(
|
||||
item: ProductListItemDto,
|
||||
): ProductEditorFormState {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
subtitle: item.subtitle,
|
||||
description: item.subtitle,
|
||||
categoryId: item.categoryId,
|
||||
kind: item.kind,
|
||||
price: item.price,
|
||||
originalPrice: item.originalPrice,
|
||||
stock: item.stock,
|
||||
tagsText: tagsToText(item.tags),
|
||||
status: item.status,
|
||||
shelfMode: 'draft',
|
||||
timedOnShelfAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 详情映射到编辑表单。 */
|
||||
export function mapDetailToEditorForm(
|
||||
detail: ProductDetailDto,
|
||||
): ProductEditorFormState {
|
||||
return {
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
subtitle: detail.subtitle,
|
||||
description: detail.description,
|
||||
categoryId: detail.categoryId,
|
||||
kind: detail.kind,
|
||||
price: detail.price,
|
||||
originalPrice: detail.originalPrice,
|
||||
stock: detail.stock,
|
||||
tagsText: tagsToText(detail.tags),
|
||||
status: detail.status,
|
||||
shelfMode: detail.status === 'on_sale' ? 'now' : 'draft',
|
||||
timedOnShelfAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 列表项映射到快速编辑表单。 */
|
||||
export function mapListItemToQuickEditForm(
|
||||
item: ProductListItemDto,
|
||||
): ProductQuickEditFormState {
|
||||
return {
|
||||
id: item.id,
|
||||
price: item.price,
|
||||
originalPrice: item.originalPrice,
|
||||
stock: item.stock,
|
||||
isOnSale: item.status === 'on_sale',
|
||||
};
|
||||
}
|
||||
|
||||
/** 列表项映射到沽清表单。 */
|
||||
export function mapListItemToSoldoutForm(
|
||||
item: ProductListItemDto,
|
||||
): ProductSoldoutFormState {
|
||||
return {
|
||||
mode: item.soldoutMode ?? 'today',
|
||||
remainStock: Math.max(0, item.stock),
|
||||
reason: item.soldoutMode ? '库存紧张' : '',
|
||||
recoverAt: '',
|
||||
syncToPlatform: true,
|
||||
notifyManager: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 编辑表单映射为保存请求参数。 */
|
||||
export function toSavePayload(
|
||||
form: ProductEditorFormState,
|
||||
storeId: string,
|
||||
): SaveProductDto {
|
||||
const price = Number(form.price || 0);
|
||||
const originalPrice = Number(form.originalPrice ?? 0);
|
||||
let normalizedStatus: ProductStatus = form.status;
|
||||
if (form.shelfMode === 'now') {
|
||||
normalizedStatus = 'on_sale';
|
||||
} else if (form.shelfMode === 'scheduled') {
|
||||
normalizedStatus = 'off_shelf';
|
||||
}
|
||||
|
||||
return {
|
||||
id: form.id || undefined,
|
||||
storeId,
|
||||
categoryId: form.categoryId,
|
||||
name: form.name.trim(),
|
||||
subtitle: form.subtitle.trim(),
|
||||
description: form.description.trim(),
|
||||
kind: form.kind,
|
||||
price: Number.isFinite(price) ? Number(price.toFixed(2)) : 0,
|
||||
originalPrice:
|
||||
originalPrice > 0 && Number.isFinite(originalPrice)
|
||||
? Number(originalPrice.toFixed(2))
|
||||
: null,
|
||||
stock: Math.max(0, Math.floor(Number(form.stock || 0))),
|
||||
tags: textToTags(form.tagsText),
|
||||
status: normalizedStatus,
|
||||
shelfMode: form.shelfMode,
|
||||
timedOnShelfAt:
|
||||
form.shelfMode === 'scheduled' && form.timedOnShelfAt
|
||||
? formatDateTime(form.timedOnShelfAt)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 文件职责:商品列表交互动作。
|
||||
* 1. 管理筛选应用、重置、分页切换。
|
||||
* 2. 管理列表勾选状态(单选、全选、清空)。
|
||||
*/
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
ProductFilterState,
|
||||
ProductPaginationChangePayload,
|
||||
} from '#/views/product/list/types';
|
||||
|
||||
interface CreateListActionsOptions {
|
||||
filters: ProductFilterState;
|
||||
loadProducts: () => Promise<void>;
|
||||
selectedProductIds: Ref<string[]>;
|
||||
}
|
||||
|
||||
export function createListActions(options: CreateListActionsOptions) {
|
||||
/** 清空当前勾选。 */
|
||||
function clearSelection() {
|
||||
options.selectedProductIds.value = [];
|
||||
}
|
||||
|
||||
/** 切换单个商品勾选。 */
|
||||
function toggleSelectProduct(productId: string, checked: boolean) {
|
||||
const idSet = new Set(options.selectedProductIds.value);
|
||||
if (checked) {
|
||||
idSet.add(productId);
|
||||
} else {
|
||||
idSet.delete(productId);
|
||||
}
|
||||
options.selectedProductIds.value = [...idSet];
|
||||
}
|
||||
|
||||
/** 按当前页 ID 执行全选/反选。 */
|
||||
function toggleSelectAllOnPage(currentPageIds: string[], checked: boolean) {
|
||||
const idSet = new Set(options.selectedProductIds.value);
|
||||
if (checked) {
|
||||
for (const id of currentPageIds) {
|
||||
idSet.add(id);
|
||||
}
|
||||
} else {
|
||||
for (const id of currentPageIds) {
|
||||
idSet.delete(id);
|
||||
}
|
||||
}
|
||||
options.selectedProductIds.value = [...idSet];
|
||||
}
|
||||
|
||||
/** 应用筛选条件并回到第一页。 */
|
||||
async function applyFilters() {
|
||||
options.filters.page = 1;
|
||||
await options.loadProducts();
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
/** 重置筛选后重新查询列表。 */
|
||||
async function resetFilters() {
|
||||
options.filters.keyword = '';
|
||||
options.filters.status = '';
|
||||
options.filters.kind = '';
|
||||
options.filters.categoryId = '';
|
||||
options.filters.page = 1;
|
||||
options.filters.pageSize = 12;
|
||||
await options.loadProducts();
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
/** 处理分页变化。 */
|
||||
async function changePage(payload: ProductPaginationChangePayload) {
|
||||
options.filters.page = payload.page;
|
||||
options.filters.pageSize = payload.pageSize;
|
||||
await options.loadProducts();
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
return {
|
||||
applyFilters,
|
||||
changePage,
|
||||
clearSelection,
|
||||
resetFilters,
|
||||
toggleSelectAllOnPage,
|
||||
toggleSelectProduct,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 文件职责:商品列表页面主编排。
|
||||
* 1. 维护门店、分类、列表、筛选、勾选、抽屉状态。
|
||||
* 2. 组装数据加载、列表交互、抽屉动作、批量动作。
|
||||
* 3. 对视图层暴露可直接绑定的状态与方法。
|
||||
*/
|
||||
import type {
|
||||
ProductCategoryDto,
|
||||
ProductListItemDto,
|
||||
ProductStatus,
|
||||
} from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type {
|
||||
ProductBatchAction,
|
||||
ProductFilterState,
|
||||
} from '#/views/product/list/types';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { createBatchActions } from './product-list-page/batch-actions';
|
||||
import {
|
||||
DEFAULT_EDITOR_FORM,
|
||||
DEFAULT_FILTER_STATE,
|
||||
DEFAULT_QUICK_EDIT_FORM,
|
||||
DEFAULT_SOLDOUT_FORM,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
PRODUCT_KIND_OPTIONS,
|
||||
PRODUCT_STATUS_OPTIONS,
|
||||
PRODUCT_VIEW_OPTIONS,
|
||||
} from './product-list-page/constants';
|
||||
import { createDataActions } from './product-list-page/data-actions';
|
||||
import { createDrawerActions } from './product-list-page/drawer-actions';
|
||||
import {
|
||||
cloneEditorForm,
|
||||
cloneFilterState,
|
||||
cloneQuickEditForm,
|
||||
cloneSoldoutForm,
|
||||
formatCurrency,
|
||||
resolveStatusMeta,
|
||||
} from './product-list-page/helpers';
|
||||
import { createListActions } from './product-list-page/list-actions';
|
||||
|
||||
export function useProductListPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 1. 页面加载状态。
|
||||
const isStoreLoading = ref(false);
|
||||
const isCategoryLoading = ref(false);
|
||||
const isListLoading = ref(false);
|
||||
const isEditorSubmitting = ref(false);
|
||||
const isQuickEditSubmitting = ref(false);
|
||||
const isSoldoutSubmitting = ref(false);
|
||||
|
||||
// 2. 核心业务数据。
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const categories = ref<ProductCategoryDto[]>([]);
|
||||
const products = ref<ProductListItemDto[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const filters = reactive<ProductFilterState>(
|
||||
cloneFilterState(DEFAULT_FILTER_STATE),
|
||||
);
|
||||
const viewMode = ref<'card' | 'list'>('list');
|
||||
const selectedProductIds = ref<string[]>([]);
|
||||
|
||||
// 3. 抽屉状态与表单。
|
||||
const isEditorDrawerOpen = ref(false);
|
||||
const editorDrawerMode = ref<'create' | 'edit'>('create');
|
||||
const editorForm = reactive(cloneEditorForm(DEFAULT_EDITOR_FORM));
|
||||
|
||||
const isQuickEditDrawerOpen = ref(false);
|
||||
const quickEditForm = reactive(cloneQuickEditForm(DEFAULT_QUICK_EDIT_FORM));
|
||||
const currentQuickEditProduct = ref<null | ProductListItemDto>(null);
|
||||
|
||||
const isSoldoutDrawerOpen = ref(false);
|
||||
const soldoutForm = reactive(cloneSoldoutForm(DEFAULT_SOLDOUT_FORM));
|
||||
const currentSoldoutProduct = ref<null | ProductListItemDto>(null);
|
||||
|
||||
// 4. 衍生状态。
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({ label: item.name, value: item.id })),
|
||||
);
|
||||
|
||||
const categorySidebarItems = computed(() => {
|
||||
const allCount = categories.value.reduce(
|
||||
(sum, item) => sum + item.productCount,
|
||||
0,
|
||||
);
|
||||
return [
|
||||
{ id: '', name: '全部商品', productCount: allCount },
|
||||
...categories.value.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
productCount: item.productCount,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
categories.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const currentPageIds = computed(() => products.value.map((item) => item.id));
|
||||
|
||||
const isAllCurrentPageChecked = computed(() => {
|
||||
if (currentPageIds.value.length === 0) return false;
|
||||
return currentPageIds.value.every((id) =>
|
||||
selectedProductIds.value.includes(id),
|
||||
);
|
||||
});
|
||||
|
||||
const isCurrentPageIndeterminate = computed(() => {
|
||||
const selectedCount = currentPageIds.value.filter((id) =>
|
||||
selectedProductIds.value.includes(id),
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < currentPageIds.value.length;
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => selectedProductIds.value.length);
|
||||
|
||||
const editorDrawerTitle = computed(() =>
|
||||
editorDrawerMode.value === 'edit' ? '编辑商品' : '添加商品',
|
||||
);
|
||||
|
||||
const editorSubmitText = computed(() =>
|
||||
editorDrawerMode.value === 'edit' ? '保存修改' : '确认添加',
|
||||
);
|
||||
|
||||
const quickEditSummary = computed(() => currentQuickEditProduct.value);
|
||||
const soldoutSummary = computed(() => currentSoldoutProduct.value);
|
||||
|
||||
// 5. 动作装配。
|
||||
const { loadProducts, loadStores, reloadCurrentStoreData } =
|
||||
createDataActions({
|
||||
categories,
|
||||
filters,
|
||||
isCategoryLoading,
|
||||
isListLoading,
|
||||
isStoreLoading,
|
||||
products,
|
||||
selectedStoreId,
|
||||
stores,
|
||||
total,
|
||||
});
|
||||
|
||||
const {
|
||||
applyFilters,
|
||||
changePage,
|
||||
clearSelection,
|
||||
resetFilters,
|
||||
toggleSelectAllOnPage,
|
||||
toggleSelectProduct,
|
||||
} = createListActions({
|
||||
filters,
|
||||
loadProducts,
|
||||
selectedProductIds,
|
||||
});
|
||||
|
||||
const {
|
||||
changeProductStatus,
|
||||
deleteProduct,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openQuickEditDrawer,
|
||||
openSoldoutDrawer,
|
||||
setEditorDrawerOpen,
|
||||
setQuickEditDrawerOpen,
|
||||
setSoldoutDrawerOpen,
|
||||
submitEditor,
|
||||
submitQuickEdit,
|
||||
submitSoldout,
|
||||
} = createDrawerActions({
|
||||
clearSelection,
|
||||
currentQuickEditProduct,
|
||||
currentSoldoutProduct,
|
||||
editorDrawerMode,
|
||||
editorForm,
|
||||
isEditorDrawerOpen,
|
||||
isEditorSubmitting,
|
||||
isQuickEditDrawerOpen,
|
||||
isQuickEditSubmitting,
|
||||
isSoldoutDrawerOpen,
|
||||
isSoldoutSubmitting,
|
||||
quickEditForm,
|
||||
reloadCurrentStoreData,
|
||||
selectedStoreId,
|
||||
soldoutForm,
|
||||
});
|
||||
|
||||
const { handleBatchAction } = createBatchActions({
|
||||
clearSelection,
|
||||
reloadCurrentStoreData,
|
||||
selectedProductIds,
|
||||
selectedStoreId,
|
||||
});
|
||||
|
||||
// 6. 字段更新方法。
|
||||
function setSelectedStoreId(value: string) {
|
||||
selectedStoreId.value = value;
|
||||
}
|
||||
|
||||
async function setSelectedCategoryId(value: string) {
|
||||
filters.categoryId = value;
|
||||
filters.page = 1;
|
||||
await loadProducts();
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function setFilterKeyword(value: string) {
|
||||
filters.keyword = value;
|
||||
}
|
||||
|
||||
function setFilterStatus(value: '' | ProductStatus) {
|
||||
filters.status = value;
|
||||
}
|
||||
|
||||
function setFilterKind(value: '' | 'combo' | 'single') {
|
||||
filters.kind = value;
|
||||
}
|
||||
|
||||
function setViewMode(value: 'card' | 'list') {
|
||||
viewMode.value = value;
|
||||
}
|
||||
|
||||
function setEditorName(value: string) {
|
||||
editorForm.name = value;
|
||||
}
|
||||
|
||||
function setEditorSubtitle(value: string) {
|
||||
editorForm.subtitle = value;
|
||||
}
|
||||
|
||||
function setEditorCategoryId(value: string) {
|
||||
editorForm.categoryId = value;
|
||||
}
|
||||
|
||||
function setEditorDescription(value: string) {
|
||||
editorForm.description = value;
|
||||
}
|
||||
|
||||
function setEditorKind(value: 'combo' | 'single') {
|
||||
editorForm.kind = value;
|
||||
}
|
||||
|
||||
function setEditorPrice(value: number) {
|
||||
editorForm.price = Math.max(0, Number(value || 0));
|
||||
}
|
||||
|
||||
function setEditorOriginalPrice(value: null | number) {
|
||||
if (value === null || value === undefined || Number(value) <= 0) {
|
||||
editorForm.originalPrice = null;
|
||||
return;
|
||||
}
|
||||
editorForm.originalPrice = Math.max(0, Number(value));
|
||||
}
|
||||
|
||||
function setEditorStock(value: number) {
|
||||
editorForm.stock = Math.max(0, Math.floor(Number(value || 0)));
|
||||
}
|
||||
|
||||
function setEditorTagsText(value: string) {
|
||||
editorForm.tagsText = value;
|
||||
}
|
||||
|
||||
function setEditorShelfMode(value: 'draft' | 'now' | 'scheduled') {
|
||||
editorForm.shelfMode = value;
|
||||
}
|
||||
|
||||
function setEditorTimedOnShelfAt(value: string) {
|
||||
editorForm.timedOnShelfAt = value;
|
||||
}
|
||||
|
||||
function setQuickEditPrice(value: number) {
|
||||
quickEditForm.price = Math.max(0, Number(value || 0));
|
||||
}
|
||||
|
||||
function setQuickEditOriginalPrice(value: null | number) {
|
||||
if (value === null || value === undefined || Number(value) <= 0) {
|
||||
quickEditForm.originalPrice = null;
|
||||
return;
|
||||
}
|
||||
quickEditForm.originalPrice = Math.max(0, Number(value));
|
||||
}
|
||||
|
||||
function setQuickEditStock(value: number) {
|
||||
quickEditForm.stock = Math.max(0, Math.floor(Number(value || 0)));
|
||||
}
|
||||
|
||||
function setQuickEditOnSale(value: boolean) {
|
||||
quickEditForm.isOnSale = value;
|
||||
}
|
||||
|
||||
function setSoldoutMode(value: 'permanent' | 'timed' | 'today') {
|
||||
soldoutForm.mode = value;
|
||||
}
|
||||
|
||||
function setSoldoutRemainStock(value: number) {
|
||||
soldoutForm.remainStock = Math.max(0, Math.floor(Number(value || 0)));
|
||||
}
|
||||
|
||||
function setSoldoutReason(value: string) {
|
||||
soldoutForm.reason = value;
|
||||
}
|
||||
|
||||
function setSoldoutRecoverAt(value: string) {
|
||||
soldoutForm.recoverAt = value;
|
||||
}
|
||||
|
||||
function setSoldoutSyncToPlatform(value: boolean) {
|
||||
soldoutForm.syncToPlatform = value;
|
||||
}
|
||||
|
||||
function setSoldoutNotifyManager(value: boolean) {
|
||||
soldoutForm.notifyManager = value;
|
||||
}
|
||||
|
||||
// 7. 页面动作封装。
|
||||
async function openCreateProductDrawer() {
|
||||
const fallbackCategoryId =
|
||||
filters.categoryId || categoryOptions.value[0]?.value || '';
|
||||
openCreateDrawer(fallbackCategoryId);
|
||||
}
|
||||
|
||||
/** 按商品 ID 跳转详情页。 */
|
||||
function openProductDetailById(productId: string) {
|
||||
if (!selectedStoreId.value || !productId) return;
|
||||
router.push({
|
||||
path: '/product/detail',
|
||||
query: {
|
||||
storeId: selectedStoreId.value,
|
||||
productId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openProductDetail(item: ProductListItemDto) {
|
||||
openProductDetailById(item.id);
|
||||
}
|
||||
|
||||
async function applySearchFilters() {
|
||||
await applyFilters();
|
||||
}
|
||||
|
||||
async function resetSearchFilters() {
|
||||
await resetFilters();
|
||||
}
|
||||
|
||||
async function handlePageChange(payload: { page: number; pageSize: number }) {
|
||||
await changePage(payload);
|
||||
}
|
||||
|
||||
function handleToggleSelect(payload: {
|
||||
checked: boolean;
|
||||
productId: string;
|
||||
}) {
|
||||
toggleSelectProduct(payload.productId, payload.checked);
|
||||
}
|
||||
|
||||
function handleToggleSelectAll(checked: boolean) {
|
||||
toggleSelectAllOnPage(currentPageIds.value, checked);
|
||||
}
|
||||
|
||||
async function handleSingleStatusChange(payload: {
|
||||
item: ProductListItemDto;
|
||||
status: ProductStatus;
|
||||
}) {
|
||||
await changeProductStatus(payload.item, payload.status);
|
||||
}
|
||||
|
||||
async function handleDeleteProduct(item: ProductListItemDto) {
|
||||
await deleteProduct(item);
|
||||
}
|
||||
|
||||
async function handleBatchCommand(action: ProductBatchAction) {
|
||||
await handleBatchAction(action);
|
||||
}
|
||||
|
||||
// 8. 监听门店切换并刷新页面。
|
||||
watch(selectedStoreId, async (storeId) => {
|
||||
if (!storeId) {
|
||||
categories.value = [];
|
||||
products.value = [];
|
||||
total.value = 0;
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
filters.page = 1;
|
||||
filters.categoryId = '';
|
||||
clearSelection();
|
||||
await reloadCurrentStoreData();
|
||||
});
|
||||
|
||||
// 9. 页面初始化。
|
||||
onMounted(loadStores);
|
||||
|
||||
return {
|
||||
PAGE_SIZE_OPTIONS,
|
||||
PRODUCT_KIND_OPTIONS,
|
||||
PRODUCT_STATUS_OPTIONS,
|
||||
PRODUCT_VIEW_OPTIONS,
|
||||
applySearchFilters,
|
||||
categories,
|
||||
categoryOptions,
|
||||
categorySidebarItems,
|
||||
clearSelection,
|
||||
currentPageIds,
|
||||
deleteProduct: handleDeleteProduct,
|
||||
editorDrawerMode,
|
||||
editorDrawerTitle,
|
||||
editorForm,
|
||||
editorSubmitText,
|
||||
filters,
|
||||
formatCurrency,
|
||||
handleBatchCommand,
|
||||
handlePageChange,
|
||||
handleSingleStatusChange,
|
||||
handleToggleSelect,
|
||||
handleToggleSelectAll,
|
||||
isAllCurrentPageChecked,
|
||||
isCategoryLoading,
|
||||
isCurrentPageIndeterminate,
|
||||
isEditorDrawerOpen,
|
||||
isEditorSubmitting,
|
||||
isListLoading,
|
||||
isQuickEditDrawerOpen,
|
||||
isQuickEditSubmitting,
|
||||
isSoldoutDrawerOpen,
|
||||
isSoldoutSubmitting,
|
||||
isStoreLoading,
|
||||
openCreateProductDrawer,
|
||||
openEditDrawer,
|
||||
openProductDetail,
|
||||
openProductDetailById,
|
||||
openQuickEditDrawer,
|
||||
openSoldoutDrawer,
|
||||
products,
|
||||
quickEditForm,
|
||||
quickEditSummary,
|
||||
resetSearchFilters,
|
||||
resolveStatusMeta,
|
||||
selectedCount,
|
||||
selectedProductIds,
|
||||
selectedStoreId,
|
||||
setEditorCategoryId,
|
||||
setEditorDescription,
|
||||
setEditorDrawerOpen,
|
||||
setEditorKind,
|
||||
setEditorName,
|
||||
setEditorOriginalPrice,
|
||||
setEditorPrice,
|
||||
setEditorShelfMode,
|
||||
setEditorStock,
|
||||
setEditorSubtitle,
|
||||
setEditorTagsText,
|
||||
setEditorTimedOnShelfAt,
|
||||
setFilterKind,
|
||||
setFilterKeyword,
|
||||
setFilterStatus,
|
||||
setQuickEditDrawerOpen,
|
||||
setQuickEditOnSale,
|
||||
setQuickEditOriginalPrice,
|
||||
setQuickEditPrice,
|
||||
setQuickEditStock,
|
||||
setSelectedCategoryId,
|
||||
setSelectedStoreId,
|
||||
setSoldoutDrawerOpen,
|
||||
setSoldoutMode,
|
||||
setSoldoutNotifyManager,
|
||||
setSoldoutReason,
|
||||
setSoldoutRecoverAt,
|
||||
setSoldoutRemainStock,
|
||||
setSoldoutSyncToPlatform,
|
||||
setViewMode,
|
||||
soldoutForm,
|
||||
soldoutSummary,
|
||||
storeOptions,
|
||||
submitEditor,
|
||||
submitQuickEdit,
|
||||
submitSoldout,
|
||||
total,
|
||||
viewMode,
|
||||
};
|
||||
}
|
||||
253
apps/web-antd/src/views/product/list/index.vue
Normal file
253
apps/web-antd/src/views/product/list/index.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表页面主视图。
|
||||
* 1. 组装分类侧栏、筛选条、动作条、列表区。
|
||||
* 2. 挂载添加/编辑、快速编辑、沽清抽屉。
|
||||
*/
|
||||
import type { ProductBatchAction } from './types';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty } from 'ant-design-vue';
|
||||
|
||||
import ProductActionBar from './components/ProductActionBar.vue';
|
||||
import ProductCategorySidebar from './components/ProductCategorySidebar.vue';
|
||||
import ProductEditorDrawer from './components/ProductEditorDrawer.vue';
|
||||
import ProductFilterToolbar from './components/ProductFilterToolbar.vue';
|
||||
import ProductListSection from './components/ProductListSection.vue';
|
||||
import ProductQuickEditDrawer from './components/ProductQuickEditDrawer.vue';
|
||||
import ProductSoldoutDrawer from './components/ProductSoldoutDrawer.vue';
|
||||
import { PRODUCT_BATCH_ACTION_OPTIONS } from './composables/product-list-page/constants';
|
||||
import { useProductListPage } from './composables/useProductListPage';
|
||||
|
||||
const {
|
||||
PAGE_SIZE_OPTIONS,
|
||||
PRODUCT_KIND_OPTIONS,
|
||||
PRODUCT_STATUS_OPTIONS,
|
||||
applySearchFilters,
|
||||
categoryOptions,
|
||||
categorySidebarItems,
|
||||
clearSelection,
|
||||
deleteProduct,
|
||||
editorDrawerMode,
|
||||
editorDrawerTitle,
|
||||
editorForm,
|
||||
editorSubmitText,
|
||||
filters,
|
||||
handleBatchCommand,
|
||||
handlePageChange,
|
||||
handleSingleStatusChange,
|
||||
handleToggleSelect,
|
||||
handleToggleSelectAll,
|
||||
isAllCurrentPageChecked,
|
||||
isCategoryLoading,
|
||||
isCurrentPageIndeterminate,
|
||||
isEditorDrawerOpen,
|
||||
isEditorSubmitting,
|
||||
isListLoading,
|
||||
isQuickEditDrawerOpen,
|
||||
isQuickEditSubmitting,
|
||||
isSoldoutDrawerOpen,
|
||||
isSoldoutSubmitting,
|
||||
isStoreLoading,
|
||||
openCreateProductDrawer,
|
||||
openEditDrawer,
|
||||
openProductDetail,
|
||||
openProductDetailById,
|
||||
openQuickEditDrawer,
|
||||
openSoldoutDrawer,
|
||||
products,
|
||||
quickEditForm,
|
||||
quickEditSummary,
|
||||
resetSearchFilters,
|
||||
selectedCount,
|
||||
selectedProductIds,
|
||||
selectedStoreId,
|
||||
setEditorCategoryId,
|
||||
setEditorDescription,
|
||||
setEditorDrawerOpen,
|
||||
setEditorKind,
|
||||
setEditorName,
|
||||
setEditorOriginalPrice,
|
||||
setEditorPrice,
|
||||
setEditorShelfMode,
|
||||
setEditorStock,
|
||||
setEditorSubtitle,
|
||||
setEditorTagsText,
|
||||
setEditorTimedOnShelfAt,
|
||||
setFilterKind,
|
||||
setFilterKeyword,
|
||||
setFilterStatus,
|
||||
setQuickEditDrawerOpen,
|
||||
setQuickEditOnSale,
|
||||
setQuickEditOriginalPrice,
|
||||
setQuickEditPrice,
|
||||
setQuickEditStock,
|
||||
setSelectedCategoryId,
|
||||
setSelectedStoreId,
|
||||
setSoldoutDrawerOpen,
|
||||
setSoldoutMode,
|
||||
setSoldoutNotifyManager,
|
||||
setSoldoutReason,
|
||||
setSoldoutRecoverAt,
|
||||
setSoldoutRemainStock,
|
||||
setSoldoutSyncToPlatform,
|
||||
setViewMode,
|
||||
soldoutForm,
|
||||
soldoutSummary,
|
||||
storeOptions,
|
||||
submitEditor,
|
||||
submitQuickEdit,
|
||||
submitSoldout,
|
||||
total,
|
||||
viewMode,
|
||||
} = useProductListPage();
|
||||
|
||||
/** 快速编辑抽屉跳转详情。 */
|
||||
function handleQuickEditDetail() {
|
||||
if (!quickEditSummary.value) return;
|
||||
openProductDetail(quickEditSummary.value);
|
||||
}
|
||||
|
||||
/** 添加/编辑抽屉跳转详情。 */
|
||||
function handleEditorDetail() {
|
||||
if (!editorForm.id) return;
|
||||
openProductDetailById(editorForm.id);
|
||||
}
|
||||
|
||||
/** 批量动作透传。 */
|
||||
function onBatchAction(action: ProductBatchAction) {
|
||||
handleBatchCommand(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="商品列表" content-class="space-y-4 page-product-list">
|
||||
<template v-if="storeOptions.length === 0">
|
||||
<Card :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="product-page-layout">
|
||||
<ProductCategorySidebar
|
||||
:categories="categorySidebarItems"
|
||||
:selected-category-id="filters.categoryId"
|
||||
:is-loading="isCategoryLoading"
|
||||
@select="setSelectedCategoryId"
|
||||
/>
|
||||
|
||||
<div class="product-page-content">
|
||||
<ProductFilterToolbar
|
||||
:selected-store-id="selectedStoreId"
|
||||
:store-options="storeOptions"
|
||||
:is-store-loading="isStoreLoading"
|
||||
:keyword="filters.keyword"
|
||||
:status="filters.status"
|
||||
:kind="filters.kind"
|
||||
:status-options="PRODUCT_STATUS_OPTIONS"
|
||||
:kind-options="PRODUCT_KIND_OPTIONS"
|
||||
:view-mode="viewMode"
|
||||
:is-loading="isListLoading"
|
||||
@update:selected-store-id="setSelectedStoreId"
|
||||
@update:keyword="setFilterKeyword"
|
||||
@update:status="setFilterStatus"
|
||||
@update:kind="setFilterKind"
|
||||
@update:view-mode="setViewMode"
|
||||
@search="applySearchFilters"
|
||||
@reset="resetSearchFilters"
|
||||
/>
|
||||
|
||||
<ProductActionBar
|
||||
:selected-count="selectedCount"
|
||||
:batch-disabled="selectedCount === 0"
|
||||
:batch-action-options="PRODUCT_BATCH_ACTION_OPTIONS"
|
||||
@add="openCreateProductDrawer"
|
||||
@batch-action="onBatchAction"
|
||||
@clear-selection="clearSelection"
|
||||
/>
|
||||
|
||||
<ProductListSection
|
||||
:rows="products"
|
||||
:view-mode="viewMode"
|
||||
:selected-product-ids="selectedProductIds"
|
||||
:is-loading="isListLoading"
|
||||
:all-checked="isAllCurrentPageChecked"
|
||||
:indeterminate="isCurrentPageIndeterminate"
|
||||
:page="filters.page"
|
||||
:page-size="filters.pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
:total="total"
|
||||
@toggle-select="handleToggleSelect"
|
||||
@toggle-select-all="handleToggleSelectAll"
|
||||
@page-change="handlePageChange"
|
||||
@edit="openEditDrawer"
|
||||
@quick-edit="openQuickEditDrawer"
|
||||
@detail="openProductDetail"
|
||||
@delete="deleteProduct"
|
||||
@change-status="handleSingleStatusChange"
|
||||
@soldout="openSoldoutDrawer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ProductEditorDrawer
|
||||
:open="isEditorDrawerOpen"
|
||||
:title="editorDrawerTitle"
|
||||
:submit-text="editorSubmitText"
|
||||
:show-detail-link="editorDrawerMode === 'edit'"
|
||||
:form="editorForm"
|
||||
:category-options="categoryOptions"
|
||||
:is-saving="isEditorSubmitting"
|
||||
:on-set-name="setEditorName"
|
||||
:on-set-subtitle="setEditorSubtitle"
|
||||
:on-set-category-id="setEditorCategoryId"
|
||||
:on-set-description="setEditorDescription"
|
||||
:on-set-kind="setEditorKind"
|
||||
:on-set-price="setEditorPrice"
|
||||
:on-set-original-price="setEditorOriginalPrice"
|
||||
:on-set-stock="setEditorStock"
|
||||
:on-set-tags-text="setEditorTagsText"
|
||||
:on-set-shelf-mode="setEditorShelfMode"
|
||||
:on-set-timed-on-shelf-at="setEditorTimedOnShelfAt"
|
||||
@update:open="setEditorDrawerOpen"
|
||||
@submit="submitEditor"
|
||||
@detail="handleEditorDetail"
|
||||
/>
|
||||
|
||||
<ProductQuickEditDrawer
|
||||
:open="isQuickEditDrawerOpen"
|
||||
:form="quickEditForm"
|
||||
:product="quickEditSummary"
|
||||
:is-saving="isQuickEditSubmitting"
|
||||
:on-set-price="setQuickEditPrice"
|
||||
:on-set-original-price="setQuickEditOriginalPrice"
|
||||
:on-set-stock="setQuickEditStock"
|
||||
:on-set-is-on-sale="setQuickEditOnSale"
|
||||
@update:open="setQuickEditDrawerOpen"
|
||||
@submit="submitQuickEdit"
|
||||
@detail="handleQuickEditDetail"
|
||||
/>
|
||||
|
||||
<ProductSoldoutDrawer
|
||||
:open="isSoldoutDrawerOpen"
|
||||
:form="soldoutForm"
|
||||
:product="soldoutSummary"
|
||||
:is-saving="isSoldoutSubmitting"
|
||||
:on-set-mode="setSoldoutMode"
|
||||
:on-set-remain-stock="setSoldoutRemainStock"
|
||||
:on-set-reason="setSoldoutReason"
|
||||
:on-set-recover-at="setSoldoutRecoverAt"
|
||||
:on-set-sync-to-platform="setSoldoutSyncToPlatform"
|
||||
:on-set-notify-manager="setSoldoutNotifyManager"
|
||||
@update:open="setSoldoutDrawerOpen"
|
||||
@submit="submitSoldout"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
24
apps/web-antd/src/views/product/list/styles/actionbar.less
Normal file
24
apps/web-antd/src/views/product/list/styles/actionbar.less
Normal file
@@ -0,0 +1,24 @@
|
||||
/* 文件职责:商品列表动作条样式。 */
|
||||
.page-product-list {
|
||||
.product-action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.product-action-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-action-selected {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
28
apps/web-antd/src/views/product/list/styles/base.less
Normal file
28
apps/web-antd/src/views/product/list/styles/base.less
Normal file
@@ -0,0 +1,28 @@
|
||||
/* 文件职责:商品列表页面基础布局样式。 */
|
||||
.page-product-list {
|
||||
.product-page-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.product-page-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.product-category-sidebar-card,
|
||||
.product-filter-toolbar-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
207
apps/web-antd/src/views/product/list/styles/drawer.less
Normal file
207
apps/web-antd/src/views/product/list/styles/drawer.less
Normal file
@@ -0,0 +1,207 @@
|
||||
/* 文件职责:商品页面抽屉样式。 */
|
||||
.product-editor-drawer,
|
||||
.product-quick-edit-drawer,
|
||||
.product-soldout-drawer {
|
||||
.ant-drawer-header {
|
||||
min-height: 56px;
|
||||
padding: 0 22px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 18px 20px 12px;
|
||||
}
|
||||
|
||||
.ant-drawer-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.product-drawer-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.product-drawer-section-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.drawer-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drawer-form-item.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.drawer-form-item.compact {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drawer-form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.drawer-form-label.required::before {
|
||||
margin-right: 4px;
|
||||
color: #ef4444;
|
||||
content: '*';
|
||||
}
|
||||
|
||||
.stock-input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.product-editor-drawer {
|
||||
.product-kind-radio-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-kind-radio-group .ant-radio-button-wrapper {
|
||||
width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-shelf-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shelf-radio-item {
|
||||
padding: 8px 10px;
|
||||
margin-inline-start: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.shelf-time-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-quick-edit-drawer,
|
||||
.product-soldout-drawer {
|
||||
.product-quick-card,
|
||||
.product-soldout-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin-bottom: 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.product-quick-cover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-quick-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-quick-meta .name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-quick-meta .sub {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.product-soldout-drawer {
|
||||
.soldout-mode-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.soldout-mode-item {
|
||||
padding: 8px 10px;
|
||||
margin-inline-start: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-drawer-footer-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.product-drawer-footer-right .ant-btn {
|
||||
min-width: 92px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
8
apps/web-antd/src/views/product/list/styles/index.less
Normal file
8
apps/web-antd/src/views/product/list/styles/index.less
Normal file
@@ -0,0 +1,8 @@
|
||||
/* 文件职责:商品列表页面样式聚合入口(仅负责分片导入)。 */
|
||||
@import './base.less';
|
||||
@import './sidebar.less';
|
||||
@import './toolbar.less';
|
||||
@import './actionbar.less';
|
||||
@import './list.less';
|
||||
@import './drawer.less';
|
||||
@import './responsive.less';
|
||||
347
apps/web-antd/src/views/product/list/styles/list.less
Normal file
347
apps/web-antd/src/views/product/list/styles/list.less
Normal file
@@ -0,0 +1,347 @@
|
||||
/* 文件职责:商品卡片/列表展示区与分页样式。 */
|
||||
.page-product-list {
|
||||
.product-list-section {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.product-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-card-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 3px 10px rgb(15 23 42 / 5%);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.product-card-item:hover {
|
||||
box-shadow: 0 10px 24px rgb(15 23 42 / 10%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.product-card-item.is-soldout {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.product-card-item.is-offshelf {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.product-card-status-ribbon {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.product-card-check {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.product-card-cover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 156px;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
.product-card-body {
|
||||
padding: 12px 14px 10px;
|
||||
}
|
||||
|
||||
.product-card-name {
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-card-subtitle {
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-card-spu {
|
||||
margin-bottom: 8px;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
'Liberation Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.product-card-price-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.price-now {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.price-old {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.product-card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.product-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.product-card-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: #fafbfc;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.product-card-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
letter-spacing: 6px;
|
||||
pointer-events: none;
|
||||
background: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
.product-list-table {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.product-list-header,
|
||||
.product-list-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
44px 66px minmax(130px, 1.2fr) 80px 110px 88px 84px minmax(90px, 1.1fr)
|
||||
74px 180px;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-list-header {
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.product-list-row {
|
||||
min-height: 72px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.product-list-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.product-list-row:hover {
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.cell {
|
||||
padding: 8px 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-cover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.info-cell .name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-cell .subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-cell .spu {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
'Liberation Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tags-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-link-btn {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.product-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.product-link-btn.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stock-ok {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.stock-low {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stock-out {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.product-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.product-status-pill.status-on-sale {
|
||||
color: #16a34a;
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.product-status-pill.status-off-shelf {
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.product-status-pill.status-sold-out {
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
77
apps/web-antd/src/views/product/list/styles/responsive.less
Normal file
77
apps/web-antd/src/views/product/list/styles/responsive.less
Normal file
@@ -0,0 +1,77 @@
|
||||
/* 文件职责:商品列表页面响应式样式。 */
|
||||
@media (max-width: 1200px) {
|
||||
.page-product-list {
|
||||
.product-card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.page-product-list {
|
||||
.product-page-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-category-sidebar-card {
|
||||
position: static;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-list-header,
|
||||
.product-list-row {
|
||||
grid-template-columns: 40px 54px minmax(120px, 1fr) 80px 88px 80px;
|
||||
}
|
||||
|
||||
.product-list-header .cell:nth-child(4),
|
||||
.product-list-header .cell:nth-child(7),
|
||||
.product-list-header .cell:nth-child(8),
|
||||
.product-list-header .cell:nth-child(9),
|
||||
.product-list-row .cell:nth-child(4),
|
||||
.product-list-row .cell:nth-child(7),
|
||||
.product-list-row .cell:nth-child(8),
|
||||
.product-list-row .cell:nth-child(9) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-product-list {
|
||||
.product-filter-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-filter-store-select,
|
||||
.product-filter-input,
|
||||
.product-filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-filter-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.product-view-switch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-view-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-action-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-action-left {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
apps/web-antd/src/views/product/list/styles/sidebar.less
Normal file
68
apps/web-antd/src/views/product/list/styles/sidebar.less
Normal file
@@ -0,0 +1,68 @@
|
||||
/* 文件职责:商品分类侧栏样式。 */
|
||||
.page-product-list {
|
||||
.product-category-sidebar-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.product-category-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-category-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.product-category-item:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.product-category-item.active {
|
||||
color: #1677ff;
|
||||
background: #f0f6ff;
|
||||
border-left-color: #1677ff;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.product-category-item.active .category-count {
|
||||
color: #1677ff;
|
||||
background: #dbeafe;
|
||||
}
|
||||
}
|
||||
54
apps/web-antd/src/views/product/list/styles/toolbar.less
Normal file
54
apps/web-antd/src/views/product/list/styles/toolbar.less
Normal file
@@ -0,0 +1,54 @@
|
||||
/* 文件职责:商品列表筛选条与视图切换样式。 */
|
||||
.page-product-list {
|
||||
.product-filter-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-filter-store-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.product-filter-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.product-filter-select {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.product-filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-view-switch {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-view-btn {
|
||||
min-width: 62px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.product-view-btn:hover {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.product-view-btn.active {
|
||||
color: #1677ff;
|
||||
background: #f0f6ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
}
|
||||
86
apps/web-antd/src/views/product/list/types.ts
Normal file
86
apps/web-antd/src/views/product/list/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 文件职责:商品列表页面类型定义。
|
||||
* 1. 维护筛选、分页、抽屉表单等页面状态类型。
|
||||
* 2. 约束组件通信与批量动作入参。
|
||||
*/
|
||||
import type {
|
||||
BatchProductActionType,
|
||||
ProductKind,
|
||||
ProductSoldoutMode,
|
||||
ProductStatus,
|
||||
} from '#/api/product';
|
||||
|
||||
/** 页面视图模式。 */
|
||||
export type ProductViewMode = 'card' | 'list';
|
||||
|
||||
/** 商品筛选条件。 */
|
||||
export interface ProductFilterState {
|
||||
categoryId: string;
|
||||
kind: '' | ProductKind;
|
||||
keyword: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: '' | ProductStatus;
|
||||
}
|
||||
|
||||
/** 侧栏分类项。 */
|
||||
export interface ProductCategorySidebarItem {
|
||||
id: string;
|
||||
name: string;
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
/** 商品编辑抽屉模式。 */
|
||||
export type ProductEditorDrawerMode = 'create' | 'edit';
|
||||
|
||||
/** 商品编辑表单。 */
|
||||
export interface ProductEditorFormState {
|
||||
categoryId: string;
|
||||
description: string;
|
||||
id: 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 ProductQuickEditFormState {
|
||||
id: string;
|
||||
isOnSale: boolean;
|
||||
originalPrice: null | number;
|
||||
price: number;
|
||||
stock: number;
|
||||
}
|
||||
|
||||
/** 沽清表单。 */
|
||||
export interface ProductSoldoutFormState {
|
||||
mode: ProductSoldoutMode;
|
||||
notifyManager: boolean;
|
||||
reason: string;
|
||||
recoverAt: string;
|
||||
remainStock: number;
|
||||
syncToPlatform: boolean;
|
||||
}
|
||||
|
||||
/** 列表分页变更参数。 */
|
||||
export interface ProductPaginationChangePayload {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 批量动作类型(页面层)。 */
|
||||
export type ProductBatchAction = BatchProductActionType;
|
||||
|
||||
/** 商品状态展示元信息。 */
|
||||
export interface ProductStatusMeta {
|
||||
badgeClass: string;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
Reference in New Issue
Block a user