feat(project): rebuild schedule supply module with split structure

This commit is contained in:
2026-02-21 11:52:09 +08:00
parent a53db5f0a4
commit 270c51de59
21 changed files with 2368 additions and 617 deletions

View File

@@ -1,559 +1,176 @@
<script setup lang="ts">
/**
* 文件职责:时段供应页面。
* 1. 管理供应时段模板
* 2. 管理时段模板与商品关联关系
* 文件职责:时段供应页面主视图
* 1. 还原原型的工具栏、统计条、规则卡片、时间轴与抽屉
* 2. 通过真实 TenantAPI 管理时段规则及关联商品
*/
import type { ProductSwitchStatus } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Divider,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { Button, Empty, Input, Select, Spin } from 'ant-design-vue';
import {
changeProductScheduleStatusApi,
deleteProductScheduleApi,
getProductScheduleListApi,
saveProductScheduleApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
import ScheduleEditorDrawer from './components/ScheduleEditorDrawer.vue';
import ScheduleProductPickerModal from './components/ScheduleProductPickerModal.vue';
import ScheduleRuleCard from './components/ScheduleRuleCard.vue';
import ScheduleTimelineCard from './components/ScheduleTimelineCard.vue';
import { useProductSchedulePage } from './composables/useProductSchedulePage';
type StatusFilter = '' | ProductSwitchStatus;
interface SlotForm {
id: string;
weekDays: number[];
startTime: string;
endTime: string;
}
interface ScheduleRow {
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
slots: SlotForm[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<ScheduleRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingScheduleId = ref('');
const form = reactive({
name: '',
description: '',
status: 'enabled' as ProductSwitchStatus,
sort: 1,
productIds: [] as string[],
slots: [] as SlotForm[],
});
const weekDayOptions = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 7 },
];
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
const drawerTitle = computed(() =>
editingScheduleId.value ? '编辑时段' : '新增时段',
);
/** 控制抽屉开关。 */
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载时段列表。 */
async function loadSchedules() {
if (!selectedStoreId.value) {
rows.value = [];
return;
}
isLoading.value = true;
try {
const result = await getProductScheduleListApi({
storeId: selectedStoreId.value,
keyword: keyword.value.trim() || undefined,
status: (statusFilter.value || undefined) as
| ProductSwitchStatus
| undefined,
});
rows.value = result as ScheduleRow[];
} catch (error) {
console.error(error);
rows.value = [];
message.error('加载时段失败');
} finally {
isLoading.value = false;
}
}
/** 加载商品选择器。 */
async function loadPickerOptions() {
if (!selectedStoreId.value) {
pickerOptions.value = [];
return;
}
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
});
pickerOptions.value = list.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
} catch (error) {
console.error(error);
pickerOptions.value = [];
message.error('加载商品失败');
}
}
/** 重置表单。 */
function resetForm() {
editingScheduleId.value = '';
form.name = '';
form.description = '';
form.status = 'enabled';
form.sort = rows.value.length + 1;
form.productIds = [];
form.slots = [
{
id: '',
weekDays: [1, 2, 3, 4, 5, 6, 7],
startTime: '09:00',
endTime: '21:00',
},
];
}
/** 打开新增抽屉。 */
async function openCreateDrawer() {
resetForm();
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 打开编辑抽屉。 */
async function openEditDrawer(row: ScheduleRow) {
editingScheduleId.value = row.id;
form.name = row.name;
form.description = row.description;
form.status = row.status;
form.sort = row.sort;
form.productIds = [...row.productIds];
form.slots = row.slots.map((slot) => ({
...slot,
weekDays: [...slot.weekDays],
}));
if (form.slots.length === 0) {
form.slots = [
{
id: '',
weekDays: [1, 2, 3, 4, 5, 6, 7],
startTime: '09:00',
endTime: '21:00',
},
];
}
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 新增时段行。 */
function addSlot() {
form.slots.push({
id: '',
weekDays: [1, 2, 3, 4, 5, 6, 7],
startTime: '09:00',
endTime: '21:00',
});
}
/** 删除时段行。 */
function removeSlot(index: number) {
if (form.slots.length <= 1) {
message.warning('至少保留一个时段');
return;
}
form.slots.splice(index, 1);
}
/** 保存时段。 */
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入时段名称');
return;
}
if (
form.slots.some(
(slot) =>
slot.weekDays.length === 0 ||
!slot.startTime.trim() ||
!slot.endTime.trim(),
)
) {
message.warning('请完整填写时段信息');
return;
}
isDrawerSubmitting.value = true;
try {
await saveProductScheduleApi({
storeId: selectedStoreId.value,
id: editingScheduleId.value || undefined,
name: form.name.trim(),
description: form.description.trim(),
status: form.status,
sort: form.sort,
productIds: [...form.productIds],
slots: form.slots.map((slot) => ({
id: slot.id || undefined,
weekDays: [...slot.weekDays],
startTime: slot.startTime.trim(),
endTime: slot.endTime.trim(),
})),
});
message.success(editingScheduleId.value ? '时段已更新' : '时段已创建');
isDrawerOpen.value = false;
await loadSchedules();
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
/** 删除时段模板。 */
function removeSchedule(row: ScheduleRow) {
if (!selectedStoreId.value) return;
Modal.confirm({
title: `确认删除时段「${row.name}」吗?`,
async onOk() {
await deleteProductScheduleApi({
storeId: selectedStoreId.value,
scheduleId: row.id,
});
message.success('时段已删除');
await loadSchedules();
},
});
}
/** 切换时段状态。 */
async function toggleScheduleStatus(row: ScheduleRow, checked: boolean) {
if (!selectedStoreId.value) return;
await changeProductScheduleStatusApi({
storeId: selectedStoreId.value,
scheduleId: row.id,
status: checked ? 'enabled' : 'disabled',
});
row.status = checked ? 'enabled' : 'disabled';
message.success('状态已更新');
}
/** 格式化星期展示文案。 */
function formatWeekDays(weekDays: number[]) {
const map: Record<number, string> = {
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
7: '日',
};
return [...weekDays]
.toSorted((a, b) => a - b)
.map((day) => map[day] || day)
.join('/');
}
/** 重置筛选。 */
function resetFilters() {
keyword.value = '';
statusFilter.value = '';
loadSchedules();
}
watch(selectedStoreId, loadSchedules);
onMounted(loadStores);
const {
coveredProductCount,
drawerSubmitText,
drawerTitle,
enabledCount,
filteredRows,
form,
getRuleColor,
getScheduleProductNames,
isDrawerOpen,
isDrawerSubmitting,
isLoading,
isPickerLoading,
isPickerOpen,
isStoreLoading,
keyword,
loadPickerProducts,
openCreateDrawer,
openEditDrawer,
openProductPicker,
pickerKeyword,
pickerProducts,
pickerSelectedIds,
removeFormProduct,
removeRule,
ruleCount,
selectAllDays,
selectedProducts,
selectedStoreId,
selectWeekdays,
selectWeekend,
setDrawerOpen,
setFormEndTime,
setFormName,
setFormStartTime,
setKeyword,
setPickerKeyword,
setPickerOpen,
setSelectedStoreId,
storeOptions,
submitDrawer,
submitPicker,
timelineRows,
toggleFormStatus,
togglePickerProduct,
toggleRuleStatus,
toggleWeekDay,
} = useProductSchedulePage();
</script>
<template>
<Page title="时段供应" content-class="space-y-4 page-product-schedule">
<Card :bordered="false">
<Space wrap>
<Page title="时段供应" content-class="page-product-schedule">
<div class="ptm-page">
<div class="ptm-toolbar">
<Select
v-model:value="selectedStoreId"
class="ptm-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<Input
v-model:value="keyword"
style="width: 220px"
placeholder="搜索时段名称"
class="ptm-search"
:value="keyword"
placeholder="搜索规则名称"
@update:value="(value) => setKeyword(String(value ?? ''))"
/>
<Select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 140px"
/>
<Button type="primary" @click="loadSchedules">查询</Button>
<Button @click="resetFilters">重置</Button>
<Button type="primary" @click="openCreateDrawer">新增时段</Button>
</Space>
</Card>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<span class="ptm-spacer"></span>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column
title="时段名称"
data-index="name"
key="name"
:width="180"
/>
<Table.Column title="供应时段" key="slots">
<template #default="{ record }">
<Space direction="vertical" size="small">
<Tag
v-for="slot in record.slots"
:key="slot.id || `${slot.startTime}-${slot.endTime}`"
>
{{ formatWeekDays(slot.weekDays) }} {{ slot.startTime }}-{{
slot.endTime
}}
</Tag>
</Space>
</template>
</Table.Column>
<Table.Column
title="关联商品数"
data-index="productCount"
key="productCount"
:width="100"
/>
<Table.Column title="状态" key="status" :width="90">
<template #default="{ record }">
<Switch
:checked="record.status === 'enabled'"
size="small"
@change="
(checked) => toggleScheduleStatus(record, checked === true)
"
/>
</template>
</Table.Column>
<Table.Column
title="更新时间"
data-index="updatedAt"
key="updatedAt"
:width="170"
/>
<Table.Column title="操作" key="action" :width="170">
<template #default="{ record }">
<Space size="small">
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
<Button danger size="small" @click="removeSchedule(record)">
删除
</Button>
</Space>
</template>
</Table.Column>
</Table>
</Card>
<Button type="primary" @click="openCreateDrawer">+ 添加时段规则</Button>
</div>
<Drawer
<div v-if="selectedStoreId" class="ptm-stats">
<span>
时段规则 <strong>{{ ruleCount }}</strong>
</span>
<span>
启用 <strong>{{ enabledCount }}</strong>
</span>
<span>
覆盖商品 <strong>{{ coveredProductCount }}</strong>
</span>
</div>
<div v-if="selectedStoreId" class="ptm-banner">
<span class="icon"><IconifyIcon icon="lucide:info" /></span>
设置商品在特定时段内供应未被任何规则覆盖的商品默认全天供应
</div>
<div v-if="!selectedStoreId" class="ptm-empty">
暂无门店请先创建门店
</div>
<Spin v-else :spinning="isLoading">
<template v-if="filteredRows.length > 0">
<ScheduleRuleCard
v-for="item in filteredRows"
:key="item.id"
:item="item"
:color="getRuleColor(item.id)"
:product-names="getScheduleProductNames(item)"
@edit="openEditDrawer"
@toggle-status="toggleRuleStatus"
@remove="removeRule"
/>
</template>
<div v-else class="ptm-empty">
<Empty description="暂无时段规则" />
</div>
<ScheduleTimelineCard :rows="timelineRows" />
</Spin>
</div>
<ScheduleEditorDrawer
:open="isDrawerOpen"
:title="drawerTitle"
width="660"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="时段名称" required>
<Input v-model:value="form.name" :maxlength="30" show-count />
</Form.Item>
<Form.Item label="时段描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
:submit-text="drawerSubmitText"
:submitting="isDrawerSubmitting"
:form="form"
:selected-products="selectedProducts"
@close="setDrawerOpen(false)"
@set-name="setFormName"
@set-start-time="setFormStartTime"
@set-end-time="setFormEndTime"
@toggle-week-day="toggleWeekDay"
@select-all-days="selectAllDays"
@select-weekdays="selectWeekdays"
@select-weekend="selectWeekend"
@open-product-picker="openProductPicker"
@remove-product="removeFormProduct"
@toggle-status="toggleFormStatus"
@submit="submitDrawer"
/>
<Divider orientation="left">时段配置</Divider>
<div v-for="(slot, index) in form.slots" :key="index" class="slot-row">
<Select
v-model:value="slot.weekDays"
mode="multiple"
:options="weekDayOptions"
style="width: 220px"
placeholder="选择星期"
/>
<Input
v-model:value="slot.startTime"
style="width: 110px"
placeholder="开始 HH:mm"
/>
<Input
v-model:value="slot.endTime"
style="width: 110px"
placeholder="结束 HH:mm"
/>
<Button danger @click="removeSlot(index)">删除</Button>
</div>
<Button @click="addSlot">新增时段</Button>
<Divider />
<Space style="display: flex; width: 100%">
<Form.Item label="排序" style="flex: 1">
<InputNumber
v-model:value="form.sort"
:min="1"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="状态" style="flex: 1">
<Select
v-model:value="form.status"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
</Form.Item>
</Space>
</Form>
<template #footer>
<Space>
<Button @click="setDrawerOpen(false)">取消</Button>
<Button
type="primary"
:loading="isDrawerSubmitting"
@click="submitDrawer"
>
保存
</Button>
</Space>
</template>
</Drawer>
<ScheduleProductPickerModal
:open="isPickerOpen"
title="关联商品"
:loading="isPickerLoading"
:submitting="false"
:keyword="pickerKeyword"
:products="pickerProducts"
:selected-ids="pickerSelectedIds"
@close="setPickerOpen(false)"
@set-keyword="setPickerKeyword"
@search="loadPickerProducts"
@toggle-product="togglePickerProduct"
@submit="submitPicker"
/>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:时段供应页面样式。 */
.page-product-schedule {
.slot-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
<style lang="less">
@import './styles/index.less';
</style>