feat: 新增门店列表页面并修复全局类型错误

1. 新增门店列表页(筛选/统计/表格/抽屉编辑),使用 mockjs 提供接口数据
2. 新增门店相关枚举、API 定义、路由配置
3. 修复 auth.ts loginApi 参数类型不匹配
4. 修复 merchant-center stores 属性路径错误及 merchant prop 类型不兼容
5. 修复 merchant-setting showSubmitButton 不存在于 VbenFormProps
This commit is contained in:
2026-02-15 16:35:22 +08:00
parent 0a161d185e
commit 4be997df63
13 changed files with 1535 additions and 1053 deletions

View File

@@ -1,404 +1,475 @@
<script setup lang="ts">
import type {
CurrentMerchantCenterDto,
MerchantAuditLogDto,
MerchantChangeLogDto,
MerchantContractDto,
MerchantDocumentDto,
MerchantStaffDto,
MerchantStoreDto,
} from '#/api/merchant';
import type { CurrentMerchantCenterDto } from '#/api/merchant';
import { computed, onMounted, ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Page } from '@vben/common-ui';
import { Alert, Empty, List, message, Spin, Tag } from 'ant-design-vue';
import {
Alert,
Badge,
Button,
Card,
Descriptions,
Empty,
Image,
Modal,
Spin,
Table,
TabPane,
Tabs,
Tag,
Timeline,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getMerchantInfoApi } from '#/api/merchant';
import {
ContractStatus,
MerchantAuditAction,
MerchantDocumentStatus,
MerchantDocumentType,
MerchantStatus,
OperatingMode,
StaffRoleType,
StaffStatus,
StoreStatus,
} from '#/enums/merchantEnum';
import MerchantSetting from './merchant-setting.vue';
const userStore = useUserStore();
const tabsValue = ref('basic');
const loading = ref(false);
const merchantCenter = ref<CurrentMerchantCenterDto | null>(null);
const activeTab = ref('documents');
const isEditModalVisible = ref(false);
const tabs = [
{ label: '基本信息', value: 'basic' },
{ label: '资质合同', value: 'qualification' },
{ label: '门店信息', value: 'stores' },
{ label: '员工信息', value: 'staffs' },
{ label: '日志记录', value: 'logs' },
];
const merchant = computed(() => merchantCenter.value?.merchant);
const documents = computed(() => merchantCenter.value?.documents ?? []);
const contracts = computed(() => merchantCenter.value?.contracts ?? []);
const stores = computed(() => merchantCenter.value?.merchant?.stores ?? []);
const staffs = computed(() => merchantCenter.value?.staffs ?? []);
const auditLogs = computed(() => merchantCenter.value?.auditLogs ?? []);
const changeLogs = computed(() => merchantCenter.value?.changeLogs ?? []);
const merchantStores = computed<MerchantStoreDto[]>(() => {
return merchantCenter.value?.merchant.stores ?? [];
});
const merchantDocuments = computed<MerchantDocumentDto[]>(() => {
return merchantCenter.value?.documents ?? [];
});
const merchantContracts = computed<MerchantContractDto[]>(() => {
return merchantCenter.value?.contracts ?? [];
});
const merchantStaffs = computed<MerchantStaffDto[]>(() => {
return merchantCenter.value?.staffs ?? [];
});
const merchantAuditLogs = computed<MerchantAuditLogDto[]>(() => {
return merchantCenter.value?.auditLogs ?? [];
});
const merchantChangeLogs = computed<MerchantChangeLogDto[]>(() => {
return merchantCenter.value?.changeLogs ?? [];
});
function formatDateTime(value?: null | string) {
if (!value) {
return '--';
}
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function resolveStoreStatus(status: number) {
if (status === 1) {
return '营业中';
}
if (status === 2) {
return '停业中';
}
return `状态${status}`;
}
function resolveContractStatus(status: number) {
if (status === 1) {
return '生效中';
}
if (status === 2) {
return '已终止';
}
if (status === 3) {
return '已过期';
}
return `状态${status}`;
}
function resolveDocumentType(documentType: number) {
if (documentType === 1) {
return '营业执照';
}
if (documentType === 2) {
return '食品经营许可证';
}
if (documentType === 3) {
return '法人身份证';
}
if (documentType === 4) {
return '门头照';
}
return `类型${documentType}`;
}
function resolveDocumentStatus(status: number) {
if (status === 1) {
return '待审核';
}
if (status === 2) {
return '已通过';
}
if (status === 3) {
return '已驳回';
}
return `状态${status}`;
}
function resolveStaffRole(roleType: number) {
if (roleType === 1) {
return '店长';
}
if (roleType === 2) {
return '店员';
}
if (roleType === 3) {
return '财务';
}
return `角色${roleType}`;
}
function resolveStaffStatus(status: number) {
if (status === 1) {
return '在职';
}
if (status === 2) {
return '离职';
}
return `状态${status}`;
}
async function loadMerchantCenter() {
async function loadData() {
loading.value = true;
try {
merchantCenter.value = await getMerchantInfoApi();
} catch {
message.error('商户中心信息加载失败');
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
}
onMounted(() => {
loadMerchantCenter();
});
function handleEditSuccess() {
isEditModalVisible.value = false;
loadData();
}
function formatDateTime(dateStr?: null | string) {
if (!dateStr) return '--';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
function formatDate(dateStr?: null | string) {
if (!dateStr) return '--';
return dayjs(dateStr).format('YYYY-MM-DD');
}
// Enum Resolvers
function resolveOperatingMode(mode?: OperatingMode) {
const map: Record<number, string> = {
[OperatingMode.Direct]: '直营',
[OperatingMode.Franchise]: '加盟',
};
return mode ? (map[mode] ?? mode) : '--';
}
function resolveMerchantStatus(status?: MerchantStatus) {
const map: Record<number, { color: string; text: string }> = {
[MerchantStatus.Pending]: { color: 'orange', text: '待审核' },
[MerchantStatus.Active]: { color: 'green', text: '营业中' },
[MerchantStatus.Suspended]: { color: 'red', text: '已暂停' },
[MerchantStatus.Closed]: { color: 'default', text: '已关闭' },
};
if (status === undefined) {
return { color: 'default', text: '--' };
}
return map[status] ?? { color: 'default', text: `未知(${status})` };
}
function resolveDocumentType(type: MerchantDocumentType) {
const map: Record<number, string> = {
[MerchantDocumentType.BusinessLicense]: '营业执照',
[MerchantDocumentType.Permit]: '许可证',
[MerchantDocumentType.Other]: '其他',
};
return map[type] ?? `类型${type}`;
}
function resolveDocumentStatus(status: MerchantDocumentStatus) {
const map: Record<
number,
{
status: 'default' | 'error' | 'processing' | 'success' | 'warning';
text: string;
}
> = {
[MerchantDocumentStatus.Pending]: { status: 'processing', text: '审核中' },
[MerchantDocumentStatus.Approved]: { status: 'success', text: '有效' },
[MerchantDocumentStatus.Rejected]: { status: 'error', text: '已驳回' },
[MerchantDocumentStatus.Expired]: { status: 'warning', text: '已过期' },
};
return map[status] ?? { status: 'default', text: `状态${status}` };
}
function resolveContractStatus(status: ContractStatus) {
const map: Record<number, { color: string; text: string }> = {
[ContractStatus.Draft]: { color: 'default', text: '草稿' },
[ContractStatus.Active]: { color: 'green', text: '生效中' },
[ContractStatus.Expired]: { color: 'orange', text: '已过期' },
[ContractStatus.Terminated]: { color: 'red', text: '已终止' },
};
return map[status] ?? { color: 'default', text: `状态${status}` };
}
function resolveStoreStatus(status: StoreStatus) {
const map: Record<number, { color: string; text: string }> = {
[StoreStatus.Operating]: { color: 'green', text: '营业中' },
[StoreStatus.Closed]: { color: 'red', text: '已关店' },
[StoreStatus.Renovating]: { color: 'orange', text: '装修中' },
};
return map[status] ?? { color: 'default', text: `状态${status}` };
}
function resolveStaffRole(role: StaffRoleType) {
const map: Record<number, string> = {
[StaffRoleType.StoreManager]: '店长',
[StaffRoleType.Financial]: '财务',
[StaffRoleType.Operator]: '操作员',
};
return map[role] ?? `角色${role}`;
}
function resolveStaffStatus(status: StaffStatus) {
const map: Record<number, { color: string; text: string }> = {
[StaffStatus.Active]: { color: 'green', text: '在职' },
[StaffStatus.Resigned]: { color: 'default', text: '离职' },
};
return map[status] ?? { color: 'default', text: `状态${status}` };
}
function resolveAuditAction(action: MerchantAuditAction) {
const map: Record<number, string> = {
[MerchantAuditAction.Unknown]: '未知',
[MerchantAuditAction.Create]: '创建',
[MerchantAuditAction.Update]: '更新',
[MerchantAuditAction.Delete]: '删除',
[MerchantAuditAction.Audit]: '审核通过',
[MerchantAuditAction.Reject]: '审核驳回',
[MerchantAuditAction.Freeze]: '冻结',
[MerchantAuditAction.Unfreeze]: '解冻',
[MerchantAuditAction.Close]: '关闭',
[MerchantAuditAction.Reopen]: '重开',
};
return map[action] ?? `动作${action}`;
}
// Table Columns
const storeColumns = [
{ title: '门店名称', dataIndex: 'name', key: 'name' },
{ title: '营业执照号', dataIndex: 'licenseNumber', key: 'licenseNumber' },
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone' },
{ title: '地址', dataIndex: 'address', key: 'address' },
{ title: '状态', key: 'status' },
];
const staffColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '角色', key: 'roleType' },
{ title: '电话', dataIndex: 'phone', key: 'phone' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '状态', key: 'status' },
];
const contractColumns = [
{ title: '合同编号', dataIndex: 'contractNumber', key: 'contractNumber' },
{ title: '状态', key: 'status' },
{ title: '开始日期', key: 'startDate' },
{ title: '结束日期', key: 'endDate' },
{ title: '签署时间', key: 'signedAt' },
{ title: '操作', key: 'action' },
];
onMounted(loadData);
</script>
<template>
<div class="p-5">
<Profile
v-model:model-value="tabsValue"
:title="$t('page.merchant.center')"
:user-info="{
realName: userStore.userInfo?.realName || '',
avatar: userStore.userInfo?.avatar || '',
username: userStore.userInfo?.username || '',
}"
:tabs="tabs"
>
<template #content>
<div class="rounded-lg bg-card p-4">
<Spin :spinning="loading">
<MerchantSetting v-if="tabsValue === 'basic'" />
<Page title="商户中心">
<template #extra>
<Button type="primary" @click="isEditModalVisible = true">
编辑基本信息
</Button>
</template>
<template v-else-if="tabsValue === 'qualification'">
<div class="mb-3 text-base font-medium">资质证照</div>
<Empty
v-if="merchantDocuments.length === 0"
description="暂无资质证照"
/>
<List
v-else
:data-source="merchantDocuments"
item-layout="vertical"
<div v-if="loading" class="p-10 text-center">
<Spin size="large" />
</div>
<div v-else-if="!merchant" class="p-10 text-center">
<Empty description="暂无商户数据" />
</div>
<div v-else class="space-y-4">
<!-- 基本信息卡片 -->
<Card :bordered="false" title="基本信息">
<Descriptions
:column="{ xxl: 3, xl: 3, lg: 2, md: 2, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="商户名称">
<span class="text-lg font-bold">{{ merchant.name }}</span>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag :color="resolveMerchantStatus(merchant.status).color">
{{ resolveMerchantStatus(merchant.status).text }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="经营模式">
<Tag color="blue">
{{ resolveOperatingMode(merchant.operatingMode) }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="营业执照号">
{{ merchant.licenseNumber || '--' }}
</Descriptions.Item>
<Descriptions.Item label="法人/负责人">
{{ merchant.legalRepresentative || '--' }}
</Descriptions.Item>
<Descriptions.Item label="联系电话">
{{ merchant.contactPhone || '--' }}
</Descriptions.Item>
<Descriptions.Item label="联系邮箱">
{{ merchant.contactEmail || '--' }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDateTime(merchant.createdAt) }}
</Descriptions.Item>
<Descriptions.Item label="注册地址" :span="3">
{{ merchant.registeredAddress || '--' }}
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 详细信息 Tabs -->
<Card :bordered="false">
<Tabs v-model:active-key="activeTab">
<!-- 资质证照 -->
<TabPane key="documents" tab="资质证照">
<Empty v-if="documents.length === 0" description="暂无资质证照" />
<div
v-else
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
>
<Card
v-for="doc in documents"
:key="doc.id"
hoverable
class="overflow-hidden"
>
<template #renderItem="{ item }">
<List.Item>
<div class="mb-2 flex flex-wrap items-center gap-2">
<Tag color="blue">
{{ resolveDocumentType(item.documentType) }}
</Tag>
<Tag>{{ resolveDocumentStatus(item.status) }}</Tag>
<span class="text-gray-500">
编号{{ item.documentNumber || '--' }}
<template #cover>
<div class="flex h-48 items-center justify-center bg-gray-50">
<Image
v-if="doc.fileUrl"
:src="doc.fileUrl"
alt="证照预览"
class="max-h-full max-w-full object-contain"
height="100%"
/>
<div v-else class="text-gray-400">无预览图</div>
</div>
</template>
<Card.Meta :title="resolveDocumentType(doc.documentType)">
<template #description>
<div class="space-y-1 text-xs">
<div class="flex justify-between">
<span>状态:</span>
<Badge
:status="resolveDocumentStatus(doc.status).status"
:text="resolveDocumentStatus(doc.status).text"
/>
</div>
<div>证号: {{ doc.documentNumber || '--' }}</div>
<div>有效期: {{ formatDate(doc.expiresAt) }}</div>
</div>
</template>
</Card.Meta>
</Card>
</div>
</TabPane>
<!-- 门店信息 -->
<TabPane key="stores" tab="门店信息">
<Table
:columns="storeColumns"
:data-source="stores"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<Tag :color="resolveStoreStatus(record.status).color">
{{ resolveStoreStatus(record.status).text }}
</Tag>
</template>
</template>
</Table>
</TabPane>
<!-- 员工信息 -->
<TabPane key="staffs" tab="员工信息">
<Table
:columns="staffColumns"
:data-source="staffs"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'roleType'">
<Tag>{{ resolveStaffRole(record.roleType) }}</Tag>
</template>
<template v-if="column.key === 'status'">
<Tag :color="resolveStaffStatus(record.status).color">
{{ resolveStaffStatus(record.status).text }}
</Tag>
</template>
</template>
</Table>
</TabPane>
<!-- 合同信息 -->
<TabPane key="contracts" tab="合同信息">
<Table
:columns="contractColumns"
:data-source="contracts"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<Tag :color="resolveContractStatus(record.status).color">
{{ resolveContractStatus(record.status).text }}
</Tag>
</template>
<template v-if="column.key === 'startDate'">
{{ formatDate(record.startDate) }}
</template>
<template v-if="column.key === 'endDate'">
{{ formatDate(record.endDate) }}
</template>
<template v-if="column.key === 'signedAt'">
{{ formatDateTime(record.signedAt) }}
</template>
<template v-if="column.key === 'action'">
<Button
v-if="record.fileUrl"
type="link"
:href="record.fileUrl"
target="_blank"
>
下载
</Button>
</template>
</template>
</Table>
</TabPane>
<!-- 操作日志 -->
<TabPane key="logs" tab="操作日志">
<div class="flex gap-8">
<div class="flex-1">
<Alert class="mb-4" message="审核日志" type="info" />
<Empty
v-if="auditLogs.length === 0"
description="暂无审核日志"
/>
<Timeline v-else>
<Timeline.Item
v-for="log in auditLogs"
:key="log.id"
color="blue"
>
<div class="font-medium">
<span class="mr-2">
[{{ resolveAuditAction(log.action) }}]
</span>
{{ log.title }}
<span class="ml-2 text-xs text-gray-400">
{{ formatDateTime(log.createdAt) }}
</span>
</div>
<div class="text-gray-600">
签发时间{{ formatDateTime(item.issuedAt) }}
<div class="text-sm text-gray-600">
{{ log.description || '无描述' }}
</div>
<div class="text-gray-600">
到期时间{{ formatDateTime(item.expiresAt) }}
<div class="text-xs text-gray-500">
操作人: {{ log.operatorName || '--' }} (IP:
{{ log.ipAddress || '--' }})
</div>
<div class="text-gray-600">
文件地址
<a
:href="item.fileUrl"
class="text-blue-500"
target="_blank"
>
{{ item.fileUrl }}
</a>
</Timeline.Item>
</Timeline>
</div>
<div class="flex-1">
<Alert class="mb-4" message="变更日志" type="info" />
<Empty
v-if="changeLogs.length === 0"
description="暂无变更日志"
/>
<Timeline v-else>
<Timeline.Item
v-for="log in changeLogs"
:key="log.id"
color="orange"
>
<div class="font-medium">
变更字段: {{ log.fieldName }}
<span class="ml-2 text-xs text-gray-400">
{{ formatDateTime(log.changedAt) }}
</span>
</div>
<div class="text-gray-600">
备注{{ item.remarks || '--' }}
<div class="text-sm">
<span class="text-red-500 line-through">
{{ log.oldValue || '(空)' }}
</span>
<span class="mx-2">-></span>
<span class="text-green-500">
{{ log.newValue || '(空)' }}
</span>
</div>
</List.Item>
</template>
</List>
<div class="text-xs text-gray-500">
操作人: {{ log.changedByName || '--' }}
</div>
</Timeline.Item>
</Timeline>
</div>
</div>
</TabPane>
</Tabs>
</Card>
</div>
<div class="mb-3 mt-6 text-base font-medium">合同信息</div>
<Empty
v-if="merchantContracts.length === 0"
description="暂无合同"
/>
<List
v-else
:data-source="merchantContracts"
item-layout="vertical"
>
<template #renderItem="{ item }">
<List.Item>
<div class="mb-2 flex flex-wrap items-center gap-2">
<Tag color="purple">
合同号{{ item.contractNumber }}
</Tag>
<Tag>{{ resolveContractStatus(item.status) }}</Tag>
</div>
<div class="text-gray-600">
生效日期{{ formatDateTime(item.startDate) }}
</div>
<div class="text-gray-600">
截止日期{{ formatDateTime(item.endDate) }}
</div>
<div class="text-gray-600">
签署时间{{ formatDateTime(item.signedAt) }}
</div>
<div class="text-gray-600">
终止时间{{ formatDateTime(item.terminatedAt) }}
</div>
<div class="text-gray-600">
终止原因{{ item.terminationReason || '--' }}
</div>
</List.Item>
</template>
</List>
</template>
<template v-else-if="tabsValue === 'stores'">
<Empty
v-if="merchantStores.length === 0"
description="暂无门店信息"
/>
<List v-else :data-source="merchantStores" item-layout="vertical">
<template #renderItem="{ item }">
<List.Item>
<div class="mb-2 flex items-center gap-2">
<Tag color="green">{{ item.name }}</Tag>
<Tag>{{ resolveStoreStatus(item.status) }}</Tag>
</div>
<div class="text-gray-600">
门店地址{{ item.address }}
</div>
<div class="text-gray-600">
联系电话{{ item.contactPhone || '--' }}
</div>
<div class="text-gray-600">
营业执照号{{ item.licenseNumber || '--' }}
</div>
</List.Item>
</template>
</List>
</template>
<template v-else-if="tabsValue === 'staffs'">
<Empty
v-if="merchantStaffs.length === 0"
description="暂无员工信息"
/>
<List v-else :data-source="merchantStaffs" item-layout="vertical">
<template #renderItem="{ item }">
<List.Item>
<div class="mb-2 flex items-center gap-2">
<Tag color="cyan">{{ item.name }}</Tag>
<Tag>{{ resolveStaffRole(item.roleType) }}</Tag>
<Tag>{{ resolveStaffStatus(item.status) }}</Tag>
</div>
<div class="text-gray-600">联系电话{{ item.phone }}</div>
<div class="text-gray-600">
联系邮箱{{ item.email || '--' }}
</div>
<div class="text-gray-600">
所属门店ID{{ item.storeId || '--' }}
</div>
</List.Item>
</template>
</List>
</template>
<template v-else-if="tabsValue === 'logs'">
<Alert class="mb-4" message="审核日志" type="info" />
<Empty
v-if="merchantAuditLogs.length === 0"
description="暂无审核日志"
/>
<List
v-else
:data-source="merchantAuditLogs"
item-layout="vertical"
>
<template #renderItem="{ item }">
<List.Item>
<div class="mb-2 flex items-center gap-2">
<Tag color="orange">{{ item.title }}</Tag>
<Tag>动作{{ item.action }}</Tag>
</div>
<div class="text-gray-600">
操作人{{ item.operatorName || '--' }}
</div>
<div class="text-gray-600">
操作描述{{ item.description || '--' }}
</div>
<div class="text-gray-600">
操作IP{{ item.ipAddress || '--' }}
</div>
<div class="text-gray-600">
操作时间{{ formatDateTime(item.createdAt) }}
</div>
</List.Item>
</template>
</List>
<Alert class="mb-4 mt-6" message="变更日志" type="info" />
<Empty
v-if="merchantChangeLogs.length === 0"
description="暂无变更日志"
/>
<List
v-else
:data-source="merchantChangeLogs"
item-layout="vertical"
>
<template #renderItem="{ item }">
<List.Item>
<div class="mb-2 flex items-center gap-2">
<Tag color="gold">{{ item.fieldName }}</Tag>
<Tag>{{ item.changedByName || '--' }}</Tag>
</div>
<div class="text-gray-600">
旧值{{ item.oldValue || '--' }}
</div>
<div class="text-gray-600">
新值{{ item.newValue || '--' }}
</div>
<div class="text-gray-600">
变更原因{{ item.changeReason || '--' }}
</div>
<div class="text-gray-600">
变更时间{{ formatDateTime(item.changedAt) }}
</div>
</List.Item>
</template>
</List>
</template>
</Spin>
</div>
</template>
</Profile>
</div>
<!-- Edit Modal -->
<Modal
v-model:open="isEditModalVisible"
title="编辑商户信息"
:footer="null"
width="800px"
destroy-on-close
>
<MerchantSetting
:merchant="merchant ?? null"
@success="handleEditSuccess"
/>
</Modal>
</Page>
</template>
<style scoped lang="scss">
.bg-card {
background-color: var(--el-bg-color-overlay);
}
</style>

View File

@@ -1,23 +1,45 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import type { MerchantDetailDto, UpdateMerchantDto } from '#/api/merchant';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { watch } from 'vue';
import { message } from 'ant-design-vue';
import { getMerchantInfoApi, updateMerchantInfoApi } from '#/api/merchant';
import { useVbenForm } from '#/adapter/form';
import { updateMerchantInfoApi } from '#/api/merchant';
import { OperatingMode } from '#/enums/merchantEnum';
const profileBaseSettingRef = ref();
const props = defineProps<{
merchant: MerchantDetailDto | null;
}>();
const formSchema = computed((): VbenFormSchema[] => {
return [
const emit = defineEmits<{
(e: 'success'): void;
}>();
const [Form, formApi] = useVbenForm({
// Use a grid layout with 2 columns
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2',
commonConfig: {
// Label width
labelWidth: 100,
},
showDefaultActions: true,
submitButtonOptions: {
content: '保存更改',
},
resetButtonOptions: { show: false },
// The submit button is usually at the end. We might want it full width or placed differently.
// With grid, it might be in the last cell.
actionWrapperClass: 'col-span-1 md:col-span-2 text-right',
schema: [
{
fieldName: 'name',
component: 'Input',
label: '商户名称',
rules: 'required',
formItemClass: 'col-span-1',
},
{
fieldName: 'operatingMode',
@@ -25,61 +47,87 @@ const formSchema = computed((): VbenFormSchema[] => {
label: '经营模式',
componentProps: {
options: [
{ label: '直营', value: 1 },
{ label: '加盟', value: 2 },
{ label: '直营', value: OperatingMode.Direct },
{ label: '加盟', value: OperatingMode.Franchise },
],
},
rules: 'required',
formItemClass: 'col-span-1',
},
{
fieldName: 'licenseNumber',
component: 'Input',
label: '营业执照号',
formItemClass: 'col-span-1',
},
{
fieldName: 'legalRepresentative',
component: 'Input',
label: '法人/负责人',
formItemClass: 'col-span-1',
},
{
fieldName: 'contactPhone',
component: 'Input',
label: '联系电话',
formItemClass: 'col-span-1',
},
{
fieldName: 'contactEmail',
component: 'Input',
label: '联系邮箱',
formItemClass: 'col-span-1',
},
{
fieldName: 'registeredAddress',
component: 'Textarea',
label: '注册地址',
formItemClass: 'col-span-1 md:col-span-2',
componentProps: {
rows: 3,
},
},
];
],
handleSubmit: onSubmit,
});
async function handleSubmit(values: Record<string, unknown>) {
async function onSubmit(values: Record<string, any>) {
if (!props.merchant?.id) return;
try {
await updateMerchantInfoApi(values);
const payload: UpdateMerchantDto = {
id: props.merchant.id,
name: values.name,
operatingMode: values.operatingMode,
licenseNumber: values.licenseNumber,
legalRepresentative: values.legalRepresentative,
contactPhone: values.contactPhone,
contactEmail: values.contactEmail,
registeredAddress: values.registeredAddress,
};
await updateMerchantInfoApi(payload);
message.success('商户信息更新成功');
} catch {
emit('success');
} catch (error) {
console.error(error);
message.error('更新失败');
}
}
onMounted(async () => {
const data = await getMerchantInfoApi();
// 假设接口返回的是 CurrentMerchantCenterDto我们需要取其中的 merchant 对象
if (data && data.merchant) {
profileBaseSettingRef.value.getFormApi().setValues(data.merchant);
}
});
watch(
() => props.merchant,
(merchant) => {
if (merchant) {
formApi.setValues(merchant);
}
},
{ immediate: true },
);
</script>
<template>
<ProfileBaseSetting
ref="profileBaseSettingRef"
:form-schema="formSchema"
@submit="handleSubmit"
/>
<div class="p-4">
<Form />
</div>
</template>

View File

@@ -0,0 +1,557 @@
<script setup lang="ts">
import type { StoreListItemDto, StoreStatsDto } from '#/api/store';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Col,
Drawer,
Form,
FormItem,
Input,
InputSearch,
message,
Popconfirm,
Row,
Select,
SelectOption,
Statistic,
Table,
Tag,
} from 'ant-design-vue';
import {
createStoreApi,
deleteStoreApi,
getStoreListApi,
getStoreStatsApi,
ServiceType,
StoreAuditStatus,
StoreBusinessStatus,
updateStoreApi,
} from '#/api/store';
/** 表格分页变更参数 */
interface TablePagination {
current?: number;
pageSize?: number;
}
// 语义色值常量(后续可迁移至全局主题配置)
const THEME_COLORS = {
text: '#1f1f1f',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
} as const;
// 头像装饰色板
const AVATAR_COLORS = [
'#3b82f6',
'#f59e0b',
'#8b5cf6',
'#ef4444',
'#22c55e',
'#06b6d4',
'#ec4899',
'#f97316',
'#14b8a6',
'#6366f1',
] as const;
// ========== 1. 列表状态 ==========
const isLoading = ref(false);
const storeList = ref<StoreListItemDto[]>([]);
const stats = ref<StoreStatsDto>({
total: 0,
operating: 0,
resting: 0,
pendingAudit: 0,
});
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
const filters = reactive({
keyword: '',
businessStatus: undefined as StoreBusinessStatus | undefined,
auditStatus: undefined as StoreAuditStatus | undefined,
serviceType: undefined as ServiceType | undefined,
});
// ========== 2. 抽屉状态 ==========
const isDrawerVisible = ref(false);
const drawerMode = ref<'create' | 'edit'>('create');
const drawerTitle = computed(() =>
drawerMode.value === 'edit' ? '编辑门店' : '添加门店',
);
const formState = reactive({
id: '',
name: '',
code: '',
contactPhone: '',
managerName: '',
address: '',
coverImage: '',
businessStatus: StoreBusinessStatus.Operating as StoreBusinessStatus,
serviceTypes: [ServiceType.Delivery] as ServiceType[],
});
const isSubmitting = ref(false);
// ========== 3. 枚举映射 ==========
const businessStatusMap: Record<number, { color: string; text: string }> = {
[StoreBusinessStatus.Operating]: { color: 'green', text: '营业中' },
[StoreBusinessStatus.Resting]: { color: 'default', text: '休息中' },
[StoreBusinessStatus.ForceClosed]: { color: 'red', text: '强制关闭' },
};
const auditStatusMap: Record<number, { color: string; text: string }> = {
[StoreAuditStatus.Pending]: { color: 'orange', text: '待审核' },
[StoreAuditStatus.Approved]: { color: 'green', text: '已通过' },
[StoreAuditStatus.Rejected]: { color: 'red', text: '已拒绝' },
};
const serviceTypeMap: Record<number, { color: string; text: string }> = {
[ServiceType.Delivery]: { color: 'blue', text: '外卖' },
[ServiceType.Pickup]: { color: 'green', text: '自提' },
[ServiceType.DineIn]: { color: 'orange', text: '堂食' },
};
const businessStatusOptions = [
{ label: '营业中', value: StoreBusinessStatus.Operating },
{ label: '休息中', value: StoreBusinessStatus.Resting },
{ label: '强制关闭', value: StoreBusinessStatus.ForceClosed },
];
const auditStatusOptions = [
{ label: '待审核', value: StoreAuditStatus.Pending },
{ label: '已通过', value: StoreAuditStatus.Approved },
{ label: '已拒绝', value: StoreAuditStatus.Rejected },
];
const serviceTypeOptions = [
{ label: '外卖配送', value: ServiceType.Delivery },
{ label: '到店自提', value: ServiceType.Pickup },
{ label: '堂食', value: ServiceType.DineIn },
];
function getAvatarColor(index: number) {
return AVATAR_COLORS[index % AVATAR_COLORS.length];
}
// ========== 4. 表格列定义 ==========
const columns = [
{ title: '门店信息', key: 'storeInfo', width: 240 },
{
title: '联系电话',
dataIndex: 'contactPhone',
key: 'contactPhone',
width: 120,
},
{ title: '店长', dataIndex: 'managerName', key: 'managerName', width: 80 },
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
{ title: '服务方式', key: 'serviceTypes', width: 180 },
{ title: '营业状态', key: 'businessStatus', width: 100 },
{ title: '审核状态', key: 'auditStatus', width: 100 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 120 },
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const },
];
// ========== 5. 数据加载 ==========
async function loadList() {
// 1. 开启加载态
isLoading.value = true;
try {
// 2. 请求门店列表
const res = await getStoreListApi({
keyword: filters.keyword || undefined,
businessStatus: filters.businessStatus,
auditStatus: filters.auditStatus,
serviceType: filters.serviceType,
page: pagination.current,
pageSize: pagination.pageSize,
});
// 3. 更新列表与分页
storeList.value = res.items;
pagination.total = res.total;
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
}
async function loadStats() {
try {
stats.value = await getStoreStatsApi();
} catch (error) {
console.error(error);
}
}
function handleSearch() {
// 1. 重置到第一页
pagination.current = 1;
// 2. 重新加载
loadList();
}
function handleReset() {
// 1. 清空筛选条件
filters.keyword = '';
filters.businessStatus = undefined;
filters.auditStatus = undefined;
filters.serviceType = undefined;
// 2. 重置分页并加载
pagination.current = 1;
loadList();
}
function handleTableChange(pag: TablePagination) {
// 1. 同步分页参数
pagination.current = pag.current ?? 1;
pagination.pageSize = pag.pageSize ?? 10;
// 2. 重新加载
loadList();
}
// ========== 6. 抽屉操作 ==========
function openDrawer(mode: 'create' | 'edit', record?: unknown) {
drawerMode.value = mode;
if (mode === 'edit' && record) {
// 1. 编辑模式:回填表单数据
const store = record as StoreListItemDto;
Object.assign(formState, {
id: store.id,
name: store.name,
code: store.code,
contactPhone: store.contactPhone,
managerName: store.managerName,
address: store.address,
coverImage: store.coverImage || '',
businessStatus: store.businessStatus,
serviceTypes: [...store.serviceTypes],
});
} else {
// 2. 新增模式:重置表单
Object.assign(formState, {
id: '',
name: '',
code: '',
contactPhone: '',
managerName: '',
address: '',
coverImage: '',
businessStatus: StoreBusinessStatus.Operating,
serviceTypes: [ServiceType.Delivery],
});
}
// 3. 打开抽屉
isDrawerVisible.value = true;
}
async function handleSubmit() {
// 1. 开启提交加载态
isSubmitting.value = true;
try {
// 2. 根据模式调用对应接口
const data = { ...formState };
if (drawerMode.value === 'edit') {
await updateStoreApi(data);
message.success('更新成功');
} else {
await createStoreApi(data);
message.success('创建成功');
}
// 3. 关闭抽屉并刷新数据
isDrawerVisible.value = false;
loadList();
loadStats();
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
// ========== 7. 删除操作 ==========
async function handleDelete(record: unknown) {
try {
// 1. 调用删除接口
const store = record as StoreListItemDto;
await deleteStoreApi(store.id);
message.success('删除成功');
// 2. 刷新列表与统计
loadList();
loadStats();
} catch (error) {
console.error(error);
}
}
// ========== 8. 统计卡片配置 ==========
const statCards = computed(() => [
{ title: '门店总数', value: stats.value.total, color: THEME_COLORS.text },
{
title: '营业中',
value: stats.value.operating,
color: THEME_COLORS.success,
},
{ title: '休息中', value: stats.value.resting, color: THEME_COLORS.warning },
{
title: '待审核',
value: stats.value.pendingAudit,
color: THEME_COLORS.danger,
},
]);
onMounted(() => {
loadList();
loadStats();
});
</script>
<template>
<Page title="门店列表" content-class="space-y-4">
<template #extra>
<div class="flex gap-2">
<Button @click="() => message.info('导出功能开发中')">导出</Button>
<Button type="primary" @click="openDrawer('create')">新增门店</Button>
</div>
</template>
<!-- 筛选栏 -->
<Card :bordered="false" size="small">
<div class="flex flex-wrap items-center gap-3">
<InputSearch
v-model:value="filters.keyword"
placeholder="搜索门店名称 / 编号 / 电话"
style="width: 260px"
allow-clear
@search="handleSearch"
/>
<Select
v-model:value="filters.businessStatus"
placeholder="营业状态"
style="width: 130px"
allow-clear
:options="businessStatusOptions"
/>
<Select
v-model:value="filters.auditStatus"
placeholder="审核状态"
style="width: 130px"
allow-clear
:options="auditStatusOptions"
/>
<Select
v-model:value="filters.serviceType"
placeholder="服务方式"
style="width: 130px"
allow-clear
:options="serviceTypeOptions"
/>
<Button type="primary" @click="handleSearch">查询</Button>
<Button @click="handleReset">重置</Button>
</div>
</Card>
<!-- 统计卡片 -->
<Row :gutter="16">
<Col v-for="item in statCards" :key="item.title" :span="6">
<Card :bordered="false" hoverable>
<Statistic
:title="item.title"
:value="item.value"
:value-style="{ color: item.color, fontWeight: 700 }"
/>
</Card>
</Col>
</Row>
<!-- 表格 -->
<Card :bordered="false">
<Table
:columns="columns"
:data-source="storeList"
:loading="isLoading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
row-key="id"
:scroll="{ x: 1200 }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record, index }">
<!-- 门店信息 -->
<template v-if="column.key === 'storeInfo'">
<div class="flex items-center gap-3">
<div
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg text-white"
:style="{ background: getAvatarColor(index) }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"
/>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4" />
<path d="M2 7h20" />
<path
d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7"
/>
</svg>
</div>
<div>
<div class="font-medium">{{ record.name }}</div>
<div class="text-xs text-gray-400">{{ record.code }}</div>
</div>
</div>
</template>
<!-- 服务方式 -->
<template v-if="column.key === 'serviceTypes'">
<Tag
v-for="st in record.serviceTypes"
:key="st"
:color="serviceTypeMap[st]?.color"
>
{{ serviceTypeMap[st]?.text }}
</Tag>
</template>
<!-- 营业状态 -->
<template v-if="column.key === 'businessStatus'">
<Tag :color="businessStatusMap[record.businessStatus]?.color">
{{ businessStatusMap[record.businessStatus]?.text }}
</Tag>
</template>
<!-- 审核状态 -->
<template v-if="column.key === 'auditStatus'">
<Tag :color="auditStatusMap[record.auditStatus]?.color">
{{ auditStatusMap[record.auditStatus]?.text }}
</Tag>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<div class="flex gap-2">
<Button
type="link"
size="small"
@click="openDrawer('edit', record)"
>
编辑
</Button>
<Popconfirm
title="确定要删除该门店吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<Button type="link" size="small" danger>删除</Button>
</Popconfirm>
</div>
</template>
</template>
</Table>
</Card>
<!-- 新增/编辑抽屉 -->
<Drawer
v-model:open="isDrawerVisible"
:title="drawerTitle"
:width="520"
:body-style="{ paddingBottom: '80px' }"
>
<Form layout="vertical">
<div
class="mb-4 border-l-[3px] border-blue-500 pl-2.5 text-[15px] font-semibold"
>
基本信息
</div>
<FormItem label="门店名称" required>
<Input v-model:value="formState.name" placeholder="请输入门店名称" />
</FormItem>
<FormItem label="门店编码" required>
<Input v-model:value="formState.code" placeholder="如ST20250006" />
</FormItem>
<FormItem label="联系电话" required>
<Input
v-model:value="formState.contactPhone"
placeholder="请输入联系电话"
/>
</FormItem>
<FormItem label="负责人" required>
<Input
v-model:value="formState.managerName"
placeholder="请输入负责人姓名"
/>
</FormItem>
<FormItem label="门店地址" required>
<Input
v-model:value="formState.address"
placeholder="请输入详细地址"
/>
</FormItem>
<div
class="mb-4 mt-6 border-l-[3px] border-blue-500 pl-2.5 text-[15px] font-semibold"
>
营业设置
</div>
<FormItem label="营业状态">
<Select v-model:value="formState.businessStatus">
<SelectOption
v-for="opt in businessStatusOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectOption>
</Select>
</FormItem>
<FormItem label="服务方式">
<Select
v-model:value="formState.serviceTypes"
mode="multiple"
placeholder="请选择服务方式"
:options="serviceTypeOptions"
/>
</FormItem>
</Form>
<template #footer>
<div class="flex justify-end gap-2">
<Button @click="isDrawerVisible = false">取消</Button>
<Button type="primary" :loading="isSubmitting" @click="handleSubmit">
确认
</Button>
</div>
</template>
</Drawer>
</Page>
</template>