feat(project): rebuild schedule supply module with split structure
This commit is contained in:
@@ -424,25 +424,17 @@ export interface ChangeProductLabelStatusDto {
|
|||||||
storeId: string;
|
storeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时段条目。 */
|
|
||||||
export interface ProductScheduleSlotDto {
|
|
||||||
endTime: string;
|
|
||||||
id: string;
|
|
||||||
startTime: string;
|
|
||||||
weekDays: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 时段模板。 */
|
/** 时段模板。 */
|
||||||
export interface ProductScheduleDto {
|
export interface ProductScheduleDto {
|
||||||
description: string;
|
endTime: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
productCount: number;
|
productCount: number;
|
||||||
productIds: string[];
|
productIds: string[];
|
||||||
slots: ProductScheduleSlotDto[];
|
startTime: string;
|
||||||
sort: number;
|
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
weekDays: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时段查询参数。 */
|
/** 时段查询参数。 */
|
||||||
@@ -454,19 +446,14 @@ export interface ProductScheduleQuery {
|
|||||||
|
|
||||||
/** 保存时段参数。 */
|
/** 保存时段参数。 */
|
||||||
export interface SaveProductScheduleDto {
|
export interface SaveProductScheduleDto {
|
||||||
description: string;
|
endTime: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
productIds: string[];
|
productIds: string[];
|
||||||
slots: Array<{
|
startTime: string;
|
||||||
endTime: string;
|
|
||||||
id?: string;
|
|
||||||
startTime: string;
|
|
||||||
weekDays: number[];
|
|
||||||
}>;
|
|
||||||
sort: number;
|
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
|
weekDays: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除时段参数。 */
|
/** 删除时段参数。 */
|
||||||
|
|||||||
@@ -87,22 +87,15 @@ interface LabelRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleSlotRecord {
|
|
||||||
endTime: string;
|
|
||||||
id: string;
|
|
||||||
startTime: string;
|
|
||||||
weekDays: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleRecord {
|
interface ScheduleRecord {
|
||||||
description: string;
|
endTime: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
productIds: string[];
|
productIds: string[];
|
||||||
slots: ScheduleSlotRecord[];
|
startTime: string;
|
||||||
sort: number;
|
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
weekDays: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductExtensionStoreState {
|
interface ProductExtensionStoreState {
|
||||||
@@ -403,18 +396,13 @@ function toScheduleItem(
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: item.description,
|
startTime: item.startTime,
|
||||||
sort: item.sort,
|
endTime: item.endTime,
|
||||||
|
weekDays: [...item.weekDays],
|
||||||
status: item.status,
|
status: item.status,
|
||||||
productIds,
|
productIds,
|
||||||
productCount: productIds.length,
|
productCount: productIds.length,
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
slots: item.slots.map((slot) => ({
|
|
||||||
id: slot.id,
|
|
||||||
weekDays: [...slot.weekDays],
|
|
||||||
startTime: slot.startTime,
|
|
||||||
endTime: slot.endTime,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,19 +540,12 @@ function createDefaultState(storeId: string): ProductExtensionStoreState {
|
|||||||
{
|
{
|
||||||
id: createId('schedule', storeId),
|
id: createId('schedule', storeId),
|
||||||
name: '午市供应',
|
name: '午市供应',
|
||||||
description: '适用于午餐时段',
|
startTime: '10:30',
|
||||||
sort: 1,
|
endTime: '14:00',
|
||||||
|
weekDays: [1, 2, 3, 4, 5, 6, 7],
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
productIds: products.slice(0, 8).map((item) => item.id),
|
productIds: products.slice(0, 8).map((item) => item.id),
|
||||||
updatedAt: toDateTimeText(new Date()),
|
updatedAt: toDateTimeText(new Date()),
|
||||||
slots: [
|
|
||||||
{
|
|
||||||
id: createId('slot', storeId),
|
|
||||||
weekDays: [1, 2, 3, 4, 5, 6, 7],
|
|
||||||
startTime: '10:30',
|
|
||||||
endTime: '14:00',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1300,14 +1281,11 @@ Mock.mock(
|
|||||||
const state = ensureStoreState(storeId);
|
const state = ensureStoreState(storeId);
|
||||||
|
|
||||||
const list = state.schedules
|
const list = state.schedules
|
||||||
.toSorted((a, b) => a.sort - b.sort)
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (status && item.status !== status) return false;
|
if (status && item.status !== status) return false;
|
||||||
if (!keyword) return true;
|
if (!keyword) return true;
|
||||||
return (
|
return item.name.toLowerCase().includes(keyword);
|
||||||
item.name.toLowerCase().includes(keyword) ||
|
|
||||||
item.description.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.map((item) => toScheduleItem(state, item));
|
.map((item) => toScheduleItem(state, item));
|
||||||
|
|
||||||
@@ -1329,29 +1307,14 @@ Mock.mock(
|
|||||||
|
|
||||||
const state = ensureStoreState(storeId);
|
const state = ensureStoreState(storeId);
|
||||||
const existingIndex = state.schedules.findIndex((item) => item.id === id);
|
const existingIndex = state.schedules.findIndex((item) => item.id === id);
|
||||||
const fallbackSort =
|
|
||||||
state.schedules.reduce((max, item) => Math.max(max, item.sort), 0) + 1;
|
|
||||||
const productIds = normalizeIdList(body.productIds).filter((productId) =>
|
const productIds = normalizeIdList(body.productIds).filter((productId) =>
|
||||||
state.products.some((item) => item.id === productId),
|
state.products.some((item) => item.id === productId),
|
||||||
);
|
);
|
||||||
|
const startTime = normalizeTimeText(body.startTime, '09:00');
|
||||||
const slotListRaw = Array.isArray(body.slots) ? body.slots : [];
|
const endTime = normalizeTimeText(body.endTime, '21:00');
|
||||||
const slots: ScheduleSlotRecord[] = slotListRaw
|
const weekDays = normalizeWeekDays(body.weekDays);
|
||||||
.map((slot) => {
|
if (productIds.length === 0) {
|
||||||
if (!slot || typeof slot !== 'object') return null;
|
return { code: 400, data: null, message: '请至少关联一个商品' };
|
||||||
const current = slot as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: normalizeText(current.id, createId('slot', storeId)),
|
|
||||||
weekDays: normalizeWeekDays(current.weekDays),
|
|
||||||
startTime: normalizeTimeText(current.startTime, '09:00'),
|
|
||||||
endTime: normalizeTimeText(current.endTime, '21:00'),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is ScheduleSlotRecord => item !== null)
|
|
||||||
.toSorted((a, b) => a.startTime.localeCompare(b.startTime));
|
|
||||||
|
|
||||||
if (slots.length === 0) {
|
|
||||||
return { code: 400, data: null, message: '至少保留一个时段' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const next: ScheduleRecord =
|
const next: ScheduleRecord =
|
||||||
@@ -1359,31 +1322,24 @@ Mock.mock(
|
|||||||
? {
|
? {
|
||||||
id: createId('schedule', storeId),
|
id: createId('schedule', storeId),
|
||||||
name,
|
name,
|
||||||
description: normalizeText(body.description),
|
startTime,
|
||||||
sort: normalizeInt(body.sort, fallbackSort, 1),
|
endTime,
|
||||||
|
weekDays,
|
||||||
status: normalizeSwitchStatus(body.status, 'enabled'),
|
status: normalizeSwitchStatus(body.status, 'enabled'),
|
||||||
productIds,
|
productIds,
|
||||||
slots,
|
|
||||||
updatedAt: toDateTimeText(new Date()),
|
updatedAt: toDateTimeText(new Date()),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
...state.schedules[existingIndex],
|
...state.schedules[existingIndex],
|
||||||
name,
|
name,
|
||||||
description: normalizeText(
|
startTime,
|
||||||
body.description,
|
endTime,
|
||||||
state.schedules[existingIndex].description,
|
weekDays,
|
||||||
),
|
|
||||||
sort: normalizeInt(
|
|
||||||
body.sort,
|
|
||||||
state.schedules[existingIndex].sort,
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
status: normalizeSwitchStatus(
|
status: normalizeSwitchStatus(
|
||||||
body.status,
|
body.status,
|
||||||
state.schedules[existingIndex].status,
|
state.schedules[existingIndex].status,
|
||||||
),
|
),
|
||||||
productIds,
|
productIds,
|
||||||
slots,
|
|
||||||
updatedAt: toDateTimeText(new Date()),
|
updatedAt: toDateTimeText(new Date()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:时段规则编辑抽屉。
|
||||||
|
*/
|
||||||
|
import type { ScheduleEditorForm, ScheduleProductChip } from '../types';
|
||||||
|
|
||||||
|
import { onBeforeUnmount, watch } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { WEEK_DAY_OPTIONS } from '../composables/schedule-page/constants';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: ScheduleEditorForm;
|
||||||
|
open: boolean;
|
||||||
|
selectedProducts: ScheduleProductChip[];
|
||||||
|
submitText: string;
|
||||||
|
submitting: boolean;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
openProductPicker: [];
|
||||||
|
removeProduct: [productId: string];
|
||||||
|
selectAllDays: [];
|
||||||
|
selectWeekdays: [];
|
||||||
|
selectWeekend: [];
|
||||||
|
setEndTime: [value: string];
|
||||||
|
setName: [value: string];
|
||||||
|
setStartTime: [value: string];
|
||||||
|
submit: [];
|
||||||
|
toggleStatus: [];
|
||||||
|
toggleWeekDay: [day: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (props.submitting) return;
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekDayActive(day: number) {
|
||||||
|
return props.form.weekDays.includes(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBodyLock(locked: boolean) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
document.body.style.overflow = locked ? 'hidden' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
setBodyLock(open);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
setBodyLock(false);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="g-drawer-mask" :class="{ open }" @click="closeDrawer"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="g-drawer ptm-editor-drawer"
|
||||||
|
:class="{ open }"
|
||||||
|
style="width: 520px"
|
||||||
|
>
|
||||||
|
<div class="g-drawer-hd">
|
||||||
|
<span class="g-drawer-title">{{ title }}</span>
|
||||||
|
<button type="button" class="g-drawer-close" @click="closeDrawer">
|
||||||
|
<IconifyIcon icon="lucide:x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-drawer-bd">
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">规则名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="g-input"
|
||||||
|
:value="form.name"
|
||||||
|
maxlength="30"
|
||||||
|
placeholder="请输入规则名称,如:早餐供应、午市套餐"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit('setName', (event.target as HTMLInputElement).value)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">时间范围</label>
|
||||||
|
<div class="ptm-fg-row">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="g-input"
|
||||||
|
:value="form.startTime"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit('setStartTime', (event.target as HTMLInputElement).value)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="ptm-fg-sep">~</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="g-input"
|
||||||
|
:value="form.endTime"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit('setEndTime', (event.target as HTMLInputElement).value)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="g-hint">
|
||||||
|
结束时间小于开始时间时视为跨天(如 21:00 ~ 02:00)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">适用星期</label>
|
||||||
|
<div class="ptm-day-sel">
|
||||||
|
<button
|
||||||
|
v-for="day in WEEK_DAY_OPTIONS"
|
||||||
|
:key="day.value"
|
||||||
|
type="button"
|
||||||
|
class="ptm-day"
|
||||||
|
:class="{ active: isWeekDayActive(day.value) }"
|
||||||
|
@click="emit('toggleWeekDay', day.value)"
|
||||||
|
>
|
||||||
|
{{ day.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ptm-day-quick">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-btn g-btn-sm"
|
||||||
|
@click="emit('selectAllDays')"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-btn g-btn-sm"
|
||||||
|
@click="emit('selectWeekdays')"
|
||||||
|
>
|
||||||
|
工作日
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-btn g-btn-sm"
|
||||||
|
@click="emit('selectWeekend')"
|
||||||
|
>
|
||||||
|
周末
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">关联商品</label>
|
||||||
|
<div class="ptm-prod-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="g-input"
|
||||||
|
readonly
|
||||||
|
placeholder="搜索并选择要关联的商品…"
|
||||||
|
@click="emit('openProductPicker')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ptm-prod-selected">
|
||||||
|
<span
|
||||||
|
v-for="product in selectedProducts"
|
||||||
|
:key="product.id"
|
||||||
|
class="ptm-prod-chip"
|
||||||
|
>
|
||||||
|
{{ product.name }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ptm-prod-chip-x"
|
||||||
|
@click="emit('removeProduct', product.id)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:x" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span v-if="selectedProducts.length === 0" class="ptm-prod-empty">
|
||||||
|
暂未关联商品
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label">启用状态</label>
|
||||||
|
<div class="ptm-toggle-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-toggle"
|
||||||
|
:class="{ on: form.status === 'enabled' }"
|
||||||
|
@click="emit('toggleStatus')"
|
||||||
|
></button>
|
||||||
|
<span class="g-toggle-label">{{
|
||||||
|
form.status === 'enabled' ? '启用' : '停用'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-drawer-ft">
|
||||||
|
<button type="button" class="g-btn" @click="closeDrawer">取消</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-btn g-btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ submitText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:时段规则关联商品弹窗。
|
||||||
|
*/
|
||||||
|
import type { ProductPickerItemDto } from '#/api/product';
|
||||||
|
|
||||||
|
import { Input, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
keyword: string;
|
||||||
|
loading: boolean;
|
||||||
|
open: boolean;
|
||||||
|
products: ProductPickerItemDto[];
|
||||||
|
selectedIds: string[];
|
||||||
|
submitting: boolean;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
search: [];
|
||||||
|
setKeyword: [value: string];
|
||||||
|
submit: [];
|
||||||
|
toggleProduct: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function setKeyword(value: string) {
|
||||||
|
emit('setKeyword', value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:open="open"
|
||||||
|
:title="title"
|
||||||
|
ok-text="确认选择"
|
||||||
|
cancel-text="取消"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
@ok="emit('submit')"
|
||||||
|
@cancel="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="ptm-picker-search">
|
||||||
|
<Input
|
||||||
|
:value="keyword"
|
||||||
|
placeholder="搜索商品名称/SPU"
|
||||||
|
allow-clear
|
||||||
|
@update:value="setKeyword"
|
||||||
|
@press-enter="emit('search')"
|
||||||
|
/>
|
||||||
|
<button type="button" class="g-btn g-btn-sm" @click="emit('search')">
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ptm-picker-list">
|
||||||
|
<div v-if="loading" class="ptm-picker-empty">加载中...</div>
|
||||||
|
<div v-else-if="products.length === 0" class="ptm-picker-empty">
|
||||||
|
暂无可选商品
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
v-for="item in products"
|
||||||
|
v-else
|
||||||
|
:key="`picker-${item.id}`"
|
||||||
|
class="ptm-picker-item"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="selectedIds.includes(item.id)"
|
||||||
|
type="checkbox"
|
||||||
|
@change="emit('toggleProduct', item.id)"
|
||||||
|
/>
|
||||||
|
<span class="name">{{ item.name }}</span>
|
||||||
|
<span class="spu">{{ item.spuCode }}</span>
|
||||||
|
<span class="price">¥{{ item.price.toFixed(2) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:时段规则卡片。
|
||||||
|
*/
|
||||||
|
import type { ProductScheduleCardViewModel } from '../types';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { WEEK_DAY_OPTIONS } from '../composables/schedule-page/constants';
|
||||||
|
import {
|
||||||
|
buildTimeSegments,
|
||||||
|
formatTimeRangeText,
|
||||||
|
} from '../composables/schedule-page/timeline-utils';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
color: string;
|
||||||
|
item: ProductScheduleCardViewModel;
|
||||||
|
productNames: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [item: ProductScheduleCardViewModel];
|
||||||
|
remove: [item: ProductScheduleCardViewModel];
|
||||||
|
toggleStatus: [item: ProductScheduleCardViewModel];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const timeText = computed(() =>
|
||||||
|
formatTimeRangeText(props.item.startTime, props.item.endTime),
|
||||||
|
);
|
||||||
|
|
||||||
|
const segments = computed(() =>
|
||||||
|
buildTimeSegments(props.item.startTime, props.item.endTime),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleProducts = computed(() => props.productNames.slice(0, 3));
|
||||||
|
|
||||||
|
const moreProductCount = computed(() =>
|
||||||
|
Math.max(0, props.productNames.length - visibleProducts.value.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
const noProducts = computed(() => props.productNames.length === 0);
|
||||||
|
|
||||||
|
function isWeekDayActive(day: number) {
|
||||||
|
return props.item.weekDays.includes(day);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ptm-card" :class="{ disabled: item.status === 'disabled' }">
|
||||||
|
<div class="ptm-card-hd">
|
||||||
|
<span class="ptm-card-name">{{ item.name }}</span>
|
||||||
|
<span
|
||||||
|
class="g-tag"
|
||||||
|
:class="item.status === 'enabled' ? 'ptm-tag-on' : 'ptm-tag-off'"
|
||||||
|
>
|
||||||
|
{{ item.status === 'enabled' ? '启用' : '停用' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ptm-time-row">
|
||||||
|
<span class="ptm-time-text">{{ timeText }}</span>
|
||||||
|
<div class="ptm-timebar-wrap">
|
||||||
|
<div
|
||||||
|
v-for="(segment, index) in segments"
|
||||||
|
:key="`${item.id}-segment-${index}`"
|
||||||
|
class="ptm-timebar-fill"
|
||||||
|
:style="{
|
||||||
|
left: `${segment.left}%`,
|
||||||
|
width: `${segment.width}%`,
|
||||||
|
background: color,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ptm-days">
|
||||||
|
<span
|
||||||
|
v-for="day in WEEK_DAY_OPTIONS"
|
||||||
|
:key="day.value"
|
||||||
|
class="ptm-day"
|
||||||
|
:class="{ active: isWeekDayActive(day.value) }"
|
||||||
|
>
|
||||||
|
{{ day.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ptm-products">
|
||||||
|
<span
|
||||||
|
v-for="(name, index) in visibleProducts"
|
||||||
|
:key="`${item.id}-product-${index}`"
|
||||||
|
class="ptm-prod-pill"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="moreProductCount > 0" class="ptm-prod-pill ptm-prod-more">
|
||||||
|
+{{ moreProductCount }}更多
|
||||||
|
</span>
|
||||||
|
<span v-if="noProducts" class="ptm-prod-pill">未关联商品</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ptm-card-ft">
|
||||||
|
<button type="button" class="g-action" @click="emit('edit', item)">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-action"
|
||||||
|
@click="emit('toggleStatus', item)"
|
||||||
|
>
|
||||||
|
{{ item.status === 'enabled' ? '停用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-action g-action-danger"
|
||||||
|
@click="emit('remove', item)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:今日供应时间轴卡片。
|
||||||
|
*/
|
||||||
|
import type { ScheduleTimelineRow } from '../types';
|
||||||
|
|
||||||
|
import { TIMELINE_AXIS_TICKS } from '../composables/schedule-page/constants';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
rows: ScheduleTimelineRow[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ptm-tl-card">
|
||||||
|
<div class="ptm-tl-title">今日供应时间轴</div>
|
||||||
|
|
||||||
|
<div class="ptm-tl-axis" style="margin-right: 10px; margin-left: 90px">
|
||||||
|
<span
|
||||||
|
v-for="tick in TIMELINE_AXIS_TICKS"
|
||||||
|
:key="`tick-${tick}`"
|
||||||
|
:style="{ left: `${(tick / 24) * 100}%` }"
|
||||||
|
>
|
||||||
|
{{ tick }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rows.length > 0" class="ptm-tl-rows">
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.id"
|
||||||
|
class="ptm-tl-row"
|
||||||
|
:class="{ disabled: row.status === 'disabled' }"
|
||||||
|
>
|
||||||
|
<span class="ptm-tl-label">{{ row.name }}</span>
|
||||||
|
<div class="ptm-tl-track">
|
||||||
|
<div
|
||||||
|
v-for="(bar, index) in row.bars"
|
||||||
|
:key="`${row.id}-bar-${index}`"
|
||||||
|
class="ptm-tl-bar"
|
||||||
|
:style="{
|
||||||
|
left: `${bar.left}%`,
|
||||||
|
width: `${bar.width}%`,
|
||||||
|
background: row.color,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ bar.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="ptm-tl-empty">今日暂无供应规则</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ScheduleEditorForm } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:时段供应页面常量。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 星期选项。 */
|
||||||
|
export const WEEK_DAY_OPTIONS = [
|
||||||
|
{ label: '周一', value: 1 },
|
||||||
|
{ label: '周二', value: 2 },
|
||||||
|
{ label: '周三', value: 3 },
|
||||||
|
{ label: '周四', value: 4 },
|
||||||
|
{ label: '周五', value: 5 },
|
||||||
|
{ label: '周六', value: 6 },
|
||||||
|
{ label: '周日', value: 7 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 全周。 */
|
||||||
|
export const WEEK_DAYS_ALL = [1, 2, 3, 4, 5, 6, 7] as const;
|
||||||
|
|
||||||
|
/** 工作日。 */
|
||||||
|
export const WEEK_DAYS_WEEKDAY = [1, 2, 3, 4, 5] as const;
|
||||||
|
|
||||||
|
/** 周末。 */
|
||||||
|
export const WEEK_DAYS_WEEKEND = [6, 7] as const;
|
||||||
|
|
||||||
|
/** 时间轴刻度。 */
|
||||||
|
export const TIMELINE_AXIS_TICKS = [0, 3, 6, 9, 12, 15, 18, 21, 24] as const;
|
||||||
|
|
||||||
|
/** 规则颜色池。 */
|
||||||
|
export const SCHEDULE_COLOR_PALETTE = [
|
||||||
|
'#1890ff',
|
||||||
|
'#52c41a',
|
||||||
|
'#722ed1',
|
||||||
|
'#fa8c16',
|
||||||
|
'#13c2c2',
|
||||||
|
'#eb2f96',
|
||||||
|
'#2f54eb',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 创建默认编辑表单。 */
|
||||||
|
export function createDefaultScheduleForm(): ScheduleEditorForm {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
startTime: '06:00',
|
||||||
|
endTime: '10:00',
|
||||||
|
weekDays: [...WEEK_DAYS_ALL],
|
||||||
|
productIds: [],
|
||||||
|
status: 'enabled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化星期列表。 */
|
||||||
|
export function normalizeWeekDays(weekDays: number[]) {
|
||||||
|
const next = weekDays
|
||||||
|
.map(Number)
|
||||||
|
.filter((item) => Number.isInteger(item) && item >= 1 && item <= 7)
|
||||||
|
.toSorted((a, b) => a - b);
|
||||||
|
return [...new Set(next)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按规则 ID 生成稳定颜色。 */
|
||||||
|
export function resolveScheduleColor(scheduleId: string) {
|
||||||
|
if (!scheduleId) {
|
||||||
|
return SCHEDULE_COLOR_PALETTE[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = 0;
|
||||||
|
for (const char of scheduleId) {
|
||||||
|
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SCHEDULE_COLOR_PALETTE[hash % SCHEDULE_COLOR_PALETTE.length];
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { ProductPickerItemDto, ProductScheduleDto } from '#/api/product';
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
import type { ScheduleProductLookup } from '#/views/product/schedule/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:时段供应数据加载动作。
|
||||||
|
*/
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getProductScheduleListApi,
|
||||||
|
searchProductPickerApi,
|
||||||
|
} from '#/api/product';
|
||||||
|
import { getStoreListApi } from '#/api/store';
|
||||||
|
|
||||||
|
interface CreateDataActionsOptions {
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isPickerLoading: Ref<boolean>;
|
||||||
|
isStoreLoading: Ref<boolean>;
|
||||||
|
pickerKeyword: Ref<string>;
|
||||||
|
pickerProducts: Ref<ProductPickerItemDto[]>;
|
||||||
|
productLookup: Ref<ScheduleProductLookup>;
|
||||||
|
rows: Ref<ProductScheduleDto[]>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
stores: Ref<StoreListItemDto[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDataActions(options: CreateDataActionsOptions) {
|
||||||
|
function mergeProductLookup(list: ProductPickerItemDto[]) {
|
||||||
|
if (list.length === 0) return;
|
||||||
|
|
||||||
|
const next: ScheduleProductLookup = {
|
||||||
|
...options.productLookup.value,
|
||||||
|
};
|
||||||
|
for (const item of list) {
|
||||||
|
next[item.id] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.productLookup.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
options.isStoreLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getStoreListApi({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
options.stores.value = result.items ?? [];
|
||||||
|
if (options.stores.value.length === 0) {
|
||||||
|
options.selectedStoreId.value = '';
|
||||||
|
options.rows.value = [];
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
options.productLookup.value = {};
|
||||||
|
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);
|
||||||
|
message.error('加载门店失败');
|
||||||
|
} finally {
|
||||||
|
options.isStoreLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchedules() {
|
||||||
|
if (!options.selectedStoreId.value) {
|
||||||
|
options.rows.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const list = await getProductScheduleListApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
});
|
||||||
|
options.rows.value = list.toSorted((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
options.rows.value = [];
|
||||||
|
message.error('加载时段规则失败');
|
||||||
|
} finally {
|
||||||
|
options.isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPickerProducts() {
|
||||||
|
if (!options.selectedStoreId.value) {
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isPickerLoading.value = true;
|
||||||
|
try {
|
||||||
|
const list = await searchProductPickerApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
keyword: options.pickerKeyword.value.trim() || undefined,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
options.pickerProducts.value = list;
|
||||||
|
mergeProductLookup(list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
message.error('加载商品失败');
|
||||||
|
} finally {
|
||||||
|
options.isPickerLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadProductLookup() {
|
||||||
|
if (!options.selectedStoreId.value) {
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
options.productLookup.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = await searchProductPickerApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
options.pickerProducts.value = list;
|
||||||
|
mergeProductLookup(list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadPickerProducts,
|
||||||
|
loadSchedules,
|
||||||
|
loadStores,
|
||||||
|
preloadProductLookup,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProductScheduleCardViewModel,
|
||||||
|
ScheduleEditorForm,
|
||||||
|
} from '#/views/product/schedule/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:时段规则编辑抽屉动作。
|
||||||
|
*/
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { saveProductScheduleApi } from '#/api/product';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDefaultScheduleForm,
|
||||||
|
normalizeWeekDays,
|
||||||
|
WEEK_DAYS_ALL,
|
||||||
|
WEEK_DAYS_WEEKDAY,
|
||||||
|
WEEK_DAYS_WEEKEND,
|
||||||
|
} from './constants';
|
||||||
|
import { isValidTimeText } from './timeline-utils';
|
||||||
|
|
||||||
|
interface CreateDrawerActionsOptions {
|
||||||
|
drawerMode: Ref<'create' | 'edit'>;
|
||||||
|
editingScheduleId: Ref<string>;
|
||||||
|
form: ScheduleEditorForm;
|
||||||
|
isDrawerOpen: Ref<boolean>;
|
||||||
|
isDrawerSubmitting: Ref<boolean>;
|
||||||
|
isPickerOpen: Ref<boolean>;
|
||||||
|
loadPickerProducts: () => Promise<void>;
|
||||||
|
loadSchedules: () => Promise<void>;
|
||||||
|
pickerKeyword: Ref<string>;
|
||||||
|
pickerSelectedIds: Ref<string[]>;
|
||||||
|
rows: Ref<ProductScheduleCardViewModel[]>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||||
|
function setDrawerOpen(value: boolean) {
|
||||||
|
options.isDrawerOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerOpen(value: boolean) {
|
||||||
|
options.isPickerOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormName(value: string) {
|
||||||
|
options.form.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormStartTime(value: string) {
|
||||||
|
options.form.startTime = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormEndTime(value: string) {
|
||||||
|
options.form.endTime = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFormStatus() {
|
||||||
|
options.form.status =
|
||||||
|
options.form.status === 'enabled' ? 'disabled' : 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormWeekDays(value: number[]) {
|
||||||
|
options.form.weekDays = normalizeWeekDays(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWeekDay(day: number) {
|
||||||
|
if (options.form.weekDays.includes(day)) {
|
||||||
|
setFormWeekDays(options.form.weekDays.filter((item) => item !== day));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormWeekDays([...options.form.weekDays, day]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllDays() {
|
||||||
|
setFormWeekDays([...WEEK_DAYS_ALL]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWeekdays() {
|
||||||
|
setFormWeekDays([...WEEK_DAYS_WEEKDAY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWeekend() {
|
||||||
|
setFormWeekDays([...WEEK_DAYS_WEEKEND]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFormProduct(productId: string) {
|
||||||
|
options.form.productIds = options.form.productIds.filter(
|
||||||
|
(item) => item !== productId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerKeyword(value: string) {
|
||||||
|
options.pickerKeyword.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePickerProduct(productId: string) {
|
||||||
|
if (options.pickerSelectedIds.value.includes(productId)) {
|
||||||
|
options.pickerSelectedIds.value = options.pickerSelectedIds.value.filter(
|
||||||
|
(item) => item !== productId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.pickerSelectedIds.value = [
|
||||||
|
...options.pickerSelectedIds.value,
|
||||||
|
productId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProductPicker() {
|
||||||
|
if (!options.selectedStoreId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.pickerKeyword.value = '';
|
||||||
|
options.pickerSelectedIds.value = [...options.form.productIds];
|
||||||
|
await options.loadPickerProducts();
|
||||||
|
options.isPickerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPicker() {
|
||||||
|
options.form.productIds = [...options.pickerSelectedIds.value];
|
||||||
|
options.isPickerOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
const next = createDefaultScheduleForm();
|
||||||
|
options.editingScheduleId.value = '';
|
||||||
|
options.form.id = '';
|
||||||
|
options.form.name = next.name;
|
||||||
|
options.form.startTime = next.startTime;
|
||||||
|
options.form.endTime = next.endTime;
|
||||||
|
options.form.weekDays = [...next.weekDays];
|
||||||
|
options.form.productIds = [];
|
||||||
|
options.form.status = next.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDrawer() {
|
||||||
|
options.drawerMode.value = 'create';
|
||||||
|
resetForm();
|
||||||
|
options.isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDrawer(item: ProductScheduleCardViewModel) {
|
||||||
|
options.drawerMode.value = 'edit';
|
||||||
|
options.editingScheduleId.value = item.id;
|
||||||
|
options.form.id = item.id;
|
||||||
|
options.form.name = item.name;
|
||||||
|
options.form.startTime = item.startTime;
|
||||||
|
options.form.endTime = item.endTime;
|
||||||
|
options.form.weekDays = [...item.weekDays];
|
||||||
|
options.form.productIds = [...item.productIds];
|
||||||
|
options.form.status = item.status;
|
||||||
|
options.isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDrawer() {
|
||||||
|
if (!options.selectedStoreId.value) return;
|
||||||
|
|
||||||
|
const normalizedName = options.form.name.trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
message.warning('请输入规则名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidTimeText(options.form.startTime)) {
|
||||||
|
message.warning('开始时间格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidTimeText(options.form.endTime)) {
|
||||||
|
message.warning('结束时间格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.form.startTime === options.form.endTime) {
|
||||||
|
message.warning('开始和结束时间不能相同');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedWeekDays = normalizeWeekDays(options.form.weekDays);
|
||||||
|
if (normalizedWeekDays.length === 0) {
|
||||||
|
message.warning('请至少选择一个适用星期');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProductIds = [...new Set(options.form.productIds)];
|
||||||
|
if (normalizedProductIds.length === 0) {
|
||||||
|
message.warning('请至少关联一个商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isDrawerSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await saveProductScheduleApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
id: options.editingScheduleId.value || undefined,
|
||||||
|
name: normalizedName,
|
||||||
|
startTime: options.form.startTime,
|
||||||
|
endTime: options.form.endTime,
|
||||||
|
weekDays: normalizedWeekDays,
|
||||||
|
productIds: normalizedProductIds,
|
||||||
|
status: options.form.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success(
|
||||||
|
options.drawerMode.value === 'create'
|
||||||
|
? '时段规则已创建'
|
||||||
|
: '时段规则已更新',
|
||||||
|
);
|
||||||
|
options.isDrawerOpen.value = false;
|
||||||
|
await options.loadSchedules();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
options.isDrawerSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openCreateDrawer,
|
||||||
|
openEditDrawer,
|
||||||
|
openProductPicker,
|
||||||
|
removeFormProduct,
|
||||||
|
selectAllDays,
|
||||||
|
selectWeekdays,
|
||||||
|
selectWeekend,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormEndTime,
|
||||||
|
setFormName,
|
||||||
|
setFormStartTime,
|
||||||
|
setPickerKeyword,
|
||||||
|
setPickerOpen,
|
||||||
|
submitDrawer,
|
||||||
|
submitPicker,
|
||||||
|
toggleFormStatus,
|
||||||
|
togglePickerProduct,
|
||||||
|
toggleWeekDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { ProductScheduleCardViewModel } from '#/views/product/schedule/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:时段规则卡片动作。
|
||||||
|
*/
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
changeProductScheduleStatusApi,
|
||||||
|
deleteProductScheduleApi,
|
||||||
|
} from '#/api/product';
|
||||||
|
|
||||||
|
interface CreateRuleActionsOptions {
|
||||||
|
loadSchedules: () => Promise<void>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuleActions(options: CreateRuleActionsOptions) {
|
||||||
|
async function setRuleStatus(
|
||||||
|
item: ProductScheduleCardViewModel,
|
||||||
|
status: ProductScheduleCardViewModel['status'],
|
||||||
|
) {
|
||||||
|
if (!options.selectedStoreId.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await changeProductScheduleStatusApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
scheduleId: item.id,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
item.status = result.status;
|
||||||
|
item.updatedAt = result.updatedAt;
|
||||||
|
message.success(status === 'enabled' ? '已启用规则' : '已停用规则');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRuleStatus(item: ProductScheduleCardViewModel) {
|
||||||
|
const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled';
|
||||||
|
await setRuleStatus(item, nextStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRule(item: ProductScheduleCardViewModel) {
|
||||||
|
if (!options.selectedStoreId.value) return;
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认删除规则「${item.name}」吗?`,
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
await deleteProductScheduleApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
scheduleId: item.id,
|
||||||
|
});
|
||||||
|
message.success('时段规则已删除');
|
||||||
|
await options.loadSchedules();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeRule,
|
||||||
|
toggleRuleStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type {
|
||||||
|
ProductScheduleCardViewModel,
|
||||||
|
ScheduleTimelineBar,
|
||||||
|
ScheduleTimelineRow,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:时段时间轴与时间段计算。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function tryParseTimeMinutes(value: string): null | number {
|
||||||
|
const matched = /^([01]\d|2[0-3]):([0-5]\d)$/.exec((value || '').trim());
|
||||||
|
if (!matched) return null;
|
||||||
|
|
||||||
|
const hour = Number(matched[1]);
|
||||||
|
const minute = Number(matched[2]);
|
||||||
|
return hour * 60 + minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPercent(minutes: number) {
|
||||||
|
return Number(((minutes / 1440) * 100).toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHourLabel(value: string, trimLeadingZero = false) {
|
||||||
|
const hour = value.slice(0, 2);
|
||||||
|
if (!trimLeadingZero) return hour;
|
||||||
|
return String(Number(hour));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验时间文本。 */
|
||||||
|
export function isValidTimeText(value: string) {
|
||||||
|
return tryParseTimeMinutes(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否跨天时段。 */
|
||||||
|
export function isCrossDay(startTime: string, endTime: string) {
|
||||||
|
const start = tryParseTimeMinutes(startTime);
|
||||||
|
const end = tryParseTimeMinutes(endTime);
|
||||||
|
if (start === null || end === null) return false;
|
||||||
|
return end <= start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片显示时间文本。 */
|
||||||
|
export function formatTimeRangeText(startTime: string, endTime: string) {
|
||||||
|
const suffix = isCrossDay(startTime, endTime) ? ' (次日)' : '';
|
||||||
|
return `${startTime} ~ ${endTime}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算时间条段(支持跨天拆分)。 */
|
||||||
|
export function buildTimeSegments(startTime: string, endTime: string) {
|
||||||
|
const start = tryParseTimeMinutes(startTime);
|
||||||
|
const end = tryParseTimeMinutes(endTime);
|
||||||
|
if (start === null || end === null || start === end) {
|
||||||
|
return [] as ScheduleTimelineBar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end > start) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
left: toPercent(start),
|
||||||
|
width: toPercent(end - start),
|
||||||
|
label: `${toHourLabel(startTime)}~${toHourLabel(endTime)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
left: toPercent(start),
|
||||||
|
width: toPercent(1440 - start),
|
||||||
|
label: `${toHourLabel(startTime)}~24`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
left: 0,
|
||||||
|
width: toPercent(end),
|
||||||
|
label: `0~${toHourLabel(endTime, true)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日星期(1-7)。 */
|
||||||
|
export function getTodayWeekDay() {
|
||||||
|
const day = new Date().getDay();
|
||||||
|
return day === 0 ? 7 : day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造今日时间轴行。 */
|
||||||
|
export function buildTimelineRow(
|
||||||
|
item: ProductScheduleCardViewModel,
|
||||||
|
color: string,
|
||||||
|
): ScheduleTimelineRow {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
status: item.status,
|
||||||
|
color,
|
||||||
|
bars: buildTimeSegments(item.startTime, item.endTime),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { ProductPickerItemDto, ProductScheduleDto } from '#/api/product';
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
import type {
|
||||||
|
ScheduleEditorForm,
|
||||||
|
ScheduleProductChip,
|
||||||
|
ScheduleProductLookup,
|
||||||
|
} from '#/views/product/schedule/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:时段供应页面状态与行为编排。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDefaultScheduleForm,
|
||||||
|
resolveScheduleColor,
|
||||||
|
} from './schedule-page/constants';
|
||||||
|
import { createDataActions } from './schedule-page/data-actions';
|
||||||
|
import { createDrawerActions } from './schedule-page/drawer-actions';
|
||||||
|
import { createRuleActions } from './schedule-page/rule-actions';
|
||||||
|
import {
|
||||||
|
buildTimelineRow,
|
||||||
|
getTodayWeekDay,
|
||||||
|
} from './schedule-page/timeline-utils';
|
||||||
|
|
||||||
|
export function useProductSchedulePage() {
|
||||||
|
const stores = ref<StoreListItemDto[]>([]);
|
||||||
|
const selectedStoreId = ref('');
|
||||||
|
const isStoreLoading = ref(false);
|
||||||
|
|
||||||
|
const rows = ref<ProductScheduleDto[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const keyword = ref('');
|
||||||
|
|
||||||
|
const isDrawerOpen = ref(false);
|
||||||
|
const isDrawerSubmitting = ref(false);
|
||||||
|
const drawerMode = ref<'create' | 'edit'>('create');
|
||||||
|
const editingScheduleId = ref('');
|
||||||
|
|
||||||
|
const form = reactive<ScheduleEditorForm>(createDefaultScheduleForm());
|
||||||
|
|
||||||
|
const isPickerOpen = ref(false);
|
||||||
|
const isPickerLoading = ref(false);
|
||||||
|
const pickerKeyword = ref('');
|
||||||
|
const pickerProducts = ref<ProductPickerItemDto[]>([]);
|
||||||
|
const pickerSelectedIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
const productLookup = ref<ScheduleProductLookup>({});
|
||||||
|
|
||||||
|
const storeOptions = computed(() =>
|
||||||
|
stores.value.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const normalized = keyword.value.trim().toLowerCase();
|
||||||
|
if (!normalized) return rows.value;
|
||||||
|
return rows.value.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(normalized),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ruleCount = computed(() => rows.value.length);
|
||||||
|
|
||||||
|
const enabledCount = computed(
|
||||||
|
() => rows.value.filter((item) => item.status === 'enabled').length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const coveredProductCount = computed(() => {
|
||||||
|
const idSet = new Set<string>();
|
||||||
|
for (const item of rows.value) {
|
||||||
|
for (const productId of item.productIds) {
|
||||||
|
idSet.add(productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idSet.size;
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawerTitle = computed(() =>
|
||||||
|
drawerMode.value === 'create' ? '添加时段规则' : '编辑时段规则',
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawerSubmitText = computed(() => '保存');
|
||||||
|
|
||||||
|
const selectedProducts = computed<ScheduleProductChip[]>(() =>
|
||||||
|
form.productIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: resolveProductName(id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const timelineRows = computed(() => {
|
||||||
|
const today = getTodayWeekDay();
|
||||||
|
return filteredRows.value
|
||||||
|
.filter((item) => item.weekDays.includes(today))
|
||||||
|
.map((item) => buildTimelineRow(item, getRuleColor(item.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function getRuleColor(scheduleId: string) {
|
||||||
|
return resolveScheduleColor(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProductName(productId: string) {
|
||||||
|
const product = productLookup.value[productId];
|
||||||
|
if (product) return product.name;
|
||||||
|
|
||||||
|
const suffix = productId.length > 4 ? productId.slice(-4) : productId;
|
||||||
|
return `商品${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScheduleProductNames(item: ProductScheduleDto) {
|
||||||
|
return item.productIds.map((id) => resolveProductName(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedStoreId(value: string) {
|
||||||
|
selectedStoreId.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKeyword(value: string) {
|
||||||
|
keyword.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadPickerProducts,
|
||||||
|
loadSchedules,
|
||||||
|
loadStores,
|
||||||
|
preloadProductLookup,
|
||||||
|
} = createDataActions({
|
||||||
|
stores,
|
||||||
|
selectedStoreId,
|
||||||
|
isStoreLoading,
|
||||||
|
rows,
|
||||||
|
isLoading,
|
||||||
|
isPickerLoading,
|
||||||
|
pickerKeyword,
|
||||||
|
pickerProducts,
|
||||||
|
productLookup,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
openCreateDrawer,
|
||||||
|
openEditDrawer,
|
||||||
|
openProductPicker,
|
||||||
|
removeFormProduct,
|
||||||
|
selectAllDays,
|
||||||
|
selectWeekdays,
|
||||||
|
selectWeekend,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormEndTime,
|
||||||
|
setFormName,
|
||||||
|
setFormStartTime,
|
||||||
|
setPickerKeyword,
|
||||||
|
setPickerOpen,
|
||||||
|
submitDrawer,
|
||||||
|
submitPicker,
|
||||||
|
toggleFormStatus,
|
||||||
|
togglePickerProduct,
|
||||||
|
toggleWeekDay,
|
||||||
|
} = createDrawerActions({
|
||||||
|
drawerMode,
|
||||||
|
editingScheduleId,
|
||||||
|
form,
|
||||||
|
isDrawerOpen,
|
||||||
|
isDrawerSubmitting,
|
||||||
|
isPickerOpen,
|
||||||
|
pickerKeyword,
|
||||||
|
pickerSelectedIds,
|
||||||
|
rows,
|
||||||
|
selectedStoreId,
|
||||||
|
loadSchedules,
|
||||||
|
loadPickerProducts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { removeRule, toggleRuleStatus } = createRuleActions({
|
||||||
|
selectedStoreId,
|
||||||
|
loadSchedules,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(selectedStoreId, () => {
|
||||||
|
keyword.value = '';
|
||||||
|
void Promise.all([loadSchedules(), preloadProductLookup()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(loadStores);
|
||||||
|
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,559 +1,176 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 文件职责:时段供应页面。
|
* 文件职责:时段供应页面主视图。
|
||||||
* 1. 管理供应时段模板。
|
* 1. 还原原型的工具栏、统计条、规则卡片、时间轴与抽屉。
|
||||||
* 2. 管理时段模板与商品关联关系。
|
* 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 { Page } from '@vben/common-ui';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
import {
|
import { Button, Empty, Input, Select, Spin } from 'ant-design-vue';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
Drawer,
|
|
||||||
Empty,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
message,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
} from 'ant-design-vue';
|
|
||||||
|
|
||||||
import {
|
import ScheduleEditorDrawer from './components/ScheduleEditorDrawer.vue';
|
||||||
changeProductScheduleStatusApi,
|
import ScheduleProductPickerModal from './components/ScheduleProductPickerModal.vue';
|
||||||
deleteProductScheduleApi,
|
import ScheduleRuleCard from './components/ScheduleRuleCard.vue';
|
||||||
getProductScheduleListApi,
|
import ScheduleTimelineCard from './components/ScheduleTimelineCard.vue';
|
||||||
saveProductScheduleApi,
|
import { useProductSchedulePage } from './composables/useProductSchedulePage';
|
||||||
searchProductPickerApi,
|
|
||||||
} from '#/api/product';
|
|
||||||
import { getStoreListApi } from '#/api/store';
|
|
||||||
|
|
||||||
type StatusFilter = '' | ProductSwitchStatus;
|
const {
|
||||||
|
coveredProductCount,
|
||||||
interface SlotForm {
|
drawerSubmitText,
|
||||||
id: string;
|
drawerTitle,
|
||||||
weekDays: number[];
|
enabledCount,
|
||||||
startTime: string;
|
filteredRows,
|
||||||
endTime: string;
|
form,
|
||||||
}
|
getRuleColor,
|
||||||
|
getScheduleProductNames,
|
||||||
interface ScheduleRow {
|
isDrawerOpen,
|
||||||
description: string;
|
isDrawerSubmitting,
|
||||||
id: string;
|
isLoading,
|
||||||
name: string;
|
isPickerLoading,
|
||||||
productCount: number;
|
isPickerOpen,
|
||||||
productIds: string[];
|
isStoreLoading,
|
||||||
slots: SlotForm[];
|
keyword,
|
||||||
sort: number;
|
loadPickerProducts,
|
||||||
status: ProductSwitchStatus;
|
openCreateDrawer,
|
||||||
updatedAt: string;
|
openEditDrawer,
|
||||||
}
|
openProductPicker,
|
||||||
|
pickerKeyword,
|
||||||
const stores = ref<StoreListItemDto[]>([]);
|
pickerProducts,
|
||||||
const selectedStoreId = ref('');
|
pickerSelectedIds,
|
||||||
const isStoreLoading = ref(false);
|
removeFormProduct,
|
||||||
|
removeRule,
|
||||||
const rows = ref<ScheduleRow[]>([]);
|
ruleCount,
|
||||||
const isLoading = ref(false);
|
selectAllDays,
|
||||||
const keyword = ref('');
|
selectedProducts,
|
||||||
const statusFilter = ref<StatusFilter>('');
|
selectedStoreId,
|
||||||
|
selectWeekdays,
|
||||||
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
|
selectWeekend,
|
||||||
const isDrawerOpen = ref(false);
|
setDrawerOpen,
|
||||||
const isDrawerSubmitting = ref(false);
|
setFormEndTime,
|
||||||
const editingScheduleId = ref('');
|
setFormName,
|
||||||
|
setFormStartTime,
|
||||||
const form = reactive({
|
setKeyword,
|
||||||
name: '',
|
setPickerKeyword,
|
||||||
description: '',
|
setPickerOpen,
|
||||||
status: 'enabled' as ProductSwitchStatus,
|
setSelectedStoreId,
|
||||||
sort: 1,
|
storeOptions,
|
||||||
productIds: [] as string[],
|
submitDrawer,
|
||||||
slots: [] as SlotForm[],
|
submitPicker,
|
||||||
});
|
timelineRows,
|
||||||
|
toggleFormStatus,
|
||||||
const weekDayOptions = [
|
togglePickerProduct,
|
||||||
{ label: '周一', value: 1 },
|
toggleRuleStatus,
|
||||||
{ label: '周二', value: 2 },
|
toggleWeekDay,
|
||||||
{ label: '周三', value: 3 },
|
} = useProductSchedulePage();
|
||||||
{ 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);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page title="时段供应" content-class="space-y-4 page-product-schedule">
|
<Page title="时段供应" content-class="page-product-schedule">
|
||||||
<Card :bordered="false">
|
<div class="ptm-page">
|
||||||
<Space wrap>
|
<div class="ptm-toolbar">
|
||||||
<Select
|
<Select
|
||||||
v-model:value="selectedStoreId"
|
class="ptm-store-select"
|
||||||
|
:value="selectedStoreId"
|
||||||
:options="storeOptions"
|
:options="storeOptions"
|
||||||
:loading="isStoreLoading"
|
:loading="isStoreLoading"
|
||||||
style="width: 240px"
|
|
||||||
placeholder="请选择门店"
|
placeholder="请选择门店"
|
||||||
|
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
v-model:value="keyword"
|
class="ptm-search"
|
||||||
style="width: 220px"
|
:value="keyword"
|
||||||
placeholder="搜索时段名称"
|
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">
|
<span class="ptm-spacer"></span>
|
||||||
<Empty description="暂无门店,请先创建门店" />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-else :bordered="false">
|
<Button type="primary" @click="openCreateDrawer">+ 添加时段规则</Button>
|
||||||
<Table
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<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"
|
:open="isDrawerOpen"
|
||||||
:title="drawerTitle"
|
:title="drawerTitle"
|
||||||
width="660"
|
:submit-text="drawerSubmitText"
|
||||||
:destroy-on-close="true"
|
:submitting="isDrawerSubmitting"
|
||||||
@update:open="setDrawerOpen"
|
:form="form"
|
||||||
>
|
:selected-products="selectedProducts"
|
||||||
<Form layout="vertical">
|
@close="setDrawerOpen(false)"
|
||||||
<Form.Item label="时段名称" required>
|
@set-name="setFormName"
|
||||||
<Input v-model:value="form.name" :maxlength="30" show-count />
|
@set-start-time="setFormStartTime"
|
||||||
</Form.Item>
|
@set-end-time="setFormEndTime"
|
||||||
<Form.Item label="时段描述">
|
@toggle-week-day="toggleWeekDay"
|
||||||
<Input v-model:value="form.description" :maxlength="100" show-count />
|
@select-all-days="selectAllDays"
|
||||||
</Form.Item>
|
@select-weekdays="selectWeekdays"
|
||||||
<Form.Item label="关联商品">
|
@select-weekend="selectWeekend"
|
||||||
<Select
|
@open-product-picker="openProductPicker"
|
||||||
v-model:value="form.productIds"
|
@remove-product="removeFormProduct"
|
||||||
mode="multiple"
|
@toggle-status="toggleFormStatus"
|
||||||
:options="pickerOptions"
|
@submit="submitDrawer"
|
||||||
placeholder="请选择商品"
|
/>
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Divider orientation="left">时段配置</Divider>
|
<ScheduleProductPickerModal
|
||||||
<div v-for="(slot, index) in form.slots" :key="index" class="slot-row">
|
:open="isPickerOpen"
|
||||||
<Select
|
title="关联商品"
|
||||||
v-model:value="slot.weekDays"
|
:loading="isPickerLoading"
|
||||||
mode="multiple"
|
:submitting="false"
|
||||||
:options="weekDayOptions"
|
:keyword="pickerKeyword"
|
||||||
style="width: 220px"
|
:products="pickerProducts"
|
||||||
placeholder="选择星期"
|
:selected-ids="pickerSelectedIds"
|
||||||
/>
|
@close="setPickerOpen(false)"
|
||||||
<Input
|
@set-keyword="setPickerKeyword"
|
||||||
v-model:value="slot.startTime"
|
@search="loadPickerProducts"
|
||||||
style="width: 110px"
|
@toggle-product="togglePickerProduct"
|
||||||
placeholder="开始 HH:mm"
|
@submit="submitPicker"
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style lang="less">
|
||||||
/* 文件职责:时段供应页面样式。 */
|
@import './styles/index.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>
|
</style>
|
||||||
|
|||||||
108
apps/web-antd/src/views/product/schedule/styles/base.less
Normal file
108
apps/web-antd/src/views/product/schedule/styles/base.less
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:时段供应页面基础样式。
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--g-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--g-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
|
||||||
|
--g-shadow-md: 0 4px 12px rgb(0 0 0 / 7%), 0 1px 3px rgb(0 0 0 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-action {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1677ff;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-action + .g-action {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-action-danger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgb(0 0 0 / 45%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-mask.open {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1001;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -6px 0 16px rgb(0 0 0 / 8%);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-hd {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
height: 54px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-close:hover {
|
||||||
|
color: #333;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-bd {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-ft {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
140
apps/web-antd/src/views/product/schedule/styles/card.less
Normal file
140
apps/web-antd/src/views/product/schedule/styles/card.less
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
.page-product-schedule {
|
||||||
|
.g-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tag-on {
|
||||||
|
color: #22c55e;
|
||||||
|
background: #dcfce7;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tag-off {
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-card {
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-card:hover {
|
||||||
|
box-shadow: var(--g-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-card.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-card-hd {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-card-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-time-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-time-text {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-timebar-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-timebar-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-days {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-day {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 26px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-day.active {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-products {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-more {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-card-ft {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
314
apps/web-antd/src/views/product/schedule/styles/drawer.less
Normal file
314
apps/web-antd/src/views/product/schedule/styles/drawer.less
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
.ptm-editor-drawer {
|
||||||
|
.g-drawer-close .iconify {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-label {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-label.required::before {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #ef4444;
|
||||||
|
content: '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-input:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f1f1f;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
transition: all var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: var(--g-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn-primary:hover {
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn-sm {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #d9d9d9;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
content: '';
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 15%);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle.on {
|
||||||
|
background: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle.on::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-fg-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-fg-row .g-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-fg-sep {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-day-sel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-day {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-day.active {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-day-quick {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-search .g-input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-selected {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1677ff;
|
||||||
|
background: #f0f5ff;
|
||||||
|
border: 1px solid #adc6ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-chip-x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
padding: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-chip-x .iconify {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-chip-x:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-prod-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-search .g-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f1f1f;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-search .g-btn:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-search .g-btn-sm {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-list {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr 140px auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-item:hover {
|
||||||
|
background: #fafcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-item .name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-item .spu {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-item .price {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-picker-empty {
|
||||||
|
padding: 28px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@import './base.less';
|
||||||
|
@import './layout.less';
|
||||||
|
@import './card.less';
|
||||||
|
@import './drawer.less';
|
||||||
|
@import './timeline.less';
|
||||||
|
@import './responsive.less';
|
||||||
108
apps/web-antd/src/views/product/schedule/styles/layout.less
Normal file
108
apps/web-antd/src/views/product/schedule/styles/layout.less
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
.page-product-schedule {
|
||||||
|
.ptm-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-store-select {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-store-select .ant-select-selector {
|
||||||
|
height: 34px !important;
|
||||||
|
border-color: #e5e7eb !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-store-select .ant-select-selection-item {
|
||||||
|
line-height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-search .ant-input {
|
||||||
|
height: 34px;
|
||||||
|
padding-left: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23bbbbbb' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E")
|
||||||
|
10px center no-repeat;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-stats span {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-stats strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0050b3;
|
||||||
|
background: #e6f7ff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-banner .icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-banner .icon .iconify {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-empty {
|
||||||
|
padding: 28px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.page-product-schedule {
|
||||||
|
@media (width <= 1200px) {
|
||||||
|
.ptm-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-axis {
|
||||||
|
margin-left: 72px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-label {
|
||||||
|
width: 62px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.ptm-time-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-time-text {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-card-ft {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.page-product-schedule {
|
||||||
|
.ptm-tl-card {
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-title {
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border-left: 3px solid #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-axis {
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-axis span {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-row.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-radius: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptm-tl-empty {
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/web-antd/src/views/product/schedule/types.ts
Normal file
47
apps/web-antd/src/views/product/schedule/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:时段供应页面共享类型定义。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ProductPickerItemDto,
|
||||||
|
ProductScheduleDto,
|
||||||
|
ProductSwitchStatus,
|
||||||
|
} from '#/api/product';
|
||||||
|
|
||||||
|
/** 时段规则编辑表单。 */
|
||||||
|
export interface ScheduleEditorForm {
|
||||||
|
endTime: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
productIds: string[];
|
||||||
|
startTime: string;
|
||||||
|
status: ProductSwitchStatus;
|
||||||
|
weekDays: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时段规则卡片视图模型。 */
|
||||||
|
export type ProductScheduleCardViewModel = ProductScheduleDto;
|
||||||
|
|
||||||
|
/** 已选商品标签。 */
|
||||||
|
export interface ScheduleProductChip {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间轴条段。 */
|
||||||
|
export interface ScheduleTimelineBar {
|
||||||
|
label: string;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间轴行。 */
|
||||||
|
export interface ScheduleTimelineRow {
|
||||||
|
bars: ScheduleTimelineBar[];
|
||||||
|
color: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: ProductSwitchStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品映射表。 */
|
||||||
|
export type ScheduleProductLookup = Record<string, ProductPickerItemDto>;
|
||||||
Reference in New Issue
Block a user