feat(project): sync all pending tenant ui changes

This commit is contained in:
2026-02-20 16:50:20 +08:00
parent 937c9b4334
commit 788491ad3d
34 changed files with 7527 additions and 95 deletions

View File

@@ -0,0 +1,564 @@
<script setup lang="ts">
/**
* 文件职责:加料管理页面。
* 1. 管理加料组、加料项配置。
* 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 {
Button,
Card,
Divider,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import {
changeProductAddonGroupStatusApi,
deleteProductAddonGroupApi,
getProductAddonGroupListApi,
saveProductAddonGroupApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
type StatusFilter = '' | ProductSwitchStatus;
interface AddonItemForm {
id: string;
name: string;
price: number;
sort: number;
status: ProductSwitchStatus;
}
interface AddonGroupRow {
description: string;
id: string;
items: AddonItemForm[];
maxSelect: number;
minSelect: number;
name: string;
productCount: number;
productIds: string[];
required: boolean;
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<AddonGroupRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingGroupId = ref('');
const form = reactive({
name: '',
description: '',
required: false,
minSelect: 0,
maxSelect: 1,
sort: 1,
status: 'enabled' as ProductSwitchStatus,
productIds: [] as string[],
items: [] as AddonItemForm[],
});
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>
<template>
<Page title="加料管理" content-class="space-y-4 page-product-addons">
<Card :bordered="false">
<Space wrap>
<Select
v-model:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
/>
<Input
v-model:value="keyword"
style="width: 220px"
placeholder="搜索加料组名称"
/>
<Select
v-model:value="statusFilter"
:options="statusOptions"
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">
<Empty description="暂无门店,请先创建门店" />
</Card>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column
title="加料组名称"
data-index="name"
key="name"
:width="180"
/>
<Table.Column title="加料项" key="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
:open="isDrawerOpen"
:title="drawerTitle"
width="620"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="加料组名称" required>
<Input v-model:value="form.name" :maxlength="30" show-count />
</Form.Item>
<Form.Item label="描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
<Form.Item label="是否必选">
<Switch v-model:checked="form.required" />
</Form.Item>
<Space style="display: flex; width: 100%">
<Form.Item label="最少可选" style="flex: 1">
<InputNumber
v-model:value="form.minSelect"
:min="0"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="最多可选" style="flex: 1">
<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>
</template>
<style scoped lang="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>