feat(project): align store delivery pages with live APIs and geocode status

This commit is contained in:
2026-02-19 17:15:19 +08:00
parent 435626ca55
commit 3b96b3f92d
62 changed files with 6813 additions and 250 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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