feat: 1:1还原加料管理页面与交互
This commit is contained in:
@@ -313,6 +313,7 @@ export interface ProductAddonItemDto {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
stock: number;
|
||||||
sort: number;
|
sort: number;
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
}
|
}
|
||||||
@@ -350,6 +351,7 @@ export interface SaveProductAddonGroupDto {
|
|||||||
price: number;
|
price: number;
|
||||||
sort: number;
|
sort: number;
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
|
stock: number;
|
||||||
}>;
|
}>;
|
||||||
maxSelect: number;
|
maxSelect: number;
|
||||||
minSelect: number;
|
minSelect: number;
|
||||||
@@ -374,6 +376,13 @@ export interface ChangeProductAddonGroupStatusDto {
|
|||||||
storeId: string;
|
storeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 绑定加料组商品参数。 */
|
||||||
|
export interface BindProductAddonGroupProductsDto {
|
||||||
|
groupId: string;
|
||||||
|
productIds: string[];
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 商品标签。 */
|
/** 商品标签。 */
|
||||||
export interface ProductLabelDto {
|
export interface ProductLabelDto {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -696,6 +705,16 @@ export async function changeProductAddonGroupStatusApi(
|
|||||||
return requestClient.post('/product/addon/group/status', data);
|
return requestClient.post('/product/addon/group/status', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 绑定加料组商品。 */
|
||||||
|
export async function bindProductAddonGroupProductsApi(
|
||||||
|
data: BindProductAddonGroupProductsDto,
|
||||||
|
) {
|
||||||
|
return requestClient.post<ProductAddonGroupDto>(
|
||||||
|
'/product/addon/group/products/bind',
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取标签列表。 */
|
/** 获取标签列表。 */
|
||||||
export async function getProductLabelListApi(params: ProductLabelQuery) {
|
export async function getProductLabelListApi(params: ProductLabelQuery) {
|
||||||
return requestClient.get<ProductLabelDto[]>('/product/label/list', {
|
return requestClient.get<ProductLabelDto[]>('/product/label/list', {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ interface AddonItemRecord {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
stock: number;
|
||||||
sort: number;
|
sort: number;
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
}
|
}
|
||||||
@@ -378,6 +379,7 @@ function toAddonGroupItem(
|
|||||||
id: addon.id,
|
id: addon.id,
|
||||||
name: addon.name,
|
name: addon.name,
|
||||||
price: addon.price,
|
price: addon.price,
|
||||||
|
stock: addon.stock,
|
||||||
sort: addon.sort,
|
sort: addon.sort,
|
||||||
status: addon.status,
|
status: addon.status,
|
||||||
})),
|
})),
|
||||||
@@ -517,6 +519,7 @@ function createDefaultState(storeId: string): ProductExtensionStoreState {
|
|||||||
id: createId('addon-item', storeId),
|
id: createId('addon-item', storeId),
|
||||||
name: '加鸡蛋',
|
name: '加鸡蛋',
|
||||||
price: 2,
|
price: 2,
|
||||||
|
stock: 160,
|
||||||
sort: 1,
|
sort: 1,
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
@@ -524,6 +527,7 @@ function createDefaultState(storeId: string): ProductExtensionStoreState {
|
|||||||
id: createId('addon-item', storeId),
|
id: createId('addon-item', storeId),
|
||||||
name: '加饭',
|
name: '加饭',
|
||||||
price: 3,
|
price: 3,
|
||||||
|
stock: 80,
|
||||||
sort: 2,
|
sort: 2,
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
@@ -1067,6 +1071,7 @@ Mock.mock(
|
|||||||
id: normalizeText(current.id, createId('addon-item', storeId)),
|
id: normalizeText(current.id, createId('addon-item', storeId)),
|
||||||
name: itemName,
|
name: itemName,
|
||||||
price: Number(normalizeNumber(current.price, 0, 0).toFixed(2)),
|
price: Number(normalizeNumber(current.price, 0, 0).toFixed(2)),
|
||||||
|
stock: normalizeInt(current.stock, 999, 0),
|
||||||
sort: normalizeInt(current.sort, index + 1, 1),
|
sort: normalizeInt(current.sort, index + 1, 1),
|
||||||
status: normalizeSwitchStatus(current.status, 'enabled'),
|
status: normalizeSwitchStatus(current.status, 'enabled'),
|
||||||
};
|
};
|
||||||
@@ -1177,6 +1182,29 @@ Mock.mock(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Mock.mock(
|
||||||
|
/\/product\/addon\/group\/products\/bind/,
|
||||||
|
'post',
|
||||||
|
(options: MockRequestOptions) => {
|
||||||
|
const body = parseBody(options);
|
||||||
|
const storeId = normalizeText(body.storeId);
|
||||||
|
const groupId = normalizeText(body.groupId);
|
||||||
|
if (!storeId || !groupId) {
|
||||||
|
return { code: 400, data: null, message: '参数不完整' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = ensureStoreState(storeId);
|
||||||
|
const target = state.addonGroups.find((item) => item.id === groupId);
|
||||||
|
if (!target) return { code: 404, data: null, message: '加料组不存在' };
|
||||||
|
|
||||||
|
target.productIds = normalizeIdList(body.productIds).filter((productId) =>
|
||||||
|
state.products.some((item) => item.id === productId),
|
||||||
|
);
|
||||||
|
target.updatedAt = toDateTimeText(new Date());
|
||||||
|
return { code: 200, data: toAddonGroupItem(state, target) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Mock.mock(
|
Mock.mock(
|
||||||
/\/product\/label\/list(?:\?|$)/,
|
/\/product\/label\/list(?:\?|$)/,
|
||||||
'get',
|
'get',
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:加料组编辑抽屉。
|
||||||
|
* 1. 承载加料组基础字段和加料项编辑表单。
|
||||||
|
* 2. 对外抛出表单变更与保存事件。
|
||||||
|
*/
|
||||||
|
import type { AddonEditorForm } from '../types';
|
||||||
|
|
||||||
|
import { onBeforeUnmount, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: AddonEditorForm;
|
||||||
|
open: boolean;
|
||||||
|
submitText: string;
|
||||||
|
submitting: boolean;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
addItem: [];
|
||||||
|
close: [];
|
||||||
|
removeItem: [index: number];
|
||||||
|
setDescription: [value: string];
|
||||||
|
setItemName: [index: number, value: string];
|
||||||
|
setItemPrice: [index: number, value: number];
|
||||||
|
setItemStock: [index: number, value: number];
|
||||||
|
setMaxSelect: [value: number];
|
||||||
|
setMinSelect: [value: number];
|
||||||
|
setName: [value: string];
|
||||||
|
setRequired: [value: boolean];
|
||||||
|
submit: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (props.submitting) return;
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pad-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">
|
||||||
|
×
|
||||||
|
</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">
|
||||||
|
<div class="g-toggle-wrap">
|
||||||
|
<label class="g-toggle-input">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.required"
|
||||||
|
@change="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setRequired',
|
||||||
|
(event.target as HTMLInputElement).checked,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="g-toggle-sl"></span>
|
||||||
|
</label>
|
||||||
|
<span class="g-toggle-label">是否必选</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pad-sel-row">
|
||||||
|
<span>选择数量:最少</span>
|
||||||
|
<input
|
||||||
|
class="g-input pad-input-num"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
:value="form.minSelect"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setMinSelect',
|
||||||
|
Number((event.target as HTMLInputElement).value || 0),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span>最多</span>
|
||||||
|
<input
|
||||||
|
class="g-input pad-input-num"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:value="form.maxSelect"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setMaxSelect',
|
||||||
|
Number((event.target as HTMLInputElement).value || 1),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">选项列表</label>
|
||||||
|
<div class="g-hint" style="margin-bottom: 8px">
|
||||||
|
每个选项可设置加价和库存,加价为 0 表示免费
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pad-opt-list-header">
|
||||||
|
<span class="h-name">名称</span>
|
||||||
|
<span class="h-price">加价(¥)</span>
|
||||||
|
<span class="h-stock">库存</span>
|
||||||
|
<span class="h-act"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pad-opt-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in form.items"
|
||||||
|
:key="index"
|
||||||
|
class="pad-opt-row"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="pad-opt-name"
|
||||||
|
type="text"
|
||||||
|
:value="item.name"
|
||||||
|
placeholder="如:珍珠"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setItemName',
|
||||||
|
index,
|
||||||
|
(event.target as HTMLInputElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="pad-opt-price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
:value="item.price"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setItemPrice',
|
||||||
|
index,
|
||||||
|
Number((event.target as HTMLInputElement).value || 0),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="pad-opt-stock"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
:value="item.stock"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setItemStock',
|
||||||
|
index,
|
||||||
|
Number((event.target as HTMLInputElement).value || 0),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pad-opt-del"
|
||||||
|
@click="emit('removeItem', index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-btn pad-btn-dashed"
|
||||||
|
@click="emit('addItem')"
|
||||||
|
>
|
||||||
|
+ 添加选项
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label">备注</label>
|
||||||
|
<textarea
|
||||||
|
class="g-textarea"
|
||||||
|
rows="2"
|
||||||
|
maxlength="120"
|
||||||
|
:value="form.description"
|
||||||
|
placeholder="可选,如:仅限堂食商品使用"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setDescription',
|
||||||
|
(event.target as HTMLTextAreaElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
></textarea>
|
||||||
|
</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,140 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:加料组卡片。
|
||||||
|
* 1. 展示加料组信息、选项表格与库存状态。
|
||||||
|
* 2. 对外抛出编辑、关联商品、改名、移除等操作事件。
|
||||||
|
*/
|
||||||
|
import type { AddonGroupCardViewModel } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: AddonGroupCardViewModel;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
bindProducts: [item: AddonGroupCardViewModel];
|
||||||
|
edit: [item: AddonGroupCardViewModel];
|
||||||
|
enable: [item: AddonGroupCardViewModel];
|
||||||
|
removeGroup: [item: AddonGroupCardViewModel];
|
||||||
|
removeItem: [payload: { group: AddonGroupCardViewModel; itemId: string }];
|
||||||
|
renameItem: [payload: { group: AddonGroupCardViewModel; itemId: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function formatPrice(value: number) {
|
||||||
|
return `+¥${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleText() {
|
||||||
|
if (
|
||||||
|
props.item.required &&
|
||||||
|
props.item.minSelect === 1 &&
|
||||||
|
props.item.maxSelect === 1
|
||||||
|
) {
|
||||||
|
return '必须选1';
|
||||||
|
}
|
||||||
|
return `最少选${props.item.minSelect},最多选${props.item.maxSelect}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStockText(stock: number) {
|
||||||
|
return stock <= 20 ? '偏少' : '充足';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStockClass(stock: number) {
|
||||||
|
return stock <= 20 ? 'pad-stock-low' : 'pad-stock-ok';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pad-card" :class="{ disabled: item.status === 'disabled' }">
|
||||||
|
<div class="pad-card-header">
|
||||||
|
<span class="pad-card-name">{{ item.name }}</span>
|
||||||
|
<span
|
||||||
|
class="g-tag"
|
||||||
|
:class="
|
||||||
|
item.status === 'disabled'
|
||||||
|
? 'pad-tag-disabled'
|
||||||
|
: item.required
|
||||||
|
? 'pad-tag-required'
|
||||||
|
: 'pad-tag-optional'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
item.status === 'disabled'
|
||||||
|
? '已停用'
|
||||||
|
: item.required
|
||||||
|
? '必选'
|
||||||
|
: '可选'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="pad-rule">{{ getRuleText() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="pad-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>选项名称</th>
|
||||||
|
<th>加价</th>
|
||||||
|
<th>库存</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="option in item.items" :key="option.id">
|
||||||
|
<td>{{ option.name }}</td>
|
||||||
|
<td>{{ formatPrice(option.price) }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="getStockClass(option.stock)">
|
||||||
|
{{ getStockText(option.stock) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-action"
|
||||||
|
@click="emit('renameItem', { group: item, itemId: option.id })"
|
||||||
|
>
|
||||||
|
改名
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-action g-action-danger"
|
||||||
|
@click="emit('removeItem', { group: item, itemId: option.id })"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pad-assoc">已关联 {{ item.productCount }} 个商品</div>
|
||||||
|
|
||||||
|
<div class="pad-card-footer">
|
||||||
|
<button type="button" class="g-action" @click="emit('edit', item)">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="item.status === 'disabled'"
|
||||||
|
type="button"
|
||||||
|
class="g-action"
|
||||||
|
@click="emit('enable', item)"
|
||||||
|
>
|
||||||
|
启用
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="g-action"
|
||||||
|
@click="emit('bindProducts', item)"
|
||||||
|
>
|
||||||
|
关联商品
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-action g-action-danger"
|
||||||
|
@click="emit('removeGroup', item)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:加料组关联商品弹窗。
|
||||||
|
*/
|
||||||
|
import type { ProductPickerItemDto } from '#/api/product';
|
||||||
|
|
||||||
|
import { Input, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
keyword: string;
|
||||||
|
loading: boolean;
|
||||||
|
open: boolean;
|
||||||
|
products: ProductPickerItemDto[];
|
||||||
|
selectedIds: string[];
|
||||||
|
submitting: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(event: 'close'): void;
|
||||||
|
(event: 'search'): void;
|
||||||
|
(event: 'setKeyword', value: string): void;
|
||||||
|
(event: 'submit'): void;
|
||||||
|
(event: 'toggleProduct', id: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
function setKeyword(value: string) {
|
||||||
|
emit('setKeyword', value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:open="props.open"
|
||||||
|
:title="title"
|
||||||
|
ok-text="确认关联"
|
||||||
|
cancel-text="取消"
|
||||||
|
:confirm-loading="props.submitting"
|
||||||
|
:ok-button-props="{ disabled: props.selectedIds.length === 0 }"
|
||||||
|
@ok="emit('submit')"
|
||||||
|
@cancel="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="pad-picker-search">
|
||||||
|
<Input
|
||||||
|
:value="props.keyword"
|
||||||
|
placeholder="搜索商品名称/SPU"
|
||||||
|
allow-clear
|
||||||
|
@update:value="setKeyword"
|
||||||
|
@press-enter="emit('search')"
|
||||||
|
/>
|
||||||
|
<button type="button" class="pad-btn pad-btn-sm" @click="emit('search')">
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pad-picker-list">
|
||||||
|
<div v-if="props.loading" class="pad-picker-empty">加载中...</div>
|
||||||
|
<div v-else-if="props.products.length === 0" class="pad-picker-empty">
|
||||||
|
暂无可选商品
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
v-for="item in props.products"
|
||||||
|
v-else
|
||||||
|
:key="`picker-${item.id}`"
|
||||||
|
class="pad-picker-item"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="props.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,610 @@
|
|||||||
|
import type {
|
||||||
|
AddonEditorForm,
|
||||||
|
AddonGroupCardViewModel,
|
||||||
|
AddonItemForm,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import type { ProductAddonGroupDto, ProductPickerItemDto } from '#/api/product';
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:加料管理页面状态与行为编排。
|
||||||
|
* 1. 管理门店、加料组卡片、统计与筛选状态。
|
||||||
|
* 2. 封装加料组新增编辑、选项改名移除、关联商品流程。
|
||||||
|
*/
|
||||||
|
import { computed, h, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Input, message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
bindProductAddonGroupProductsApi,
|
||||||
|
changeProductAddonGroupStatusApi,
|
||||||
|
deleteProductAddonGroupApi,
|
||||||
|
getProductAddonGroupListApi,
|
||||||
|
saveProductAddonGroupApi,
|
||||||
|
searchProductPickerApi,
|
||||||
|
} from '#/api/product';
|
||||||
|
import { getStoreListApi } from '#/api/store';
|
||||||
|
|
||||||
|
const DEFAULT_ITEM: AddonItemForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
price: 0,
|
||||||
|
stock: 999,
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useProductAddonsPage() {
|
||||||
|
const stores = ref<StoreListItemDto[]>([]);
|
||||||
|
const selectedStoreId = ref('');
|
||||||
|
const isStoreLoading = ref(false);
|
||||||
|
|
||||||
|
const rows = ref<AddonGroupCardViewModel[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const keyword = ref('');
|
||||||
|
|
||||||
|
const isDrawerOpen = ref(false);
|
||||||
|
const isDrawerSubmitting = ref(false);
|
||||||
|
const drawerMode = ref<'create' | 'edit'>('create');
|
||||||
|
const editingGroupId = ref('');
|
||||||
|
const editingGroupName = ref('');
|
||||||
|
const editingProductIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
const form = reactive<AddonEditorForm>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
required: false,
|
||||||
|
minSelect: 0,
|
||||||
|
maxSelect: 1,
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
items: [{ ...DEFAULT_ITEM }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPickerOpen = ref(false);
|
||||||
|
const isPickerLoading = ref(false);
|
||||||
|
const isPickerSubmitting = ref(false);
|
||||||
|
const pickerTitle = ref('关联商品');
|
||||||
|
const pickerKeyword = ref('');
|
||||||
|
const pickerProducts = ref<ProductPickerItemDto[]>([]);
|
||||||
|
const pickerSelectedIds = ref<string[]>([]);
|
||||||
|
const bindingGroupId = ref('');
|
||||||
|
|
||||||
|
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 groupCount = computed(() => rows.value.length);
|
||||||
|
|
||||||
|
const optionCount = computed(() =>
|
||||||
|
rows.value.reduce((sum, item) => sum + item.items.length, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const relatedProductCount = computed(() =>
|
||||||
|
rows.value.reduce((sum, item) => sum + item.productCount, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawerTitle = computed(() =>
|
||||||
|
drawerMode.value === 'create'
|
||||||
|
? '添加加料组'
|
||||||
|
: `编辑加料组 - ${editingGroupName.value}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawerSubmitText = computed(() =>
|
||||||
|
drawerMode.value === 'create' ? '确认保存' : '保存修改',
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
rows.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 loadAddonGroups() {
|
||||||
|
if (!selectedStoreId.value) {
|
||||||
|
rows.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const list = await getProductAddonGroupListApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
});
|
||||||
|
rows.value = list;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
rows.value = [];
|
||||||
|
message.error('加载加料组失败');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedStoreId(value: string) {
|
||||||
|
selectedStoreId.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKeyword(value: string) {
|
||||||
|
keyword.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDrawerOpen(value: boolean) {
|
||||||
|
isDrawerOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormName(value: string) {
|
||||||
|
form.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormDescription(value: string) {
|
||||||
|
form.description = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormRequired(value: boolean) {
|
||||||
|
form.required = value;
|
||||||
|
if (value && form.minSelect < 1) {
|
||||||
|
form.minSelect = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormMinSelect(value: number) {
|
||||||
|
form.minSelect = Math.max(0, value);
|
||||||
|
if (form.required && form.minSelect < 1) {
|
||||||
|
form.minSelect = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormMaxSelect(value: number) {
|
||||||
|
form.maxSelect = Math.max(1, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setItemName(index: number, value: string) {
|
||||||
|
const current = form.items[index];
|
||||||
|
if (!current) return;
|
||||||
|
current.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setItemPrice(index: number, value: number) {
|
||||||
|
const current = form.items[index];
|
||||||
|
if (!current) return;
|
||||||
|
current.price = Number.isNaN(value) ? 0 : Math.max(0, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setItemStock(index: number, value: number) {
|
||||||
|
const current = form.items[index];
|
||||||
|
if (!current) return;
|
||||||
|
current.stock = Number.isNaN(value) ? 0 : Math.max(0, Math.floor(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
form.items.push({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
price: 0,
|
||||||
|
stock: 999,
|
||||||
|
sort: form.items.length + 1,
|
||||||
|
status: 'enabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
if (form.items.length <= 1) {
|
||||||
|
message.warning('至少保留一个加料项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.items.splice(index, 1);
|
||||||
|
form.items.forEach((item, idx) => {
|
||||||
|
item.sort = idx + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingGroupId.value = '';
|
||||||
|
editingGroupName.value = '';
|
||||||
|
editingProductIds.value = [];
|
||||||
|
form.name = '';
|
||||||
|
form.description = '';
|
||||||
|
form.required = false;
|
||||||
|
form.minSelect = 0;
|
||||||
|
form.maxSelect = 1;
|
||||||
|
form.sort = rows.value.length + 1;
|
||||||
|
form.status = 'enabled';
|
||||||
|
form.items = [{ ...DEFAULT_ITEM }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDrawer() {
|
||||||
|
drawerMode.value = 'create';
|
||||||
|
resetForm();
|
||||||
|
isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDrawer(item: AddonGroupCardViewModel) {
|
||||||
|
drawerMode.value = 'edit';
|
||||||
|
editingGroupId.value = item.id;
|
||||||
|
editingGroupName.value = item.name;
|
||||||
|
editingProductIds.value = [...item.productIds];
|
||||||
|
form.name = item.name;
|
||||||
|
form.description = item.description;
|
||||||
|
form.required = item.required;
|
||||||
|
form.minSelect = item.minSelect;
|
||||||
|
form.maxSelect = item.maxSelect;
|
||||||
|
form.sort = item.sort;
|
||||||
|
form.status = item.status;
|
||||||
|
form.items = item.items.map((option, index) => ({
|
||||||
|
id: option.id,
|
||||||
|
name: option.name,
|
||||||
|
price: option.price,
|
||||||
|
stock: option.stock,
|
||||||
|
sort: option.sort || index + 1,
|
||||||
|
status: option.status,
|
||||||
|
}));
|
||||||
|
if (form.items.length === 0) {
|
||||||
|
form.items = [{ ...DEFAULT_ITEM }];
|
||||||
|
}
|
||||||
|
isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDrawer() {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
message.warning('请输入加料组名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedItems = form.items
|
||||||
|
.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
name: item.name.trim(),
|
||||||
|
sort: index + 1,
|
||||||
|
price: Number(item.price.toFixed(2)),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name);
|
||||||
|
if (normalizedItems.length === 0) {
|
||||||
|
message.warning('至少保留一个加料项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueNames = new Set(
|
||||||
|
normalizedItems.map((item) => item.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (uniqueNames.size !== normalizedItems.length) {
|
||||||
|
message.warning('加料项名称不能重复');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.maxSelect < form.minSelect) {
|
||||||
|
message.warning('最大可选数量不能小于最小可选数量');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDrawerSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await saveProductAddonGroupApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
id: editingGroupId.value || undefined,
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
required: form.required,
|
||||||
|
minSelect: form.minSelect,
|
||||||
|
maxSelect: form.maxSelect,
|
||||||
|
sort: form.sort,
|
||||||
|
status: form.status,
|
||||||
|
productIds: [...editingProductIds.value],
|
||||||
|
items: normalizedItems.map((item) => ({
|
||||||
|
id: item.id || undefined,
|
||||||
|
name: item.name,
|
||||||
|
price: item.price,
|
||||||
|
stock: item.stock,
|
||||||
|
sort: item.sort,
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
message.success(
|
||||||
|
drawerMode.value === 'create' ? '加料组已创建' : '加料组已更新',
|
||||||
|
);
|
||||||
|
isDrawerOpen.value = false;
|
||||||
|
await loadAddonGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isDrawerSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGroup(item: AddonGroupCardViewModel) {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认删除加料组「${item.name}」吗?`,
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
await deleteProductAddonGroupApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
groupId: item.id,
|
||||||
|
});
|
||||||
|
message.success('加料组已删除');
|
||||||
|
await loadAddonGroups();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableGroup(item: AddonGroupCardViewModel) {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
try {
|
||||||
|
await changeProductAddonGroupStatusApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
groupId: item.id,
|
||||||
|
status: 'enabled',
|
||||||
|
});
|
||||||
|
message.success('加料组已启用');
|
||||||
|
await loadAddonGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroupInline(
|
||||||
|
group: AddonGroupCardViewModel,
|
||||||
|
items: ProductAddonGroupDto['items'],
|
||||||
|
) {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
await saveProductAddonGroupApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
required: group.required,
|
||||||
|
minSelect: group.minSelect,
|
||||||
|
maxSelect: group.maxSelect,
|
||||||
|
sort: group.sort,
|
||||||
|
status: group.status,
|
||||||
|
productIds: [...group.productIds],
|
||||||
|
items: items.map((item, index) => ({
|
||||||
|
id: item.id || undefined,
|
||||||
|
name: item.name.trim(),
|
||||||
|
price: Number(item.price.toFixed(2)),
|
||||||
|
stock: item.stock,
|
||||||
|
sort: index + 1,
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameItem(payload: {
|
||||||
|
group: AddonGroupCardViewModel;
|
||||||
|
itemId: string;
|
||||||
|
}) {
|
||||||
|
const currentItem = payload.group.items.find(
|
||||||
|
(item) => item.id === payload.itemId,
|
||||||
|
);
|
||||||
|
if (!currentItem) return;
|
||||||
|
|
||||||
|
let draftName = currentItem.name;
|
||||||
|
Modal.confirm({
|
||||||
|
title: `改名 - ${currentItem.name}`,
|
||||||
|
okText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
content: () =>
|
||||||
|
h(Input, {
|
||||||
|
value: draftName,
|
||||||
|
maxlength: 30,
|
||||||
|
placeholder: '请输入新名称',
|
||||||
|
'onUpdate:value': (value: string) => {
|
||||||
|
draftName = value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async onOk() {
|
||||||
|
const nextName = draftName.trim();
|
||||||
|
if (!nextName) {
|
||||||
|
message.warning('加料项名称不能为空');
|
||||||
|
throw new Error('invalid-name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = payload.group.items.map((item) =>
|
||||||
|
item.id === payload.itemId ? { ...item, name: nextName } : item,
|
||||||
|
);
|
||||||
|
const uniqueNames = new Set(
|
||||||
|
nextItems.map((item) => item.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (uniqueNames.size !== nextItems.length) {
|
||||||
|
message.warning('加料项名称不能重复');
|
||||||
|
throw new Error('duplicate-name');
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveGroupInline(payload.group, nextItems);
|
||||||
|
message.success('加料项名称已更新');
|
||||||
|
await loadAddonGroups();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCardItem(payload: {
|
||||||
|
group: AddonGroupCardViewModel;
|
||||||
|
itemId: string;
|
||||||
|
}) {
|
||||||
|
if (payload.group.items.length <= 1) {
|
||||||
|
message.warning('至少保留一个加料项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItem = payload.group.items.find(
|
||||||
|
(item) => item.id === payload.itemId,
|
||||||
|
);
|
||||||
|
if (!currentItem) return;
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认移除选项「${currentItem.name}」吗?`,
|
||||||
|
okText: '确认移除',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
const nextItems = payload.group.items.filter(
|
||||||
|
(item) => item.id !== payload.itemId,
|
||||||
|
);
|
||||||
|
await saveGroupInline(payload.group, nextItems);
|
||||||
|
message.success('加料项已移除');
|
||||||
|
await loadAddonGroups();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPickerProducts() {
|
||||||
|
if (!selectedStoreId.value) {
|
||||||
|
pickerProducts.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isPickerLoading.value = true;
|
||||||
|
try {
|
||||||
|
pickerProducts.value = await searchProductPickerApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
keyword: pickerKeyword.value.trim() || undefined,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
pickerProducts.value = [];
|
||||||
|
message.error('加载商品失败');
|
||||||
|
} finally {
|
||||||
|
isPickerLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBindProducts(item: AddonGroupCardViewModel) {
|
||||||
|
bindingGroupId.value = item.id;
|
||||||
|
pickerTitle.value = `关联商品 - ${item.name}`;
|
||||||
|
pickerKeyword.value = '';
|
||||||
|
pickerSelectedIds.value = [...item.productIds];
|
||||||
|
pickerProducts.value = [];
|
||||||
|
isPickerOpen.value = true;
|
||||||
|
await loadPickerProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerOpen(value: boolean) {
|
||||||
|
isPickerOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerKeyword(value: string) {
|
||||||
|
pickerKeyword.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePickerProduct(id: string) {
|
||||||
|
if (pickerSelectedIds.value.includes(id)) {
|
||||||
|
pickerSelectedIds.value = pickerSelectedIds.value.filter(
|
||||||
|
(item) => item !== id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pickerSelectedIds.value = [...pickerSelectedIds.value, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPicker() {
|
||||||
|
if (!selectedStoreId.value || !bindingGroupId.value) return;
|
||||||
|
|
||||||
|
isPickerSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await bindProductAddonGroupProductsApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
groupId: bindingGroupId.value,
|
||||||
|
productIds: [...pickerSelectedIds.value],
|
||||||
|
});
|
||||||
|
message.success('关联商品已更新');
|
||||||
|
isPickerOpen.value = false;
|
||||||
|
await loadAddonGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isPickerSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedStoreId, () => {
|
||||||
|
keyword.value = '';
|
||||||
|
void loadAddonGroups();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(loadStores);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addItem,
|
||||||
|
drawerSubmitText,
|
||||||
|
drawerTitle,
|
||||||
|
enableGroup,
|
||||||
|
filteredRows,
|
||||||
|
form,
|
||||||
|
groupCount,
|
||||||
|
isDrawerOpen,
|
||||||
|
isDrawerSubmitting,
|
||||||
|
isLoading,
|
||||||
|
isPickerLoading,
|
||||||
|
isPickerOpen,
|
||||||
|
isPickerSubmitting,
|
||||||
|
isStoreLoading,
|
||||||
|
keyword,
|
||||||
|
loadPickerProducts,
|
||||||
|
openBindProducts,
|
||||||
|
openCreateDrawer,
|
||||||
|
openEditDrawer,
|
||||||
|
optionCount,
|
||||||
|
pickerKeyword,
|
||||||
|
pickerProducts,
|
||||||
|
pickerSelectedIds,
|
||||||
|
pickerTitle,
|
||||||
|
relatedProductCount,
|
||||||
|
removeCardItem,
|
||||||
|
removeGroup,
|
||||||
|
removeItem,
|
||||||
|
renameItem,
|
||||||
|
selectedStoreId,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormDescription,
|
||||||
|
setFormMaxSelect,
|
||||||
|
setFormMinSelect,
|
||||||
|
setFormName,
|
||||||
|
setFormRequired,
|
||||||
|
setItemName,
|
||||||
|
setItemPrice,
|
||||||
|
setItemStock,
|
||||||
|
setKeyword,
|
||||||
|
setPickerKeyword,
|
||||||
|
setPickerOpen,
|
||||||
|
setSelectedStoreId,
|
||||||
|
storeOptions,
|
||||||
|
submitDrawer,
|
||||||
|
submitPicker,
|
||||||
|
togglePickerProduct,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,564 +1,164 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 文件职责:加料管理页面。
|
* 文件职责:加料管理页面主视图。
|
||||||
* 1. 管理加料组、加料项配置。
|
* 1. 还原原型的工具栏、统计条、卡片列表与右侧抽屉。
|
||||||
* 2. 管理加料组与商品关联。
|
* 2. 管理加料组及其关联商品。
|
||||||
*/
|
*/
|
||||||
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 {
|
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 AddonEditorDrawer from './components/AddonEditorDrawer.vue';
|
||||||
changeProductAddonGroupStatusApi,
|
import AddonGroupCard from './components/AddonGroupCard.vue';
|
||||||
deleteProductAddonGroupApi,
|
import AddonProductPickerModal from './components/AddonProductPickerModal.vue';
|
||||||
getProductAddonGroupListApi,
|
import { useProductAddonsPage } from './composables/useProductAddonsPage';
|
||||||
saveProductAddonGroupApi,
|
|
||||||
searchProductPickerApi,
|
|
||||||
} from '#/api/product';
|
|
||||||
import { getStoreListApi } from '#/api/store';
|
|
||||||
|
|
||||||
type StatusFilter = '' | ProductSwitchStatus;
|
const {
|
||||||
|
addItem,
|
||||||
interface AddonItemForm {
|
drawerSubmitText,
|
||||||
id: string;
|
drawerTitle,
|
||||||
name: string;
|
enableGroup,
|
||||||
price: number;
|
filteredRows,
|
||||||
sort: number;
|
form,
|
||||||
status: ProductSwitchStatus;
|
groupCount,
|
||||||
}
|
isDrawerOpen,
|
||||||
|
isDrawerSubmitting,
|
||||||
interface AddonGroupRow {
|
isLoading,
|
||||||
description: string;
|
isPickerLoading,
|
||||||
id: string;
|
isPickerOpen,
|
||||||
items: AddonItemForm[];
|
isPickerSubmitting,
|
||||||
maxSelect: number;
|
isStoreLoading,
|
||||||
minSelect: number;
|
keyword,
|
||||||
name: string;
|
loadPickerProducts,
|
||||||
productCount: number;
|
openBindProducts,
|
||||||
productIds: string[];
|
openCreateDrawer,
|
||||||
required: boolean;
|
openEditDrawer,
|
||||||
sort: number;
|
optionCount,
|
||||||
status: ProductSwitchStatus;
|
pickerKeyword,
|
||||||
updatedAt: string;
|
pickerProducts,
|
||||||
}
|
pickerSelectedIds,
|
||||||
|
pickerTitle,
|
||||||
const stores = ref<StoreListItemDto[]>([]);
|
relatedProductCount,
|
||||||
const selectedStoreId = ref('');
|
removeCardItem,
|
||||||
const isStoreLoading = ref(false);
|
removeGroup,
|
||||||
|
removeItem,
|
||||||
const rows = ref<AddonGroupRow[]>([]);
|
renameItem,
|
||||||
const isLoading = ref(false);
|
selectedStoreId,
|
||||||
const keyword = ref('');
|
setDrawerOpen,
|
||||||
const statusFilter = ref<StatusFilter>('');
|
setFormDescription,
|
||||||
|
setFormMaxSelect,
|
||||||
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
|
setFormMinSelect,
|
||||||
const isDrawerOpen = ref(false);
|
setFormName,
|
||||||
const isDrawerSubmitting = ref(false);
|
setFormRequired,
|
||||||
const editingGroupId = ref('');
|
setItemName,
|
||||||
|
setItemPrice,
|
||||||
const form = reactive({
|
setItemStock,
|
||||||
name: '',
|
setKeyword,
|
||||||
description: '',
|
setPickerKeyword,
|
||||||
required: false,
|
setPickerOpen,
|
||||||
minSelect: 0,
|
setSelectedStoreId,
|
||||||
maxSelect: 1,
|
storeOptions,
|
||||||
sort: 1,
|
submitDrawer,
|
||||||
status: 'enabled' as ProductSwitchStatus,
|
submitPicker,
|
||||||
productIds: [] as string[],
|
togglePickerProduct,
|
||||||
items: [] as AddonItemForm[],
|
} = useProductAddonsPage();
|
||||||
});
|
|
||||||
|
|
||||||
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(() =>
|
|
||||||
editingGroupId.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 loadAddonGroups() {
|
|
||||||
if (!selectedStoreId.value) {
|
|
||||||
rows.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
try {
|
|
||||||
const result = await getProductAddonGroupListApi({
|
|
||||||
storeId: selectedStoreId.value,
|
|
||||||
keyword: keyword.value.trim() || undefined,
|
|
||||||
status: (statusFilter.value || undefined) as
|
|
||||||
| ProductSwitchStatus
|
|
||||||
| undefined,
|
|
||||||
});
|
|
||||||
rows.value = result as AddonGroupRow[];
|
|
||||||
} 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() {
|
|
||||||
editingGroupId.value = '';
|
|
||||||
form.name = '';
|
|
||||||
form.description = '';
|
|
||||||
form.required = false;
|
|
||||||
form.minSelect = 0;
|
|
||||||
form.maxSelect = 1;
|
|
||||||
form.sort = rows.value.length + 1;
|
|
||||||
form.status = 'enabled';
|
|
||||||
form.productIds = [];
|
|
||||||
form.items = [
|
|
||||||
{
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
price: 0,
|
|
||||||
sort: 1,
|
|
||||||
status: 'enabled',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开新增抽屉。 */
|
|
||||||
async function openCreateDrawer() {
|
|
||||||
resetForm();
|
|
||||||
await loadPickerOptions();
|
|
||||||
isDrawerOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开编辑抽屉。 */
|
|
||||||
async function openEditDrawer(row: AddonGroupRow) {
|
|
||||||
editingGroupId.value = row.id;
|
|
||||||
form.name = row.name;
|
|
||||||
form.description = row.description;
|
|
||||||
form.required = row.required;
|
|
||||||
form.minSelect = row.minSelect;
|
|
||||||
form.maxSelect = row.maxSelect;
|
|
||||||
form.sort = row.sort;
|
|
||||||
form.status = row.status;
|
|
||||||
form.productIds = [...row.productIds];
|
|
||||||
form.items = row.items.map((item) => ({ ...item }));
|
|
||||||
if (form.items.length === 0) {
|
|
||||||
form.items = [{ id: '', name: '', price: 0, sort: 1, status: 'enabled' }];
|
|
||||||
}
|
|
||||||
await loadPickerOptions();
|
|
||||||
isDrawerOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 添加加料项。 */
|
|
||||||
function addAddonItem() {
|
|
||||||
form.items.push({
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
price: 0,
|
|
||||||
sort: form.items.length + 1,
|
|
||||||
status: 'enabled',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除加料项。 */
|
|
||||||
function removeAddonItem(index: number) {
|
|
||||||
if (form.items.length <= 1) {
|
|
||||||
message.warning('至少保留一个加料项');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.items.splice(index, 1);
|
|
||||||
form.items.forEach((item, idx) => {
|
|
||||||
item.sort = idx + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存加料组。 */
|
|
||||||
async function submitDrawer() {
|
|
||||||
if (!selectedStoreId.value) return;
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
message.warning('请输入加料组名称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.items.some((item) => !item.name.trim())) {
|
|
||||||
message.warning('加料项名称不能为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.maxSelect < form.minSelect) {
|
|
||||||
message.warning('最大可选数量不能小于最小可选数量');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isDrawerSubmitting.value = true;
|
|
||||||
try {
|
|
||||||
await saveProductAddonGroupApi({
|
|
||||||
storeId: selectedStoreId.value,
|
|
||||||
id: editingGroupId.value || undefined,
|
|
||||||
name: form.name.trim(),
|
|
||||||
description: form.description.trim(),
|
|
||||||
required: form.required,
|
|
||||||
minSelect: form.minSelect,
|
|
||||||
maxSelect: form.maxSelect,
|
|
||||||
sort: form.sort,
|
|
||||||
status: form.status,
|
|
||||||
productIds: [...form.productIds],
|
|
||||||
items: form.items.map((item) => ({
|
|
||||||
id: item.id || undefined,
|
|
||||||
name: item.name.trim(),
|
|
||||||
price: item.price,
|
|
||||||
sort: item.sort,
|
|
||||||
status: item.status,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
message.success(editingGroupId.value ? '加料组已更新' : '加料组已创建');
|
|
||||||
isDrawerOpen.value = false;
|
|
||||||
await loadAddonGroups();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
isDrawerSubmitting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除加料组。 */
|
|
||||||
function removeAddonGroup(row: AddonGroupRow) {
|
|
||||||
if (!selectedStoreId.value) return;
|
|
||||||
Modal.confirm({
|
|
||||||
title: `确认删除加料组「${row.name}」吗?`,
|
|
||||||
async onOk() {
|
|
||||||
await deleteProductAddonGroupApi({
|
|
||||||
storeId: selectedStoreId.value,
|
|
||||||
groupId: row.id,
|
|
||||||
});
|
|
||||||
message.success('加料组已删除');
|
|
||||||
await loadAddonGroups();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 切换加料组状态。 */
|
|
||||||
async function toggleAddonStatus(row: AddonGroupRow, checked: boolean) {
|
|
||||||
if (!selectedStoreId.value) return;
|
|
||||||
await changeProductAddonGroupStatusApi({
|
|
||||||
storeId: selectedStoreId.value,
|
|
||||||
groupId: row.id,
|
|
||||||
status: checked ? 'enabled' : 'disabled',
|
|
||||||
});
|
|
||||||
row.status = checked ? 'enabled' : 'disabled';
|
|
||||||
message.success('状态已更新');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重置筛选。 */
|
|
||||||
function resetFilters() {
|
|
||||||
keyword.value = '';
|
|
||||||
statusFilter.value = '';
|
|
||||||
loadAddonGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selectedStoreId, loadAddonGroups);
|
|
||||||
|
|
||||||
onMounted(loadStores);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page title="加料管理" content-class="space-y-4 page-product-addons">
|
<Page title="加料管理" content-class="page-product-addons">
|
||||||
<Card :bordered="false">
|
<div class="pad-page">
|
||||||
<Space wrap>
|
<div class="pad-toolbar">
|
||||||
<Select
|
<Select
|
||||||
v-model:value="selectedStoreId"
|
class="pad-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="pad-search"
|
||||||
style="width: 220px"
|
:value="keyword"
|
||||||
placeholder="搜索加料组名称"
|
placeholder="搜索加料组名称…"
|
||||||
|
@update:value="setKeyword"
|
||||||
/>
|
/>
|
||||||
<Select
|
<span class="pad-spacer"></span>
|
||||||
v-model:value="statusFilter"
|
<Button type="primary" @click="openCreateDrawer">+ 添加加料组</Button>
|
||||||
:options="statusOptions"
|
</div>
|
||||||
style="width: 140px"
|
|
||||||
/>
|
|
||||||
<Button type="primary" @click="loadAddonGroups">查询</Button>
|
|
||||||
<Button @click="resetFilters">重置</Button>
|
|
||||||
<Button type="primary" @click="openCreateDrawer">新增加料组</Button>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-if="!selectedStoreId" :bordered="false">
|
<div v-if="selectedStoreId" class="pad-stats">
|
||||||
<Empty description="暂无门店,请先创建门店" />
|
<span>
|
||||||
</Card>
|
加料组 <strong>{{ groupCount }}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
选项总数 <strong>{{ optionCount }}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
关联商品 <strong>{{ relatedProductCount }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card v-else :bordered="false">
|
<div v-if="!selectedStoreId" class="pad-empty">
|
||||||
<Table
|
暂无门店,请先创建门店
|
||||||
row-key="id"
|
</div>
|
||||||
:data-source="rows"
|
|
||||||
:loading="isLoading"
|
|
||||||
:pagination="false"
|
|
||||||
size="middle"
|
|
||||||
>
|
|
||||||
<Table.Column
|
|
||||||
title="加料组名称"
|
|
||||||
data-index="name"
|
|
||||||
key="name"
|
|
||||||
:width="180"
|
|
||||||
/>
|
|
||||||
<Table.Column title="加料项" key="items">
|
|
||||||
<template #default="{ record }">
|
|
||||||
<Space wrap size="small">
|
|
||||||
<Tag v-for="item in record.items" :key="item.id || item.name">
|
|
||||||
{{ item.name }}(+{{ item.price }})
|
|
||||||
</Tag>
|
|
||||||
</Space>
|
|
||||||
</template>
|
|
||||||
</Table.Column>
|
|
||||||
<Table.Column title="选择规则" key="rule" :width="160">
|
|
||||||
<template #default="{ record }">
|
|
||||||
{{ record.required ? '必选' : '非必选' }},{{ record.minSelect }}~{{
|
|
||||||
record.maxSelect
|
|
||||||
}}
|
|
||||||
</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) => toggleAddonStatus(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="removeAddonGroup(record)">
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</template>
|
|
||||||
</Table.Column>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Drawer
|
<Spin v-else :spinning="isLoading">
|
||||||
|
<template v-if="filteredRows.length > 0">
|
||||||
|
<AddonGroupCard
|
||||||
|
v-for="item in filteredRows"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
@edit="openEditDrawer"
|
||||||
|
@bind-products="openBindProducts"
|
||||||
|
@remove-group="removeGroup"
|
||||||
|
@enable="enableGroup"
|
||||||
|
@rename-item="renameItem"
|
||||||
|
@remove-item="removeCardItem"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div v-else class="pad-empty">
|
||||||
|
<Empty description="暂无加料组" />
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddonEditorDrawer
|
||||||
:open="isDrawerOpen"
|
:open="isDrawerOpen"
|
||||||
:title="drawerTitle"
|
:title="drawerTitle"
|
||||||
width="620"
|
:submit-text="drawerSubmitText"
|
||||||
:destroy-on-close="true"
|
:submitting="isDrawerSubmitting"
|
||||||
@update:open="setDrawerOpen"
|
:form="form"
|
||||||
>
|
@close="setDrawerOpen(false)"
|
||||||
<Form layout="vertical">
|
@set-name="setFormName"
|
||||||
<Form.Item label="加料组名称" required>
|
@set-description="setFormDescription"
|
||||||
<Input v-model:value="form.name" :maxlength="30" show-count />
|
@set-required="setFormRequired"
|
||||||
</Form.Item>
|
@set-min-select="setFormMinSelect"
|
||||||
<Form.Item label="描述">
|
@set-max-select="setFormMaxSelect"
|
||||||
<Input v-model:value="form.description" :maxlength="100" show-count />
|
@set-item-name="setItemName"
|
||||||
</Form.Item>
|
@set-item-price="setItemPrice"
|
||||||
<Form.Item label="关联商品">
|
@set-item-stock="setItemStock"
|
||||||
<Select
|
@add-item="addItem"
|
||||||
v-model:value="form.productIds"
|
@remove-item="removeItem"
|
||||||
mode="multiple"
|
@submit="submitDrawer"
|
||||||
:options="pickerOptions"
|
/>
|
||||||
placeholder="请选择商品"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="是否必选">
|
<AddonProductPickerModal
|
||||||
<Switch v-model:checked="form.required" />
|
:open="isPickerOpen"
|
||||||
</Form.Item>
|
:title="pickerTitle"
|
||||||
|
:loading="isPickerLoading"
|
||||||
<Space style="display: flex; width: 100%">
|
:submitting="isPickerSubmitting"
|
||||||
<Form.Item label="最少可选" style="flex: 1">
|
:keyword="pickerKeyword"
|
||||||
<InputNumber
|
:products="pickerProducts"
|
||||||
v-model:value="form.minSelect"
|
:selected-ids="pickerSelectedIds"
|
||||||
:min="0"
|
@close="setPickerOpen(false)"
|
||||||
style="width: 100%"
|
@set-keyword="setPickerKeyword"
|
||||||
/>
|
@search="loadPickerProducts"
|
||||||
</Form.Item>
|
@toggle-product="togglePickerProduct"
|
||||||
<Form.Item label="最多可选" style="flex: 1">
|
@submit="submitPicker"
|
||||||
<InputNumber
|
/>
|
||||||
v-model:value="form.maxSelect"
|
|
||||||
:min="1"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Divider orientation="left">加料项</Divider>
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in form.items"
|
|
||||||
:key="index"
|
|
||||||
class="addon-item-row"
|
|
||||||
>
|
|
||||||
<Input v-model:value="item.name" placeholder="加料项名称,如:珍珠" />
|
|
||||||
<InputNumber
|
|
||||||
v-model:value="item.price"
|
|
||||||
:min="0"
|
|
||||||
:step="0.5"
|
|
||||||
style="width: 120px"
|
|
||||||
placeholder="加价"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
v-model:value="item.status"
|
|
||||||
style="width: 110px"
|
|
||||||
:options="[
|
|
||||||
{ label: '启用', value: 'enabled' },
|
|
||||||
{ label: '停用', value: 'disabled' },
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<Button danger @click="removeAddonItem(index)">删除</Button>
|
|
||||||
</div>
|
|
||||||
<Button @click="addAddonItem">新增加料项</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-addons {
|
|
||||||
.addon-item-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-table-cell) {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
620
apps/web-antd/src/views/product/addons/styles/index.less
Normal file
620
apps/web-antd/src/views/product/addons/styles/index.less
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:加料管理页面样式。
|
||||||
|
* 1. 对齐原型的工具栏、统计条、卡片列表与抽屉视觉。
|
||||||
|
* 2. 提供加料项行编辑、库存状态和关联商品弹窗样式。
|
||||||
|
*/
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-product-addons {
|
||||||
|
.pad-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-store-select {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-store-select .ant-select-selector {
|
||||||
|
height: 34px !important;
|
||||||
|
border-color: #e5e7eb !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-store-select .ant-select-selection-item {
|
||||||
|
line-height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-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='%239ca3af' 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-stats span {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-stats strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-card {
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-card:hover {
|
||||||
|
box-shadow: var(--g-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-card.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-card-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-tag-required {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-tag-optional {
|
||||||
|
color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-tag-disabled {
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-rule {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-table {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-table th {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: left;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-table tr:hover td {
|
||||||
|
background: rgb(22 119 255 / 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-stock-ok {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-stock-low {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-assoc {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-card-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-empty {
|
||||||
|
padding: 28px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-editor-drawer {
|
||||||
|
.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 {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-input,
|
||||||
|
.g-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-input {
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-textarea {
|
||||||
|
min-height: 66px;
|
||||||
|
padding-top: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-input:focus,
|
||||||
|
.g-textarea: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:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-wrap {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-sl {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #d9d9d9;
|
||||||
|
border-radius: 11px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-sl::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-input input:checked + .g-toggle-sl {
|
||||||
|
background: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input:checked + .g-toggle-sl::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-sel-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-sel-row span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-input-num {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-list-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-list-header .h-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-list-header .h-price,
|
||||||
|
.pad-opt-list-header .h-stock {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-list-header .h-act {
|
||||||
|
width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-price,
|
||||||
|
.pad-opt-stock {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-del {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-opt-del:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-btn-dashed {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-btn-dashed:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f1f1f;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-btn-sm {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-list {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-item:hover {
|
||||||
|
background: #fafcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-item .name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-item .spu {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-item .price {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-picker-empty {
|
||||||
|
padding: 28px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1200px) {
|
||||||
|
.pad-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/web-antd/src/views/product/addons/types.ts
Normal file
30
apps/web-antd/src/views/product/addons/types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ProductAddonGroupDto, ProductSwitchStatus } from '#/api/product';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:加料管理页面类型定义。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 加料项编辑表单。 */
|
||||||
|
export interface AddonItemForm {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
stock: number;
|
||||||
|
sort: number;
|
||||||
|
status: ProductSwitchStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加料组编辑表单。 */
|
||||||
|
export interface AddonEditorForm {
|
||||||
|
description: string;
|
||||||
|
items: AddonItemForm[];
|
||||||
|
maxSelect: number;
|
||||||
|
minSelect: number;
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
sort: number;
|
||||||
|
status: ProductSwitchStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加料组卡片视图模型。 */
|
||||||
|
export type AddonGroupCardViewModel = ProductAddonGroupDto;
|
||||||
Reference in New Issue
Block a user