chore: 初始化平台管理端
This commit is contained in:
227
src/views/store/store-detail/components/BusinessHoursPanel.vue
Normal file
227
src/views/store/store-detail/components/BusinessHoursPanel.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-gray-500">{{ t('store.businessHours.tip') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton @click="loadHours">{{ t('store.action.refresh') }}</ElButton>
|
||||
<ElButton type="primary" @click="handleAdd">{{
|
||||
t('store.businessHours.action.add')
|
||||
}}</ElButton>
|
||||
<ElButton type="success" :loading="saving" @click="handleSave">
|
||||
{{ t('store.businessHours.action.save') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTable :data="hours" border stripe v-loading="loading" class="w-full">
|
||||
<ElTableColumn
|
||||
:label="t('store.businessHours.table.dayOfWeek')"
|
||||
min-width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElSelect v-model="row.dayOfWeek" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in dayOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="t('store.businessHours.table.hourType')"
|
||||
min-width="120"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElSelect v-model="row.hourType" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in hourTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="t('store.businessHours.table.startTime')"
|
||||
min-width="120"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElTimePicker
|
||||
v-model="row.startTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm:ss"
|
||||
:placeholder="t('common.startTime')"
|
||||
class="!w-full"
|
||||
:clearable="false"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.businessHours.table.endTime')" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTimePicker
|
||||
v-model="row.endTime"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm:ss"
|
||||
:placeholder="t('common.endTime')"
|
||||
class="!w-full"
|
||||
:clearable="false"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="t('store.businessHours.table.capacityLimit')"
|
||||
min-width="120"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.capacityLimit"
|
||||
class="!w-full"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
:placeholder="t('common.unlimited')"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.businessHours.table.notes')" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<ElInput
|
||||
v-model="row.notes"
|
||||
:placeholder="t('store.businessHours.table.notesPlaceholder')"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('common.action')" width="80" fixed="right" align="center">
|
||||
<template #default="{ $index }">
|
||||
<ElButton link type="danger" @click="handleRemove($index)">
|
||||
{{ t('store.action.delete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElButton,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTimePicker
|
||||
} from 'element-plus'
|
||||
import { fetchBatchUpdateBusinessHours, fetchStoreBusinessHours } from '@/api/store'
|
||||
import { BusinessHourType } from '@/enums/BusinessHourType'
|
||||
|
||||
defineOptions({ name: 'BusinessHoursPanel' })
|
||||
|
||||
interface BusinessHourFormItem {
|
||||
dayOfWeek: number
|
||||
hourType: BusinessHourType
|
||||
startTime: string
|
||||
endTime: string
|
||||
capacityLimit?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// 1. 基础输入
|
||||
const props = defineProps<{ storeId: string }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 2. 时段数据
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const hours = ref<BusinessHourFormItem[]>([])
|
||||
|
||||
// 3. 选项配置
|
||||
const dayOptions = [
|
||||
{ value: 0, label: t('store.businessHours.days.sunday') },
|
||||
{ value: 1, label: t('store.businessHours.days.monday') },
|
||||
{ value: 2, label: t('store.businessHours.days.tuesday') },
|
||||
{ value: 3, label: t('store.businessHours.days.wednesday') },
|
||||
{ value: 4, label: t('store.businessHours.days.thursday') },
|
||||
{ value: 5, label: t('store.businessHours.days.friday') },
|
||||
{ value: 6, label: t('store.businessHours.days.saturday') }
|
||||
]
|
||||
const hourTypeOptions = [
|
||||
{ value: BusinessHourType.Normal, label: t('store.businessHours.hourType.normal') },
|
||||
{
|
||||
value: BusinessHourType.ReservationOnly,
|
||||
label: t('store.businessHours.hourType.reservation')
|
||||
},
|
||||
{ value: BusinessHourType.PickupOrDelivery, label: t('store.businessHours.hourType.pickup') },
|
||||
{ value: BusinessHourType.Closed, label: t('store.businessHours.hourType.closed') }
|
||||
]
|
||||
|
||||
// 4. 数据加载
|
||||
const loadHours = async () => {
|
||||
if (!props.storeId) {
|
||||
hours.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await fetchStoreBusinessHours(props.storeId)
|
||||
hours.value = list.map((item) => ({
|
||||
dayOfWeek: item.dayOfWeek,
|
||||
hourType: item.hourType,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
capacityLimit: item.capacityLimit ?? undefined,
|
||||
notes: item.notes ?? ''
|
||||
}))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.storeId,
|
||||
() => {
|
||||
loadHours()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 5. 交互处理
|
||||
const handleAdd = () => {
|
||||
hours.value.push({
|
||||
dayOfWeek: 1,
|
||||
hourType: BusinessHourType.Normal,
|
||||
startTime: '09:00:00',
|
||||
endTime: '18:00:00',
|
||||
capacityLimit: undefined,
|
||||
notes: ''
|
||||
})
|
||||
}
|
||||
const handleRemove = (index: number) => {
|
||||
hours.value.splice(index, 1)
|
||||
}
|
||||
const handleSave = async () => {
|
||||
if (!props.storeId) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await fetchBatchUpdateBusinessHours(props.storeId, { items: hours.value })
|
||||
ElMessage.success(t('store.message.updateSuccess'))
|
||||
loadHours()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-gray-500">{{ t('store.deliveryZone.tip') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton @click="loadZones">{{ t('store.action.refresh') }}</ElButton>
|
||||
<ElButton type="primary" @click="handleCreate">
|
||||
{{ t('store.deliveryZone.action.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTable :data="zones" border stripe v-loading="loading">
|
||||
<ElTableColumn
|
||||
:label="t('store.deliveryZone.table.zoneName')"
|
||||
min-width="160"
|
||||
prop="zoneName"
|
||||
/>
|
||||
<ElTableColumn :label="t('store.deliveryZone.table.minimumOrderAmount')" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.minimumOrderAmount ?? '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.deliveryZone.table.deliveryFee')" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.deliveryFee ?? '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.deliveryZone.table.estimatedMinutes')" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.estimatedMinutes ?? '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('common.action')" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ElButton link type="primary" @click="handleEdit(row)">
|
||||
{{ t('store.action.edit') }}
|
||||
</ElButton>
|
||||
<ElButton link type="danger" @click="handleDelete(row)">
|
||||
{{ t('store.action.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ t('store.deliveryZone.check.title') }}</span>
|
||||
<ElButton type="primary" :loading="checking" @click="handleCheck">
|
||||
{{ t('store.deliveryZone.check.action') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<ElForm :model="checkForm" inline>
|
||||
<ElFormItem :label="t('store.deliveryZone.check.longitude')">
|
||||
<ElInputNumber v-model="checkForm.longitude" :step="0.000001" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.deliveryZone.check.latitude')">
|
||||
<ElInputNumber v-model="checkForm.latitude" :step="0.000001" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<ElAlert
|
||||
v-if="checkResult"
|
||||
:type="checkResult.inRange ? 'success' : 'warning'"
|
||||
:title="
|
||||
checkResult.inRange
|
||||
? t('store.deliveryZone.check.inRange')
|
||||
: t('store.deliveryZone.check.outOfRange')
|
||||
"
|
||||
show-icon
|
||||
class="mt-4"
|
||||
>
|
||||
<template #default>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span
|
||||
>{{ t('store.deliveryZone.check.distance') }}: {{ checkResult.distance ?? '-' }}</span
|
||||
>
|
||||
<span class="ml-4">
|
||||
{{ t('store.deliveryZone.check.zoneName') }}:
|
||||
{{ checkResult.deliveryZoneName || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElAlert>
|
||||
</ElCard>
|
||||
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="
|
||||
editingZone ? t('store.deliveryZone.action.edit') : t('store.deliveryZone.action.add')
|
||||
"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
class="delivery-zone-dialog"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Section: Basic Info -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||
<div class="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
{{ t('store.deliveryZone.form.basicInfo') }}
|
||||
</div>
|
||||
<ElFormItem :label="t('store.deliveryZone.form.zoneName')" prop="zoneName" class="mb-0">
|
||||
<ElInput
|
||||
v-model="formModel.zoneName"
|
||||
:placeholder="t('store.deliveryZone.form.zoneNamePlaceholder')"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon class="text-gray-400"><Location /></el-icon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<!-- Section: Delivery Settings (Grid) -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||
<div class="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
|
||||
<el-icon><Money /></el-icon>
|
||||
{{ t('store.deliveryZone.form.costSettings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<ElFormItem
|
||||
:label="t('store.deliveryZone.form.minimumOrderAmount')"
|
||||
prop="minimumOrderAmount"
|
||||
>
|
||||
<ElInputNumber
|
||||
v-model="formModel.minimumOrderAmount"
|
||||
class="!w-full"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>¥</template>
|
||||
</ElInputNumber>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="t('store.deliveryZone.form.deliveryFee')" prop="deliveryFee">
|
||||
<ElInputNumber
|
||||
v-model="formModel.deliveryFee"
|
||||
class="!w-full"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
size="large"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
:label="t('store.deliveryZone.form.estimatedMinutes')"
|
||||
prop="estimatedMinutes"
|
||||
>
|
||||
<ElInputNumber
|
||||
v-model="formModel.estimatedMinutes"
|
||||
class="!w-full"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
size="large"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="t('store.deliveryZone.form.sortOrder')" prop="sortOrder">
|
||||
<ElInputNumber
|
||||
v-model="formModel.sortOrder"
|
||||
class="!w-full"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
size="large"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Map Area -->
|
||||
<div class="bg-blue-50/50 p-4 rounded-lg border border-blue-100">
|
||||
<ElFormItem
|
||||
:label="t('store.deliveryZone.form.polygonGeoJson')"
|
||||
prop="polygonGeoJson"
|
||||
class="mb-0 !border-0"
|
||||
>
|
||||
<!-- Hidden Input for Validation -->
|
||||
<ElInput v-model="formModel.polygonGeoJson" class="hidden" />
|
||||
|
||||
<!-- Visual Map Card -->
|
||||
<div
|
||||
class="w-full flex items-center justify-between bg-white px-4 py-3 rounded border border-blue-200 shadow-sm cursor-pointer hover:border-blue-300 hover:shadow transition-all group"
|
||||
@click="polygonDialogVisible = true"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
:class="
|
||||
formModel.polygonGeoJson
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-gray-100 text-gray-400'
|
||||
"
|
||||
>
|
||||
<el-icon :size="20"><MapLocation /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{
|
||||
formModel.polygonGeoJson
|
||||
? t('store.deliveryZone.status.configured')
|
||||
: t('store.deliveryZone.status.notConfigured')
|
||||
}}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
{{
|
||||
formModel.polygonGeoJson
|
||||
? t('store.deliveryZone.form.clickToEdit')
|
||||
: t('store.deliveryZone.form.clickToDraw')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton type="primary" link class="group-hover:translate-x-1 transition-transform">
|
||||
{{
|
||||
formModel.polygonGeoJson
|
||||
? t('store.action.edit')
|
||||
: t('store.deliveryZone.action.setup')
|
||||
}}
|
||||
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-2 px-1">
|
||||
{{ t('store.deliveryZone.form.drawHint') }}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-gray-400">
|
||||
<!-- Optional left-side footer content -->
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ElButton size="large" @click="dialogVisible = false">{{
|
||||
t('common.cancel')
|
||||
}}</ElButton>
|
||||
<ElButton type="primary" :loading="saving" size="large" @click="handleSubmit">
|
||||
{{ t('common.confirm') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<DeliveryZonePolygonDialog
|
||||
v-model="polygonDialogVisible"
|
||||
:geo-json="formModel.polygonGeoJson"
|
||||
:store-location="storeLocation"
|
||||
@apply="handlePolygonApply"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElTable,
|
||||
ElTableColumn
|
||||
} from 'element-plus'
|
||||
import DeliveryZonePolygonDialog from './DeliveryZonePolygonDialog.vue'
|
||||
import {
|
||||
fetchCreateStoreDeliveryZone,
|
||||
fetchDeleteStoreDeliveryZone,
|
||||
fetchDeliveryZoneCheck,
|
||||
fetchStoreDeliveryZones,
|
||||
fetchUpdateStoreDeliveryZone
|
||||
} from '@/api/store'
|
||||
import { InfoFilled, Location, Money, MapLocation, ArrowRight } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'DeliveryZoneMapEditor' })
|
||||
|
||||
interface DeliveryZoneFormState {
|
||||
zoneName: string
|
||||
polygonGeoJson: string
|
||||
minimumOrderAmount?: number
|
||||
deliveryFee?: number
|
||||
estimatedMinutes?: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
// 1. 基础输入
|
||||
const props = defineProps<{
|
||||
storeId: string
|
||||
store?: Api.Store.StoreDto | null
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const createDefaultForm = (): DeliveryZoneFormState => ({
|
||||
zoneName: '',
|
||||
polygonGeoJson: '',
|
||||
minimumOrderAmount: undefined,
|
||||
deliveryFee: undefined,
|
||||
estimatedMinutes: undefined,
|
||||
sortOrder: 100
|
||||
})
|
||||
|
||||
// 2. 区域数据
|
||||
const loading = ref(false)
|
||||
const zones = ref<Api.Store.StoreDeliveryZoneDto[]>([])
|
||||
|
||||
// 3. 检测数据
|
||||
const checking = ref(false)
|
||||
const checkForm = ref<{ longitude?: number; latitude?: number }>({
|
||||
longitude: undefined,
|
||||
latitude: undefined
|
||||
})
|
||||
const checkResult = ref<Api.Store.StoreDeliveryCheckResultDto | null>(null)
|
||||
|
||||
// 4. 表单状态
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const polygonDialogVisible = ref(false)
|
||||
const editingZone = ref<Api.Store.StoreDeliveryZoneDto | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formModel = ref<DeliveryZoneFormState>(createDefaultForm())
|
||||
const storeLocation = computed(() => ({
|
||||
longitude: props.store?.longitude ?? undefined,
|
||||
latitude: props.store?.latitude ?? undefined
|
||||
}))
|
||||
|
||||
// 5. 校验规则
|
||||
const formRules: FormRules<DeliveryZoneFormState> = {
|
||||
zoneName: [
|
||||
{ required: true, message: t('store.deliveryZone.rules.zoneName'), trigger: 'blur' }
|
||||
],
|
||||
polygonGeoJson: [
|
||||
{ required: true, message: t('store.deliveryZone.rules.polygonGeoJson'), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 6. 数据加载
|
||||
const loadZones = async () => {
|
||||
if (!props.storeId) {
|
||||
zones.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
zones.value = await fetchStoreDeliveryZones(props.storeId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.storeId,
|
||||
() => {
|
||||
loadZones()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (!visible) {
|
||||
polygonDialogVisible.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 7. 交互处理
|
||||
const handleCreate = () => {
|
||||
editingZone.value = null
|
||||
formModel.value = createDefaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const handleEdit = (row: Api.Store.StoreDeliveryZoneDto) => {
|
||||
editingZone.value = row
|
||||
formModel.value = {
|
||||
zoneName: row.zoneName,
|
||||
polygonGeoJson: row.polygonGeoJson,
|
||||
minimumOrderAmount: row.minimumOrderAmount ?? undefined,
|
||||
deliveryFee: row.deliveryFee ?? undefined,
|
||||
estimatedMinutes: row.estimatedMinutes ?? undefined,
|
||||
sortOrder: row.sortOrder
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const handleDelete = async (row: Api.Store.StoreDeliveryZoneDto) => {
|
||||
// 1. 二次确认
|
||||
await ElMessageBox.confirm(
|
||||
t('store.deliveryZone.deleteConfirm'),
|
||||
t('store.deliveryZone.deleteTitle'),
|
||||
{
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
// 2. 执行删除
|
||||
await fetchDeleteStoreDeliveryZone(props.storeId, row.id)
|
||||
ElMessage.success(t('store.message.deleteSuccess'))
|
||||
loadZones()
|
||||
}
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 校验表单
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 保存区域
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingZone.value) {
|
||||
await fetchUpdateStoreDeliveryZone(props.storeId, editingZone.value.id, {
|
||||
zoneName: formModel.value.zoneName.trim(),
|
||||
polygonGeoJson: formModel.value.polygonGeoJson.trim(),
|
||||
minimumOrderAmount: formModel.value.minimumOrderAmount,
|
||||
deliveryFee: formModel.value.deliveryFee,
|
||||
estimatedMinutes: formModel.value.estimatedMinutes,
|
||||
sortOrder: formModel.value.sortOrder
|
||||
})
|
||||
ElMessage.success(t('store.message.updateSuccess'))
|
||||
} else {
|
||||
await fetchCreateStoreDeliveryZone(props.storeId, {
|
||||
zoneName: formModel.value.zoneName.trim(),
|
||||
polygonGeoJson: formModel.value.polygonGeoJson.trim(),
|
||||
minimumOrderAmount: formModel.value.minimumOrderAmount,
|
||||
deliveryFee: formModel.value.deliveryFee,
|
||||
estimatedMinutes: formModel.value.estimatedMinutes,
|
||||
sortOrder: formModel.value.sortOrder
|
||||
})
|
||||
ElMessage.success(t('store.message.createSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadZones()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePolygonApply = (geoJson: string) => {
|
||||
formModel.value.polygonGeoJson = geoJson
|
||||
}
|
||||
const handleCheck = async () => {
|
||||
if (
|
||||
!props.storeId ||
|
||||
checkForm.value.longitude === undefined ||
|
||||
checkForm.value.latitude === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 执行检测
|
||||
checking.value = true
|
||||
try {
|
||||
checkResult.value = await fetchDeliveryZoneCheck(props.storeId, {
|
||||
longitude: checkForm.value.longitude,
|
||||
latitude: checkForm.value.latitude
|
||||
})
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="t('store.deliveryZone.form.drawPolygonTitle')"
|
||||
width="70vw"
|
||||
top="5vh"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
class="delivery-zone-polygon-dialog"
|
||||
@opened="handleOpened"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<ElAlert
|
||||
v-if="!apiKey"
|
||||
type="warning"
|
||||
:title="t('store.deliveryZone.form.mapKeyMissing')"
|
||||
show-icon
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
class="toolbar flex flex-wrap items-center justify-between gap-2 p-2 bg-gray-50 rounded border border-gray-200"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton type="primary" plain size="small" :disabled="!canDraw" @click="setMode('draw')">
|
||||
{{ t('store.deliveryZone.form.drawPolygon') }}
|
||||
</ElButton>
|
||||
<ElButton plain size="small" :disabled="!canEdit" @click="setMode('edit')">
|
||||
{{ t('store.deliveryZone.form.editPolygon') }}
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain size="small" :disabled="!hasGeometry" @click="handleClear">
|
||||
{{ t('store.deliveryZone.form.clearPolygon') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span v-if="currentMode === 'draw'"
|
||||
>{{ t('common.tips') }}: {{ t('store.deliveryZone.form.drawHint') }}</span
|
||||
>
|
||||
<span v-else-if="currentMode === 'edit'"
|
||||
>{{ t('common.tips') }}:
|
||||
{{ t('store.deliveryZone.form.editHint', 'Drag points to adjust area') }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="map-shell mt-2">
|
||||
<div v-if="apiKey" :id="mapContainerId" class="map-canvas"></div>
|
||||
<div v-else class="map-empty">
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ t('store.deliveryZone.form.mapKeyMissingHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ElButton @click="dialogVisible = false">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton type="primary" @click="handleApply">
|
||||
{{ t('common.confirm') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElAlert, ElButton, ElDialog, ElMessage } from 'element-plus'
|
||||
import { loadTencentMapScript } from '@/utils/tencent-map'
|
||||
|
||||
defineOptions({ name: 'DeliveryZonePolygonDialog' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
geoJson?: string
|
||||
storeLocation?: {
|
||||
longitude?: number | null
|
||||
latitude?: number | null
|
||||
}
|
||||
}>(),
|
||||
{
|
||||
geoJson: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'apply', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Config
|
||||
const apiKey = import.meta.env.VITE_TENCENT_MAP_KEY as string
|
||||
const mapContainerId = `dz-map-${Math.random().toString(36).slice(2, 8)}`
|
||||
const DEFAULT_CENTER = { lat: 39.909187, lng: 116.397451 } // Beijing
|
||||
|
||||
// Refs
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// Shallow refs for Map instances (Performance)
|
||||
const mapInstance = shallowRef<any>(null)
|
||||
const overlayInstance = shallowRef<any>(null)
|
||||
const editorInstance = shallowRef<any>(null)
|
||||
const tmapNamespace = shallowRef<any>(null)
|
||||
|
||||
// State
|
||||
const currentMode = ref<'draw' | 'edit' | 'none'>('none')
|
||||
const hasGeometry = ref(false)
|
||||
|
||||
const canDraw = computed(() => !hasGeometry.value && apiKey)
|
||||
const canEdit = computed(() => hasGeometry.value && apiKey)
|
||||
|
||||
// --- Map Logic ---
|
||||
|
||||
const initMap = async () => {
|
||||
if (!apiKey) return
|
||||
|
||||
try {
|
||||
await loadTencentMapScript(apiKey)
|
||||
const TMap = (window as any).TMap
|
||||
tmapNamespace.value = TMap
|
||||
|
||||
// 1. Init Map
|
||||
const centerLatLng = getInitialCenter(TMap)
|
||||
mapInstance.value = new TMap.Map(document.getElementById(mapContainerId), {
|
||||
center: centerLatLng,
|
||||
zoom: 14,
|
||||
pitch: 0,
|
||||
rotation: 0
|
||||
})
|
||||
|
||||
// 2. Init MultiPolygon Overlay
|
||||
overlayInstance.value = new TMap.MultiPolygon({
|
||||
map: mapInstance.value,
|
||||
styles: {
|
||||
// Highlight style
|
||||
highlight: new TMap.PolygonStyle({
|
||||
color: 'rgba(59, 130, 246, 0.2)',
|
||||
borderColor: 'rgba(37, 99, 235, 1)',
|
||||
borderWidth: 2
|
||||
})
|
||||
},
|
||||
geometries: [] // Start empty, fill later
|
||||
})
|
||||
|
||||
// 3. Init GeometryEditor
|
||||
initEditor()
|
||||
|
||||
// 4. Load Initial Data
|
||||
loadInitialGeometry()
|
||||
|
||||
// 5. Add Store Marker
|
||||
addStoreMarker(TMap)
|
||||
} catch (err) {
|
||||
console.error('Map Init Failed:', err)
|
||||
ElMessage.error(t('common.error.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const addStoreMarker = (TMap: any) => {
|
||||
if (!props.storeLocation?.latitude || !props.storeLocation?.longitude) return
|
||||
|
||||
new TMap.MultiMarker({
|
||||
map: mapInstance.value,
|
||||
styles: {
|
||||
store: new TMap.MarkerStyle({
|
||||
width: 25,
|
||||
height: 35,
|
||||
anchor: { x: 12, y: 35 }
|
||||
})
|
||||
},
|
||||
geometries: [
|
||||
{
|
||||
id: 'store-location',
|
||||
styleId: 'store',
|
||||
position: new TMap.LatLng(props.storeLocation.latitude, props.storeLocation.longitude),
|
||||
content: t('store.detail.title')
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const initEditor = () => {
|
||||
const TMap = tmapNamespace.value
|
||||
if (!mapInstance.value || !overlayInstance.value || !TMap) return
|
||||
|
||||
// Create new editor
|
||||
editorInstance.value = new TMap.tools.GeometryEditor({
|
||||
map: mapInstance.value,
|
||||
overlayList: [
|
||||
{
|
||||
overlay: overlayInstance.value,
|
||||
id: 'polygon-group',
|
||||
selectedStyleId: 'highlight',
|
||||
drawingStyleId: 'highlight' // Style when drawing
|
||||
}
|
||||
],
|
||||
actionMode: TMap.tools.constants.EDITOR_ACTION.DRAW,
|
||||
activeOverlayId: 'polygon-group',
|
||||
selectable: true,
|
||||
snappable: true // Allow snapping to points
|
||||
})
|
||||
|
||||
// Bind Events
|
||||
editorInstance.value.on('draw_complete', onDrawComplete)
|
||||
editorInstance.value.on('adjust_complete', onAdjustComplete)
|
||||
editorInstance.value.on('delete_complete', onDeleteComplete)
|
||||
}
|
||||
|
||||
const getInitialCenter = (TMap: any) => {
|
||||
if (props.storeLocation?.latitude && props.storeLocation?.longitude) {
|
||||
return new TMap.LatLng(props.storeLocation.latitude, props.storeLocation.longitude)
|
||||
}
|
||||
return new TMap.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng)
|
||||
}
|
||||
|
||||
const loadInitialGeometry = () => {
|
||||
if (!props.geoJson || !props.geoJson.trim()) {
|
||||
hasGeometry.value = false
|
||||
localGeoJson.value = ''
|
||||
setMode('draw')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const geoObj = JSON.parse(props.geoJson)
|
||||
const TMap = tmapNamespace.value
|
||||
let paths: any[] = []
|
||||
|
||||
// Parse Standard GeoJSON Polygon
|
||||
// GeoJSON coords are [lng, lat], TMap needs LatLng
|
||||
const parseRing = (ring: number[][]) => {
|
||||
return ring.map((p) => new TMap.LatLng(p[1], p[0]))
|
||||
}
|
||||
|
||||
if (geoObj.type === 'Polygon') {
|
||||
paths = geoObj.coordinates.map(parseRing)
|
||||
} else if (geoObj.type === 'MultiPolygon') {
|
||||
// Take the first polygon if multiple
|
||||
if (geoObj.coordinates && geoObj.coordinates.length > 0) {
|
||||
paths = geoObj.coordinates[0].map(parseRing)
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.length > 0) {
|
||||
// Add to overlay
|
||||
overlayInstance.value.add([
|
||||
{
|
||||
id: 'main-zone',
|
||||
styleId: 'highlight',
|
||||
paths: paths
|
||||
}
|
||||
])
|
||||
|
||||
// Adjust map view
|
||||
const bounds = new TMap.LatLngBounds()
|
||||
paths.forEach((ring: any[]) => {
|
||||
ring.forEach((p) => bounds.extend(p))
|
||||
})
|
||||
mapInstance.value.fitBounds(bounds, { padding: 50 })
|
||||
|
||||
hasGeometry.value = true
|
||||
localGeoJson.value = props.geoJson // Sync initial value
|
||||
setMode('edit')
|
||||
} else {
|
||||
setMode('draw')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse initial GeoJSON', e)
|
||||
setMode('draw')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Interaction ---
|
||||
|
||||
const setMode = (mode: 'draw' | 'edit') => {
|
||||
if (!editorInstance.value || !tmapNamespace.value) return
|
||||
const TMap = tmapNamespace.value
|
||||
const Constants = TMap.tools.constants
|
||||
|
||||
if (mode === 'draw') {
|
||||
editorInstance.value.setActionMode(Constants.EDITOR_ACTION.DRAW)
|
||||
currentMode.value = 'draw'
|
||||
} else {
|
||||
editorInstance.value.setActionMode(Constants.EDITOR_ACTION.INTERACT)
|
||||
currentMode.value = 'edit'
|
||||
}
|
||||
}
|
||||
|
||||
const onDrawComplete = (geometry: any) => {
|
||||
hasGeometry.value = true
|
||||
syncData(geometry)
|
||||
setMode('edit')
|
||||
}
|
||||
|
||||
const onAdjustComplete = (geometry: any) => {
|
||||
syncData(geometry)
|
||||
}
|
||||
|
||||
const onDeleteComplete = () => {
|
||||
hasGeometry.value = false
|
||||
localGeoJson.value = ''
|
||||
setMode('draw')
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
// 1. Destroy Editor first to detach from overlay
|
||||
if (editorInstance.value) {
|
||||
editorInstance.value.destroy()
|
||||
editorInstance.value = null
|
||||
}
|
||||
|
||||
// 2. Clear Overlay
|
||||
if (overlayInstance.value) {
|
||||
overlayInstance.value.setGeometries([])
|
||||
}
|
||||
|
||||
// 3. Reset State & UI
|
||||
hasGeometry.value = false
|
||||
localGeoJson.value = ''
|
||||
currentMode.value = 'draw'
|
||||
|
||||
// 4. Re-init Editor (Fresh start)
|
||||
initEditor()
|
||||
}
|
||||
|
||||
// --- Data Sync ---
|
||||
|
||||
const localGeoJson = ref('')
|
||||
|
||||
// Enhanced syncData to accept geometry directly from events to avoid overlay sync race conditions
|
||||
const syncData = (eventGeometry?: any) => {
|
||||
// 1. Try to get geometry from event or overlay
|
||||
let geometry = eventGeometry
|
||||
|
||||
if (!geometry) {
|
||||
if (!overlayInstance.value) return
|
||||
|
||||
const geometries = overlayInstance.value.getGeometries()
|
||||
if (geometries && geometries.length > 0) {
|
||||
geometry = geometries[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If no geometry found anywhere, clear data
|
||||
if (!geometry) {
|
||||
localGeoJson.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Process Geometry
|
||||
try {
|
||||
let rawPaths = geometry.paths
|
||||
|
||||
// Robustness: Wrap single loop polygon into multi-loop structure
|
||||
if (rawPaths && rawPaths.length > 0 && rawPaths[0].getLat) {
|
||||
rawPaths = [rawPaths]
|
||||
}
|
||||
|
||||
const coordinates = rawPaths.map((ring: any[]) => {
|
||||
const ringCoords = ring.map((p: any) => [p.getLng(), p.getLat()])
|
||||
// Ensure closed loop
|
||||
if (ringCoords.length > 0) {
|
||||
const first = ringCoords[0]
|
||||
const last = ringCoords[ringCoords.length - 1]
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||
ringCoords.push(first)
|
||||
}
|
||||
}
|
||||
return ringCoords
|
||||
})
|
||||
|
||||
const geoJsonObj = {
|
||||
type: 'Polygon',
|
||||
coordinates: coordinates
|
||||
}
|
||||
|
||||
localGeoJson.value = JSON.stringify(geoJsonObj)
|
||||
} catch (e) {
|
||||
console.error('Data Sync Failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Submit ---
|
||||
|
||||
const handleApply = () => {
|
||||
// Do NOT call syncData() here. It may overwrite valid event-based data with stale/empty overlay data.
|
||||
// relying on onDrawComplete/onAdjustComplete to keep localGeoJson up to date.
|
||||
emit('apply', localGeoJson.value)
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
const handleOpened = async () => {
|
||||
await nextTick()
|
||||
initMap()
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
if (editorInstance.value) {
|
||||
editorInstance.value.destroy()
|
||||
editorInstance.value = null
|
||||
}
|
||||
if (mapInstance.value) {
|
||||
mapInstance.value.destroy()
|
||||
mapInstance.value = null
|
||||
}
|
||||
hasGeometry.value = false
|
||||
currentMode.value = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.delivery-zone-polygon-dialog :deep(.el-dialog__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-shell {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
549
src/views/store/store-detail/components/StoreFeePanel.vue
Normal file
549
src/views/store/store-detail/components/StoreFeePanel.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<ElCard shadow="never" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ t('store.fee.title') }}</span>
|
||||
<ElButton type="primary" :loading="saving" @click="handleSave">
|
||||
{{ t('store.action.save') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm :model="feeForm" label-width="100px" class="store-fee-form">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div class="lg:border-r lg:border-gray-100 lg:pr-8">
|
||||
<!-- 1. 收取方式 -->
|
||||
<ElFormItem :label="t('store.fee.packagingMode.title')">
|
||||
<ElRadioGroup v-model="uiPackagingMode">
|
||||
<ElRadio label="ITEM" size="large">{{
|
||||
t('store.fee.packagingMode.byItem')
|
||||
}}</ElRadio>
|
||||
<ElRadio label="ORDER" size="large">{{
|
||||
t('store.fee.packagingMode.byOrder')
|
||||
}}</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 2. 收费说明 (仅按订单收费显示) -->
|
||||
<div
|
||||
v-if="uiPackagingMode === 'ORDER'"
|
||||
class="ml-[100px] mb-6 p-4 bg-gray-50 rounded-lg border border-gray-100 text-sm text-gray-600"
|
||||
>
|
||||
<div class="font-medium mb-2 text-gray-900">{{
|
||||
t('store.fee.packagingHelp.title')
|
||||
}}</div>
|
||||
<ul class="list-disc list-inside space-y-1 pl-1">
|
||||
<li>{{ t('store.fee.packagingHelp.item1') }}</li>
|
||||
<li>{{ t('store.fee.packagingHelp.item2') }}</li>
|
||||
<li>{{ t('store.fee.packagingHelp.item3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 3. 收费规则 (仅按订单收费显示) -->
|
||||
<template v-if="uiPackagingMode === 'ORDER'">
|
||||
<ElFormItem :label="t('store.fee.packagingRule.title')">
|
||||
<ElRadioGroup v-model="uiOrderMode">
|
||||
<ElRadio label="TIERED" size="large">{{
|
||||
t('store.fee.packagingRule.tiered')
|
||||
}}</ElRadio>
|
||||
<ElRadio label="FIXED" size="large">{{
|
||||
t('store.fee.packagingRule.fixed')
|
||||
}}</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 3.1 阶梯价表单 (Grid Layout) -->
|
||||
<div v-if="uiOrderMode === 'TIERED'" class="pl-[20px] mb-6">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(tier, index) in packagingFeeTiers"
|
||||
:key="index"
|
||||
class="flex items-center"
|
||||
>
|
||||
<!-- Label: 折后价阶梯N -->
|
||||
<div class="w-[110px] text-right pr-3 text-sm text-gray-500 shrink-0">
|
||||
{{ t('store.fee.tier.label', { index: index + 1 }) }}
|
||||
<el-tooltip v-if="index === 0" content="Tier 1 starts from 0">
|
||||
<el-icon class="ml-1 translate-y-0.5 text-gray-400 cursor-help"
|
||||
><QuestionFilled
|
||||
/></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Range Start -->
|
||||
<div class="text-sm text-gray-900 font-medium w-[60px] text-right shrink-0">
|
||||
{{ index === 0 ? '0' : (packagingFeeTiers[index - 1]?.maxPrice ?? 0) }}
|
||||
{{ t('store.fee.tier.unit') }}
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="px-2 text-gray-400 shrink-0">-</div>
|
||||
|
||||
<!-- Range End (Input or Text) -->
|
||||
<div class="w-[160px] shrink-0">
|
||||
<template v-if="index < packagingFeeTiers.length - 1">
|
||||
<div class="flex items-center">
|
||||
<ElInputNumber
|
||||
v-model="tier.maxPrice"
|
||||
:min="
|
||||
(index === 0 ? 0 : (packagingFeeTiers[index - 1]?.maxPrice ?? 0)) +
|
||||
0.01
|
||||
"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
class="!w-[110px]"
|
||||
:placeholder="t('store.fee.tier.inputPlaceholder')"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-500">{{
|
||||
t('store.fee.tier.unitInclude')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-sm text-gray-500 italic block py-1.5">
|
||||
{{ t('store.fee.tier.limitInfinity') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Fee Label -->
|
||||
<div class="w-[60px] text-right text-sm text-gray-500 px-2 shrink-0">
|
||||
{{ t('store.fee.tier.fee') }}
|
||||
</div>
|
||||
|
||||
<!-- Fee Input -->
|
||||
<div class="flex items-center w-[140px] shrink-0">
|
||||
<ElInputNumber
|
||||
v-model="tier.fee"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
class="!w-[110px]"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-500">{{ t('store.fee.tier.unit') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Delete Action -->
|
||||
<div class="ml-2 w-[30px] flex justify-center shrink-0">
|
||||
<ElButton
|
||||
v-if="
|
||||
packagingFeeTiers.length > 1 && index === packagingFeeTiers.length - 1
|
||||
"
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
@click="removeTier(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Button -->
|
||||
<div class="pl-[110px] mt-3">
|
||||
<ElButton
|
||||
v-if="packagingFeeTiers.length < 10"
|
||||
type="primary"
|
||||
link
|
||||
:icon="Plus"
|
||||
@click="addTier"
|
||||
>
|
||||
{{ t('store.fee.tier.add', { current: packagingFeeTiers.length, max: 10 }) }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3.2 一口价表单 -->
|
||||
<ElFormItem
|
||||
v-if="uiOrderMode === 'FIXED'"
|
||||
:label="t('store.fee.fixedPackagingFee')"
|
||||
class="animate-fade-in"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber
|
||||
v-model="feeForm.fixedPackagingFee"
|
||||
class="!w-[180px]"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
/>
|
||||
<span class="text-gray-500 text-sm">元</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<!-- 4. 其他费用 -->
|
||||
<ElFormItem :label="t('store.fee.minimumOrderAmount')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber
|
||||
v-model="feeForm.minimumOrderAmount"
|
||||
class="!w-[180px]"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
/>
|
||||
<span class="text-gray-500 text-sm">元</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.fee.deliveryFee')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber
|
||||
v-model="feeForm.deliveryFee"
|
||||
class="!w-[180px]"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
/>
|
||||
<span class="text-gray-500 text-sm">元</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.fee.freeDeliveryThreshold')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber
|
||||
v-model="feeForm.freeDeliveryThreshold"
|
||||
class="!w-[180px]"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
/>
|
||||
<span class="text-gray-500 text-sm">元</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
|
||||
<!-- Preview Card (Minor style updates) -->
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ t('store.fee.preview.title') }}</span>
|
||||
<ElButton type="primary" :loading="calculating" @click="handleCalculate">
|
||||
{{ t('store.fee.preview.action') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<ElForm :model="calcForm" label-width="100px">
|
||||
<ElFormItem :label="t('store.fee.preview.orderAmount')">
|
||||
<ElInputNumber
|
||||
v-model="calcForm.orderAmount"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.fee.preview.itemCount')">
|
||||
<ElInputNumber
|
||||
v-model="calcForm.itemCount"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="0"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">{{ t('store.fee.preview.itemsTitle') }}</span>
|
||||
<ElButton type="primary" link @click="handleAddItem">
|
||||
{{ t('store.fee.preview.addItem') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTable :data="calcItems" border size="small" class="mt-2">
|
||||
<ElTableColumn :label="t('store.fee.preview.skuId')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model="row.skuId" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.fee.preview.quantity')" width="100">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber v-model="row.quantity" class="w-full" :min="1" :controls="false" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.fee.preview.packagingFee')" width="140">
|
||||
<template #default="{ row }">
|
||||
<ElInputNumber
|
||||
v-model="row.packagingFee"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
:precision="2"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('common.action')" width="80">
|
||||
<template #default="{ $index }">
|
||||
<ElButton link type="danger" @click="handleRemoveItem($index)">
|
||||
{{ t('store.action.delete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElAlert
|
||||
v-if="calcResult?.message"
|
||||
type="info"
|
||||
:title="calcResult.message"
|
||||
show-icon
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<ElDescriptions v-if="calcResult" :column="2" border class="mt-4">
|
||||
<ElDescriptionsItem :label="t('store.fee.preview.result.totalAmount')">
|
||||
{{ calcResult.totalAmount }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.fee.preview.result.totalFee')">
|
||||
{{ calcResult.totalFee }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.fee.preview.result.deliveryFee')">
|
||||
{{ calcResult.deliveryFee }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.fee.preview.result.packagingFee')">
|
||||
{{ calcResult.packagingFee }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.fee.preview.result.meetsMinimum')">
|
||||
{{ calcResult.meetsMinimum ? t('common.yes') : t('common.no') }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.fee.preview.result.shortfall')">
|
||||
{{ calcResult.shortfall ?? '-' }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTooltip
|
||||
} from 'element-plus'
|
||||
import { QuestionFilled, Plus, Delete } from '@element-plus/icons-vue'
|
||||
import { fetchCalculateStoreFee, fetchStoreFee, fetchUpdateStoreFee } from '@/api/store'
|
||||
import { PackagingFeeMode } from '@/enums/PackagingFeeMode'
|
||||
|
||||
defineOptions({ name: 'StoreFeePanel' })
|
||||
|
||||
interface FeeFormState {
|
||||
minimumOrderAmount: number
|
||||
deliveryFee: number
|
||||
packagingFeeMode: PackagingFeeMode
|
||||
fixedPackagingFee: number
|
||||
freeDeliveryThreshold?: number
|
||||
}
|
||||
|
||||
interface PackagingFeeTier {
|
||||
minPrice: number
|
||||
maxPrice?: number // undefined implies Infinity
|
||||
fee: number
|
||||
}
|
||||
|
||||
interface FeeItemState {
|
||||
skuId: string
|
||||
quantity: number
|
||||
packagingFee: number
|
||||
}
|
||||
|
||||
// 1. 基础输入
|
||||
const props = defineProps<{ storeId: string }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 2. 费用配置
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// UI States
|
||||
const uiPackagingMode = ref<'ITEM' | 'ORDER'>('ITEM')
|
||||
const uiOrderMode = ref<'FIXED' | 'TIERED'>('FIXED')
|
||||
const packagingFeeTiers = ref<PackagingFeeTier[]>([{ minPrice: 0, maxPrice: undefined, fee: 0 }])
|
||||
|
||||
const feeForm = ref<FeeFormState>({
|
||||
minimumOrderAmount: 0,
|
||||
deliveryFee: 0,
|
||||
packagingFeeMode: PackagingFeeMode.Fixed,
|
||||
fixedPackagingFee: 0,
|
||||
freeDeliveryThreshold: undefined
|
||||
})
|
||||
|
||||
// 3. 预览计算
|
||||
const calculating = ref(false)
|
||||
const calcForm = ref({ orderAmount: 0, itemCount: 0 })
|
||||
const calcItems = ref<FeeItemState[]>([])
|
||||
const calcResult = ref<Api.Store.StoreFeeCalculationResultDto | null>(null)
|
||||
|
||||
// 4. 初始化加载
|
||||
const loadFee = async () => {
|
||||
if (!props.storeId) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchStoreFee(props.storeId)
|
||||
|
||||
// Map API data to UI state
|
||||
feeForm.value = {
|
||||
minimumOrderAmount: data.minimumOrderAmount ?? 0,
|
||||
deliveryFee: data.deliveryFee ?? 0,
|
||||
packagingFeeMode: data.packagingFeeMode,
|
||||
fixedPackagingFee: data.fixedPackagingFee ?? 0,
|
||||
freeDeliveryThreshold: data.freeDeliveryThreshold ?? undefined
|
||||
}
|
||||
|
||||
// Initialize UI Modes
|
||||
if (data.packagingFeeMode === PackagingFeeMode.PerItem) {
|
||||
uiPackagingMode.value = 'ITEM'
|
||||
} else {
|
||||
uiPackagingMode.value = 'ORDER'
|
||||
}
|
||||
// Note: Backend doesn't support TIERED yet, so default to FIXED
|
||||
uiOrderMode.value = 'FIXED'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.storeId,
|
||||
() => {
|
||||
loadFee()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 5. 阶梯操作
|
||||
const addTier = () => {
|
||||
// Find the last tier
|
||||
const last = packagingFeeTiers.value[packagingFeeTiers.value.length - 1]
|
||||
|
||||
// If last tier is infinite, we can't extend from it unless we set a limit on it first
|
||||
// But in our UI, the 'last tier' is always infinite. To add a tier, we split the LAST one.
|
||||
// Logic:
|
||||
// Current: 0 -> Infinity
|
||||
// Add ->
|
||||
// Tier 1: 0 -> (User Input, e.g. 10)
|
||||
// Tier 2: 10 -> Infinity
|
||||
|
||||
// Step 1: Set a default maxPrice for current last tier if it's undefined
|
||||
if (last.maxPrice === undefined) {
|
||||
// Default gap 10?
|
||||
const start = last.minPrice
|
||||
last.maxPrice = start + 10
|
||||
}
|
||||
|
||||
// Step 2: Add new infinite tier
|
||||
packagingFeeTiers.value.push({
|
||||
minPrice: last.maxPrice as number,
|
||||
maxPrice: undefined,
|
||||
fee: 0
|
||||
})
|
||||
}
|
||||
|
||||
const removeTier = (index: number) => {
|
||||
// Can only remove the last tier, and merge it into previous
|
||||
if (index === packagingFeeTiers.value.length - 1 && index > 0) {
|
||||
packagingFeeTiers.value.pop()
|
||||
// Reset the new last tier to infinite
|
||||
packagingFeeTiers.value[packagingFeeTiers.value.length - 1].maxPrice = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 操作处理
|
||||
const handleSave = async () => {
|
||||
if (!props.storeId) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
|
||||
// Map UI State back to API Mode
|
||||
let apiMode = PackagingFeeMode.Fixed
|
||||
if (uiPackagingMode.value === 'ITEM') {
|
||||
apiMode = PackagingFeeMode.PerItem
|
||||
}
|
||||
|
||||
// Note: If TIERED is selected, we technically can't save it to backend yet.
|
||||
// For now, we only save the 'Fixed Fee' part if in FIXED mode.
|
||||
// User said "don't worry about API", so we simply proceed.
|
||||
|
||||
try {
|
||||
await fetchUpdateStoreFee(props.storeId, {
|
||||
minimumOrderAmount: feeForm.value.minimumOrderAmount,
|
||||
deliveryFee: feeForm.value.deliveryFee,
|
||||
packagingFeeMode: apiMode,
|
||||
fixedPackagingFee: feeForm.value.fixedPackagingFee,
|
||||
freeDeliveryThreshold: feeForm.value.freeDeliveryThreshold
|
||||
})
|
||||
|
||||
// Log Tier Data for debugging/future backend implementation
|
||||
if (uiPackagingMode.value === 'ORDER' && uiOrderMode.value === 'TIERED') {
|
||||
console.log('Saving Tiered Config (Frontend Only):', packagingFeeTiers.value)
|
||||
}
|
||||
|
||||
ElMessage.success(t('store.message.updateSuccess'))
|
||||
loadFee()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing preview logic ...
|
||||
const handleAddItem = () => {
|
||||
calcItems.value.push({ skuId: '', quantity: 1, packagingFee: 0 })
|
||||
}
|
||||
const handleRemoveItem = (index: number) => {
|
||||
calcItems.value.splice(index, 1)
|
||||
}
|
||||
const handleCalculate = async () => {
|
||||
if (!props.storeId) {
|
||||
return
|
||||
}
|
||||
calculating.value = true
|
||||
try {
|
||||
calcResult.value = await fetchCalculateStoreFee(props.storeId, {
|
||||
orderAmount: calcForm.value.orderAmount,
|
||||
itemCount: calcForm.value.itemCount || undefined,
|
||||
items: calcItems.value.map((item) => ({
|
||||
skuId: item.skuId,
|
||||
quantity: item.quantity,
|
||||
packagingFee: item.packagingFee
|
||||
}))
|
||||
})
|
||||
} finally {
|
||||
calculating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.store-fee-form :deep(.el-input-number .el-input__inner) {
|
||||
padding-right: 50px !important;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Hide input number controls */
|
||||
.store-fee-form :deep(.el-input-number__decrease),
|
||||
.store-fee-form :deep(.el-input-number__increase) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.store-fee-form :deep(.el-input-number .el-input__wrapper) {
|
||||
padding-right: 0 !important;
|
||||
padding-left: 15px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ElTag v-if="checkResult" :type="checkResult.isComplete ? 'success' : 'warning'">
|
||||
{{
|
||||
checkResult.isComplete
|
||||
? t('store.qualification.complete')
|
||||
: t('store.qualification.incomplete')
|
||||
}}
|
||||
</ElTag>
|
||||
<ElTag v-if="checkResult" type="info">
|
||||
{{ t('store.qualification.expiringSoon', { count: checkResult.expiringSoonCount }) }}
|
||||
</ElTag>
|
||||
<ElTag v-if="checkResult" type="danger">
|
||||
{{ t('store.qualification.expired', { count: checkResult.expiredCount }) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton @click="loadData">{{ t('store.action.refresh') }}</ElButton>
|
||||
<ElButton type="primary" @click="handleCreate">
|
||||
{{ t('store.qualification.action.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElAlert
|
||||
v-if="checkResult && checkResult.warnings.length > 0"
|
||||
type="warning"
|
||||
:title="t('store.qualification.warningTitle')"
|
||||
show-icon
|
||||
class="mb-2"
|
||||
>
|
||||
<template #default>
|
||||
<ul class="list-disc pl-4">
|
||||
<li v-for="item in checkResult.warnings" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElTable :data="qualifications" border stripe v-loading="loading">
|
||||
<ElTableColumn :label="t('store.qualification.table.type')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ getQualificationTypeText(row.qualificationType) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.qualification.table.documentNumber')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.documentNumber || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.qualification.table.expiresAt')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.expiresAt) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.qualification.table.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getStatusType(row)">
|
||||
{{ getStatusText(row) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('common.action')" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ElButton link type="primary" @click="handleEdit(row)">
|
||||
{{ t('store.action.edit') }}
|
||||
</ElButton>
|
||||
<ElButton link type="danger" @click="handleDelete(row)">
|
||||
{{ t('store.action.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="
|
||||
editingItem ? t('store.qualification.action.edit') : t('store.qualification.action.add')
|
||||
"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ElForm ref="formRef" :model="formModel" :rules="formRules" label-width="110px">
|
||||
<ElFormItem :label="t('store.qualification.form.type')" prop="qualificationType">
|
||||
<ElSelect v-model="formModel.qualificationType" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in qualificationTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.qualification.form.fileUrl')" prop="fileUrl">
|
||||
<ImageUpload
|
||||
v-model="formModel.fileUrl"
|
||||
:placeholder="t('store.qualification.form.fileUrlPlaceholder')"
|
||||
:upload-type="resolveUploadType(formModel.qualificationType)"
|
||||
box-width="100%"
|
||||
box-height="160px"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.qualification.form.documentNumber')" prop="documentNumber">
|
||||
<ElInput
|
||||
v-model="formModel.documentNumber"
|
||||
:placeholder="t('store.qualification.form.documentNumberPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.qualification.form.issuedAt')" prop="issuedAt">
|
||||
<ElDatePicker v-model="formModel.issuedAt" type="date" value-format="YYYY-MM-DD" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.qualification.form.expiresAt')" prop="expiresAt">
|
||||
<ElDatePicker v-model="formModel.expiresAt" type="date" value-format="YYYY-MM-DD" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.qualification.form.sortOrder')" prop="sortOrder">
|
||||
<ElInputNumber v-model="formModel.sortOrder" class="w-full" :min="0" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="handleSubmit">
|
||||
{{ t('common.confirm') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag
|
||||
} from 'element-plus'
|
||||
import {
|
||||
fetchCheckStoreQualifications,
|
||||
fetchCreateStoreQualification,
|
||||
fetchDeleteStoreQualification,
|
||||
fetchStoreQualifications,
|
||||
fetchUpdateStoreQualification
|
||||
} from '@/api/store'
|
||||
import { StoreQualificationType } from '@/enums/StoreQualificationType'
|
||||
import { formatDate } from '@/utils/billing'
|
||||
import ImageUpload from '@/components/common/ImageUpload.vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'StoreQualificationPanel' })
|
||||
|
||||
interface QualificationFormState {
|
||||
qualificationType: StoreQualificationType
|
||||
fileUrl: string
|
||||
documentNumber?: string
|
||||
issuedAt?: string
|
||||
expiresAt?: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
// 1. 基础输入
|
||||
const props = defineProps<{ storeId: string; tenantId?: string }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const createDefaultForm = (): QualificationFormState => ({
|
||||
qualificationType: StoreQualificationType.BusinessLicense,
|
||||
fileUrl: '',
|
||||
documentNumber: '',
|
||||
issuedAt: '',
|
||||
expiresAt: '',
|
||||
sortOrder: 100
|
||||
})
|
||||
|
||||
const isLicenseType = (type: StoreQualificationType) =>
|
||||
type === StoreQualificationType.BusinessLicense ||
|
||||
type === StoreQualificationType.FoodServiceLicense
|
||||
|
||||
// 2. 资质数据
|
||||
const loading = ref(false)
|
||||
const qualifications = ref<Api.Store.StoreQualificationDto[]>([])
|
||||
const checkResult = ref<Api.Store.StoreQualificationCheckResultDto | null>(null)
|
||||
|
||||
// 3. 表单状态
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingItem = ref<Api.Store.StoreQualificationDto | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formModel = ref<QualificationFormState>(createDefaultForm())
|
||||
|
||||
// 4. 选项配置
|
||||
const qualificationTypeOptions = [
|
||||
{
|
||||
value: StoreQualificationType.BusinessLicense,
|
||||
label: t('store.qualification.type.businessLicense')
|
||||
},
|
||||
{
|
||||
value: StoreQualificationType.FoodServiceLicense,
|
||||
label: t('store.qualification.type.foodService')
|
||||
},
|
||||
{
|
||||
value: StoreQualificationType.StorefrontPhoto,
|
||||
label: t('store.qualification.type.storefront')
|
||||
},
|
||||
{ value: StoreQualificationType.InteriorPhoto, label: t('store.qualification.type.interior') }
|
||||
]
|
||||
|
||||
const resolveUploadType = (type: StoreQualificationType): Api.Files.UploadFileType => {
|
||||
// 1. 证照类统一按营业执照类型上传
|
||||
if (isLicenseType(type)) {
|
||||
return 'business_license'
|
||||
}
|
||||
|
||||
// 2. 其他图片类型统一归类为 other
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const resolveRequestOptions = () => {
|
||||
// 1. 未提供租户ID时使用默认请求配置
|
||||
if (!props.tenantId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 2. 携带租户上下文执行请求
|
||||
return { tenantId: props.tenantId }
|
||||
}
|
||||
|
||||
// 5. 校验规则
|
||||
const formRules: FormRules<QualificationFormState> = {
|
||||
qualificationType: [
|
||||
{ required: true, message: t('store.qualification.rules.type'), trigger: 'change' }
|
||||
],
|
||||
fileUrl: [
|
||||
{ required: true, message: t('store.qualification.rules.fileUrl'), trigger: 'change' }
|
||||
],
|
||||
documentNumber: [
|
||||
{
|
||||
validator: (_, value, callback) => {
|
||||
if (!isLicenseType(formModel.value.qualificationType)) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!value || !value.trim()) {
|
||||
callback(new Error(t('store.qualification.rules.documentNumber')))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
expiresAt: [
|
||||
{
|
||||
validator: (_, value, callback) => {
|
||||
if (!isLicenseType(formModel.value.qualificationType) && !value) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
callback(new Error(t('store.qualification.rules.expiresAt')))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 6. 初始化加载
|
||||
const loadData = async () => {
|
||||
if (!props.storeId) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
// 1. 组装租户上下文
|
||||
const requestOptions = resolveRequestOptions()
|
||||
|
||||
// 2. 并行加载资质与完整性检查
|
||||
const [list, check] = await Promise.all([
|
||||
fetchStoreQualifications(props.storeId, requestOptions),
|
||||
fetchCheckStoreQualifications(props.storeId, requestOptions)
|
||||
])
|
||||
qualifications.value = list
|
||||
checkResult.value = check
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.storeId, props.tenantId],
|
||||
() => {
|
||||
loadData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 7. 交互处理
|
||||
const handleCreate = () => {
|
||||
editingItem.value = null
|
||||
formModel.value = createDefaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const handleEdit = (row: Api.Store.StoreQualificationDto) => {
|
||||
editingItem.value = row
|
||||
formModel.value = {
|
||||
qualificationType: row.qualificationType,
|
||||
fileUrl: row.fileUrl,
|
||||
documentNumber: row.documentNumber ?? '',
|
||||
issuedAt: row.issuedAt ?? '',
|
||||
expiresAt: row.expiresAt ?? '',
|
||||
sortOrder: row.sortOrder
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const handleDelete = async (row: Api.Store.StoreQualificationDto) => {
|
||||
// 1. 二次确认
|
||||
await ElMessageBox.confirm(
|
||||
t('store.qualification.deleteConfirm'),
|
||||
t('store.qualification.deleteTitle'),
|
||||
{
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
// 2. 执行删除
|
||||
const requestOptions = resolveRequestOptions()
|
||||
await fetchDeleteStoreQualification(props.storeId, row.id, requestOptions)
|
||||
ElMessage.success(t('store.message.deleteSuccess'))
|
||||
loadData()
|
||||
}
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 校验表单
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 执行保存
|
||||
saving.value = true
|
||||
try {
|
||||
const requestOptions = resolveRequestOptions()
|
||||
|
||||
if (editingItem.value) {
|
||||
await fetchUpdateStoreQualification(
|
||||
props.storeId,
|
||||
editingItem.value.id,
|
||||
{
|
||||
fileUrl: formModel.value.fileUrl.trim(),
|
||||
documentNumber: formModel.value.documentNumber?.trim() || undefined,
|
||||
issuedAt: formModel.value.issuedAt || undefined,
|
||||
expiresAt: formModel.value.expiresAt || undefined,
|
||||
sortOrder: formModel.value.sortOrder
|
||||
},
|
||||
requestOptions
|
||||
)
|
||||
ElMessage.success(t('store.message.updateSuccess'))
|
||||
} else {
|
||||
await fetchCreateStoreQualification(
|
||||
props.storeId,
|
||||
{
|
||||
qualificationType: formModel.value.qualificationType,
|
||||
fileUrl: formModel.value.fileUrl.trim(),
|
||||
documentNumber: formModel.value.documentNumber?.trim() || undefined,
|
||||
issuedAt: formModel.value.issuedAt || undefined,
|
||||
expiresAt: formModel.value.expiresAt || undefined,
|
||||
sortOrder: formModel.value.sortOrder
|
||||
},
|
||||
requestOptions
|
||||
)
|
||||
ElMessage.success(t('store.message.createSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
// 8. 显示格式
|
||||
const getQualificationTypeText = (type: StoreQualificationType) => {
|
||||
switch (type) {
|
||||
case StoreQualificationType.BusinessLicense:
|
||||
return t('store.qualification.type.businessLicense')
|
||||
case StoreQualificationType.FoodServiceLicense:
|
||||
return t('store.qualification.type.foodService')
|
||||
case StoreQualificationType.StorefrontPhoto:
|
||||
return t('store.qualification.type.storefront')
|
||||
case StoreQualificationType.InteriorPhoto:
|
||||
return t('store.qualification.type.interior')
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
const getStatusType = (row: Api.Store.StoreQualificationDto) => {
|
||||
if (row.isExpired) {
|
||||
return 'danger'
|
||||
}
|
||||
if (row.isExpiringSoon) {
|
||||
return 'warning'
|
||||
}
|
||||
return 'success'
|
||||
}
|
||||
const getStatusText = (row: Api.Store.StoreQualificationDto) => {
|
||||
if (row.isExpired) {
|
||||
return t('store.qualification.status.expired')
|
||||
}
|
||||
if (row.isExpiringSoon) {
|
||||
return t('store.qualification.status.expiringSoon')
|
||||
}
|
||||
return t('store.qualification.status.valid')
|
||||
}
|
||||
</script>
|
||||
348
src/views/store/store-detail/components/TemporaryHoursPanel.vue
Normal file
348
src/views/store/store-detail/components/TemporaryHoursPanel.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ t('store.temporaryHours.tip') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton @click="loadHolidays">{{ t('store.action.refresh') }}</ElButton>
|
||||
<ElButton type="primary" @click="handleAdd">
|
||||
{{ t('store.temporaryHours.action.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTable :data="holidays" border stripe v-loading="loading" class="w-full">
|
||||
<ElTableColumn :label="t('store.temporaryHours.table.dateRange')" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDate(row.date) }}</span>
|
||||
<span v-if="row.endDate"> ~ {{ formatDate(row.endDate) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.temporaryHours.table.timeRange')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.isAllDay">{{ t('store.temporaryHours.allDay') }}</span>
|
||||
<span v-else>{{ formatTime(row.startTime) }} - {{ formatTime(row.endTime) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="t('store.temporaryHours.table.overrideType')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getOverrideTypeTagType(row.overrideType)">
|
||||
{{ getOverrideTypeLabel(row.overrideType) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="t('store.temporaryHours.table.reason')"
|
||||
min-width="180"
|
||||
prop="reason"
|
||||
/>
|
||||
<ElTableColumn :label="t('common.action')" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="handleEdit(row)">
|
||||
{{ t('common.edit') }}
|
||||
</ElButton>
|
||||
<ElButton link type="danger" @click="handleDelete(row)">
|
||||
{{ t('store.action.delete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="
|
||||
isEdit
|
||||
? t('store.temporaryHours.dialog.editTitle')
|
||||
: t('store.temporaryHours.dialog.addTitle')
|
||||
"
|
||||
width="500px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<ElFormItem :label="t('store.temporaryHours.form.dateRange')" prop="dateRange">
|
||||
<ElDatePicker
|
||||
v-model="form.dateRange"
|
||||
type="daterange"
|
||||
:start-placeholder="t('common.startDate')"
|
||||
:end-placeholder="t('common.endDate')"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="!w-full"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="t('store.temporaryHours.form.isAllDay')">
|
||||
<ElSwitch v-model="form.isAllDay" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="!form.isAllDay"
|
||||
:label="t('store.temporaryHours.form.timeRange')"
|
||||
prop="timeRange"
|
||||
>
|
||||
<ElTimePicker
|
||||
v-model="form.timeRange"
|
||||
is-range
|
||||
format="HH:mm"
|
||||
value-format="HH:mm:ss"
|
||||
:start-placeholder="t('common.startTime')"
|
||||
:end-placeholder="t('common.endTime')"
|
||||
class="!w-full"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="t('store.temporaryHours.form.overrideType')" prop="overrideType">
|
||||
<ElSelect v-model="form.overrideType" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in overrideTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="t('store.temporaryHours.form.reason')">
|
||||
<ElInput
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
:placeholder="t('store.temporaryHours.form.reasonPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ t('common.confirm') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
ElTimePicker,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from 'element-plus'
|
||||
import {
|
||||
fetchCreateStoreHoliday,
|
||||
fetchDeleteStoreHoliday,
|
||||
fetchStoreHolidays,
|
||||
fetchUpdateStoreHoliday
|
||||
} from '@/api/store'
|
||||
import { OverrideType } from '@/enums/OverrideType'
|
||||
|
||||
defineOptions({ name: 'TemporaryHoursPanel' })
|
||||
|
||||
// 1. 基础输入
|
||||
const props = defineProps<{ storeId: string }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 2. 状态管理
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const holidays = ref<Api.Store.StoreHolidayDto[]>([])
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 3. 表单数据
|
||||
const form = reactive({
|
||||
dateRange: [] as string[],
|
||||
isAllDay: true,
|
||||
timeRange: [] as string[],
|
||||
overrideType: OverrideType.Closed,
|
||||
reason: ''
|
||||
})
|
||||
|
||||
// 4. 选项配置
|
||||
const overrideTypeOptions = computed(() => [
|
||||
{
|
||||
value: OverrideType.Closed,
|
||||
label: t('store.temporaryHours.overrideType.closed')
|
||||
},
|
||||
{
|
||||
value: OverrideType.TemporaryOpen,
|
||||
label: t('store.temporaryHours.overrideType.temporaryOpen')
|
||||
},
|
||||
{
|
||||
value: OverrideType.ModifiedHours,
|
||||
label: t('store.temporaryHours.overrideType.modifiedHours')
|
||||
}
|
||||
])
|
||||
|
||||
// 5. 表单校验规则
|
||||
const formRules: FormRules = {
|
||||
dateRange: [
|
||||
{
|
||||
required: true,
|
||||
message: t('store.temporaryHours.rules.dateRequired'),
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
overrideType: [
|
||||
{
|
||||
required: true,
|
||||
message: t('store.temporaryHours.rules.typeRequired'),
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
timeRange: [
|
||||
{
|
||||
validator: (_rule, _value, callback) => {
|
||||
if (!form.isAllDay && (!form.timeRange || form.timeRange.length < 2)) {
|
||||
callback(new Error(t('store.temporaryHours.rules.timeRequired')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 6. 数据加载
|
||||
const loadHolidays = async () => {
|
||||
if (!props.storeId) {
|
||||
holidays.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
holidays.value = await fetchStoreHolidays(props.storeId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.storeId,
|
||||
() => {
|
||||
loadHolidays()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 7. 辅助函数
|
||||
const formatDate = (dateStr: string) => dateStr?.split('T')[0] || ''
|
||||
const formatTime = (timeStr?: string) => timeStr?.substring(0, 5) || ''
|
||||
|
||||
const getOverrideTypeLabel = (type: number) => {
|
||||
const option = overrideTypeOptions.value.find((item) => item.value === type)
|
||||
return option?.label || ''
|
||||
}
|
||||
const getOverrideTypeTagType = (type: number) => {
|
||||
switch (type) {
|
||||
case OverrideType.Closed:
|
||||
return 'danger'
|
||||
case OverrideType.TemporaryOpen:
|
||||
return 'success'
|
||||
case OverrideType.ModifiedHours:
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 交互处理
|
||||
const resetForm = () => {
|
||||
form.dateRange = []
|
||||
form.isAllDay = true
|
||||
form.timeRange = []
|
||||
form.overrideType = OverrideType.Closed
|
||||
form.reason = ''
|
||||
editingId.value = null
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: Api.Store.StoreHolidayDto) => {
|
||||
resetForm()
|
||||
isEdit.value = true
|
||||
editingId.value = row.id
|
||||
const startDate = formatDate(row.date)
|
||||
const endDate = row.endDate ? formatDate(row.endDate) : startDate
|
||||
form.dateRange = [startDate, endDate]
|
||||
form.isAllDay = row.isAllDay
|
||||
form.timeRange = row.startTime && row.endTime ? [row.startTime, row.endTime] : []
|
||||
form.overrideType = row.overrideType
|
||||
form.reason = row.reason || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Api.Store.StoreHolidayDto) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('store.temporaryHours.deleteConfirm'), t('common.warning'), {
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await fetchDeleteStoreHoliday(props.storeId, row.id)
|
||||
ElMessage.success(t('store.message.deleteSuccess'))
|
||||
loadHolidays()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return
|
||||
}
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Api.Store.CreateStoreHolidayRequest = {
|
||||
date: form.dateRange[0],
|
||||
endDate: form.dateRange[1] !== form.dateRange[0] ? form.dateRange[1] : undefined,
|
||||
isAllDay: form.isAllDay,
|
||||
startTime: !form.isAllDay ? form.timeRange[0] : undefined,
|
||||
endTime: !form.isAllDay ? form.timeRange[1] : undefined,
|
||||
overrideType: form.overrideType,
|
||||
reason: form.reason?.trim() || undefined
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateStoreHoliday(props.storeId, editingId.value, payload)
|
||||
ElMessage.success(t('store.message.updateSuccess'))
|
||||
} else {
|
||||
await fetchCreateStoreHoliday(props.storeId, payload)
|
||||
ElMessage.success(t('store.message.createSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadHolidays()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
222
src/views/store/store-list/components/BusinessStatusDialog.vue
Normal file
222
src/views/store/store-list/components/BusinessStatusDialog.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="t('store.businessStatus.title')"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="isForceClosed"
|
||||
type="warning"
|
||||
:title="t('store.businessStatus.forceClosedTip')"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<ElForm ref="formRef" :model="formModel" :rules="formRules" label-width="110px">
|
||||
<ElFormItem :label="t('store.businessStatus.targetStatus')" prop="businessStatus">
|
||||
<ElRadioGroup v-model="formModel.businessStatus" :disabled="isForceClosed">
|
||||
<ElRadioButton :label="StoreBusinessStatus.Open">
|
||||
{{ t('store.businessStatus.open') }}
|
||||
</ElRadioButton>
|
||||
<ElRadioButton :label="StoreBusinessStatus.Resting">
|
||||
{{ t('store.businessStatus.resting') }}
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="formModel.businessStatus === StoreBusinessStatus.Resting"
|
||||
:label="t('store.businessStatus.closureReason')"
|
||||
prop="closureReason"
|
||||
>
|
||||
<ElSelect v-model="formModel.closureReason" class="w-full" :disabled="isForceClosed">
|
||||
<ElOption
|
||||
v-for="option in closureReasonOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="formModel.businessStatus === StoreBusinessStatus.Resting"
|
||||
:label="t('store.businessStatus.closureReasonText')"
|
||||
prop="closureReasonText"
|
||||
>
|
||||
<ElInput
|
||||
v-model="formModel.closureReasonText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="t('store.businessStatus.closureReasonTextPlaceholder')"
|
||||
:disabled="isForceClosed"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton type="primary" :loading="saving" :disabled="isForceClosed" @click="handleSubmit">
|
||||
{{ t('common.confirm') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElRadioButton,
|
||||
ElRadioGroup,
|
||||
ElSelect
|
||||
} from 'element-plus'
|
||||
import { fetchToggleBusinessStatus } from '@/api/store'
|
||||
import { StoreBusinessStatus } from '@/enums/StoreBusinessStatus'
|
||||
import { StoreClosureReason } from '@/enums/StoreClosureReason'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'BusinessStatusDialog' })
|
||||
|
||||
interface BusinessStatusFormState {
|
||||
businessStatus: StoreBusinessStatus
|
||||
closureReason?: StoreClosureReason | null
|
||||
closureReasonText?: string
|
||||
}
|
||||
|
||||
// 1. 基础输入输出
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
store?: Api.Store.StoreDto | null
|
||||
}>(),
|
||||
{ store: null }
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'saved'): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const { modelValue, store } = toRefs(props)
|
||||
|
||||
// 2. 表单状态
|
||||
const formRef = ref<FormInstance>()
|
||||
const saving = ref(false)
|
||||
const formModel = ref<BusinessStatusFormState>({
|
||||
businessStatus: StoreBusinessStatus.Open,
|
||||
closureReason: undefined,
|
||||
closureReasonText: ''
|
||||
})
|
||||
|
||||
// 3. 视图计算
|
||||
const dialogVisible = computed({
|
||||
get: () => modelValue.value,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const isForceClosed = computed(
|
||||
() => store.value?.businessStatus === StoreBusinessStatus.ForceClosed
|
||||
)
|
||||
|
||||
// 4. 下拉选项
|
||||
const closureReasonOptions = [
|
||||
{ value: StoreClosureReason.EquipmentMaintenance, label: t('store.closureReason.equipment') },
|
||||
{ value: StoreClosureReason.OwnerVacation, label: t('store.closureReason.vacation') },
|
||||
{ value: StoreClosureReason.OutOfStock, label: t('store.closureReason.outOfStock') },
|
||||
{
|
||||
value: StoreClosureReason.TemporarilyClosed,
|
||||
label: t('store.closureReason.temporarilyClosed')
|
||||
},
|
||||
{ value: StoreClosureReason.Other, label: t('store.closureReason.other') }
|
||||
]
|
||||
|
||||
// 5. 校验规则
|
||||
const formRules: FormRules<BusinessStatusFormState> = {
|
||||
businessStatus: [
|
||||
{ required: true, message: t('store.businessStatus.rules.status'), trigger: 'change' }
|
||||
],
|
||||
closureReason: [
|
||||
{
|
||||
validator: (_, value, callback) => {
|
||||
if (formModel.value.businessStatus !== StoreBusinessStatus.Resting) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
callback(new Error(t('store.businessStatus.rules.reason')))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 6. 同步门店状态
|
||||
watch(
|
||||
store,
|
||||
(current) => {
|
||||
if (!current) {
|
||||
formModel.value = {
|
||||
businessStatus: StoreBusinessStatus.Open,
|
||||
closureReason: undefined,
|
||||
closureReasonText: ''
|
||||
}
|
||||
return
|
||||
}
|
||||
formModel.value = {
|
||||
businessStatus: current.businessStatus,
|
||||
closureReason: current.closureReason ?? undefined,
|
||||
closureReasonText: current.closureReasonText ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const buildPayload = (): Api.Store.ToggleBusinessStatusRequest => ({
|
||||
businessStatus: formModel.value.businessStatus,
|
||||
closureReason:
|
||||
formModel.value.businessStatus === StoreBusinessStatus.Resting
|
||||
? formModel.value.closureReason
|
||||
: undefined,
|
||||
closureReasonText:
|
||||
formModel.value.businessStatus === StoreBusinessStatus.Resting
|
||||
? formModel.value.closureReasonText?.trim() || undefined
|
||||
: undefined
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!store.value || !formRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 校验表单
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 提交状态变更
|
||||
saving.value = true
|
||||
try {
|
||||
await fetchToggleBusinessStatus(String(store.value.id), buildPayload())
|
||||
ElMessage.success(t('store.message.statusUpdated'))
|
||||
emit('saved')
|
||||
dialogVisible.value = false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
// 1. 清理校验状态
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
</script>
|
||||
232
src/views/store/store-list/components/StoreDetailDrawer.vue
Normal file
232
src/views/store/store-list/components/StoreDetailDrawer.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
:title="t('store.detail.title')"
|
||||
size="60%"
|
||||
direction="rtl"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="px-4 pb-4" v-loading="loading">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ store?.name || t('store.detail.empty') }}
|
||||
</div>
|
||||
<div class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
{{ store?.code || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTabs v-if="storeId" v-model="activeTab">
|
||||
<ElTabPane :label="t('store.detail.tabs.basic')" name="basic">
|
||||
<ElDescriptions v-if="store" :column="2" border>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.name')">
|
||||
{{ store.name }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.code')">
|
||||
{{ store.code }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.auditStatus')">
|
||||
<ElTag :type="getAuditStatusType(store.auditStatus)">
|
||||
{{ getAuditStatusText(store.auditStatus) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.businessStatus')">
|
||||
<ElTag :type="getBusinessStatusType(store.businessStatus)">
|
||||
{{ getBusinessStatusText(store.businessStatus) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.ownershipType')">
|
||||
{{ getOwnershipTypeText(store.ownershipType) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.phone')">
|
||||
{{ store.phone || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.address')">
|
||||
{{ formatFullAddress(store) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="t('store.detail.fields.createdAt')">
|
||||
{{ formatDateTime(store.createdAt) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElTabPane>
|
||||
<ElTabPane :label="t('store.detail.tabs.qualification')" name="qualification">
|
||||
<StoreQualificationPanel :store-id="storeId" :tenant-id="store?.tenantId" />
|
||||
</ElTabPane>
|
||||
<ElTabPane :label="t('store.detail.tabs.businessHours')" name="businessHours">
|
||||
<BusinessHoursPanel :store-id="storeId" />
|
||||
</ElTabPane>
|
||||
<ElTabPane :label="t('store.detail.tabs.temporaryHours')" name="temporaryHours">
|
||||
<TemporaryHoursPanel :store-id="storeId" />
|
||||
</ElTabPane>
|
||||
<ElTabPane :label="t('store.detail.tabs.deliveryZone')" name="deliveryZone">
|
||||
<DeliveryZoneMapEditor :store-id="storeId" :store="store" />
|
||||
</ElTabPane>
|
||||
<ElTabPane :label="t('store.detail.tabs.fee')" name="fee">
|
||||
<StoreFeePanel :store-id="storeId" />
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
|
||||
<ElEmpty v-else :description="t('store.detail.empty')" />
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElDrawer,
|
||||
ElEmpty,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElTag
|
||||
} from 'element-plus'
|
||||
import { fetchStoreDetail } from '@/api/store'
|
||||
import { StoreAuditStatus } from '@/enums/StoreAuditStatus'
|
||||
import { StoreBusinessStatus } from '@/enums/StoreBusinessStatus'
|
||||
import { StoreOwnershipType } from '@/enums/StoreOwnershipType'
|
||||
import { formatDateTime } from '@/utils/billing'
|
||||
import StoreQualificationPanel from '@/views/store/store-detail/components/StoreQualificationPanel.vue'
|
||||
import BusinessHoursPanel from '@/views/store/store-detail/components/BusinessHoursPanel.vue'
|
||||
import TemporaryHoursPanel from '@/views/store/store-detail/components/TemporaryHoursPanel.vue'
|
||||
import DeliveryZoneMapEditor from '@/views/store/store-detail/components/DeliveryZoneMapEditor.vue'
|
||||
import StoreFeePanel from '@/views/store/store-detail/components/StoreFeePanel.vue'
|
||||
|
||||
defineOptions({ name: 'StoreDetailDrawer' })
|
||||
|
||||
type StoreDetailTab =
|
||||
| 'basic'
|
||||
| 'qualification'
|
||||
| 'businessHours'
|
||||
| 'temporaryHours'
|
||||
| 'deliveryZone'
|
||||
| 'fee'
|
||||
|
||||
// 1. 基础状态
|
||||
const { t } = useI18n()
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const storeId = ref('')
|
||||
const store = ref<Api.Store.StoreDto | null>(null)
|
||||
const activeTab = ref<StoreDetailTab>('basic')
|
||||
const currentLoadToken = ref(0)
|
||||
|
||||
// 2. 详情加载
|
||||
const loadStore = async (token: number) => {
|
||||
if (!storeId.value) {
|
||||
store.value = null
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 1. 拉取门店详情
|
||||
const result = await fetchStoreDetail(storeId.value, { showErrorMessage: false })
|
||||
if (token !== currentLoadToken.value) {
|
||||
return
|
||||
}
|
||||
store.value = result
|
||||
} finally {
|
||||
if (token === currentLoadToken.value) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 抽屉控制
|
||||
const open = async (id: string) => {
|
||||
visible.value = true
|
||||
|
||||
// 1. 初始化抽屉状态
|
||||
storeId.value = String(id)
|
||||
activeTab.value = 'basic'
|
||||
store.value = null
|
||||
|
||||
// 2. 拉取详情数据
|
||||
const token = ++currentLoadToken.value
|
||||
await loadStore(token)
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
// 1. 关闭时清理状态,避免残留
|
||||
currentLoadToken.value += 1
|
||||
storeId.value = ''
|
||||
store.value = null
|
||||
activeTab.value = 'basic'
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const formatFullAddress = (target: Api.Store.StoreDto) =>
|
||||
`${target.province ?? ''}${target.city ?? ''}${target.district ?? ''}${target.address ?? ''}` ||
|
||||
'-'
|
||||
|
||||
// 4. 状态展示
|
||||
const getAuditStatusType = (status: StoreAuditStatus) => {
|
||||
switch (status) {
|
||||
case StoreAuditStatus.Draft:
|
||||
return 'info'
|
||||
case StoreAuditStatus.Pending:
|
||||
return 'warning'
|
||||
case StoreAuditStatus.Activated:
|
||||
return 'success'
|
||||
case StoreAuditStatus.Rejected:
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
const getAuditStatusText = (status: StoreAuditStatus) => {
|
||||
switch (status) {
|
||||
case StoreAuditStatus.Draft:
|
||||
return t('store.auditStatus.draft')
|
||||
case StoreAuditStatus.Pending:
|
||||
return t('store.auditStatus.pending')
|
||||
case StoreAuditStatus.Activated:
|
||||
return t('store.auditStatus.activated')
|
||||
case StoreAuditStatus.Rejected:
|
||||
return t('store.auditStatus.rejected')
|
||||
default:
|
||||
return t('store.auditStatus.unknown')
|
||||
}
|
||||
}
|
||||
const getBusinessStatusType = (status: StoreBusinessStatus) => {
|
||||
switch (status) {
|
||||
case StoreBusinessStatus.Open:
|
||||
return 'success'
|
||||
case StoreBusinessStatus.Resting:
|
||||
return 'warning'
|
||||
case StoreBusinessStatus.ForceClosed:
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
const getBusinessStatusText = (status: StoreBusinessStatus) => {
|
||||
switch (status) {
|
||||
case StoreBusinessStatus.Open:
|
||||
return t('store.businessStatus.open')
|
||||
case StoreBusinessStatus.Resting:
|
||||
return t('store.businessStatus.resting')
|
||||
case StoreBusinessStatus.ForceClosed:
|
||||
return t('store.businessStatus.forceClosed')
|
||||
default:
|
||||
return t('store.businessStatus.unknown')
|
||||
}
|
||||
}
|
||||
const getOwnershipTypeText = (type?: StoreOwnershipType) => {
|
||||
switch (type) {
|
||||
case StoreOwnershipType.SameEntity:
|
||||
return t('store.ownership.same')
|
||||
case StoreOwnershipType.DifferentEntity:
|
||||
return t('store.ownership.different')
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
662
src/views/store/store-list/components/StoreFormDialog.vue
Normal file
662
src/views/store/store-list/components/StoreFormDialog.vue
Normal file
@@ -0,0 +1,662 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? t('store.form.editTitle') : t('store.form.createTitle')"
|
||||
width="850px"
|
||||
destroy-on-close
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="formModel" :rules="formRules" label-width="110px">
|
||||
<ElTabs v-model="activeTab" class="store-form-tabs">
|
||||
<!-- 1. 基础信息 -->
|
||||
<ElTabPane :label="t('store.form.tabs.basic')" name="basic">
|
||||
<div class="grid gap-4 py-4 md:grid-cols-2">
|
||||
<ElFormItem :label="t('store.form.merchantId')" prop="merchantId">
|
||||
<MerchantSelect v-model="formModel.merchantId" placeholder="输入商户名搜索并选择" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.code')" prop="code">
|
||||
<div class="flex w-full gap-2">
|
||||
<ElInput v-model="formModel.code" :placeholder="t('store.form.codePlaceholder')" />
|
||||
<ElButton @click="handleGenerateCode" :icon="Refresh" plain title="重新生成" />
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.name')" prop="name">
|
||||
<ElInput v-model="formModel.name" :placeholder="t('store.form.namePlaceholder')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.phone')" prop="phone">
|
||||
<ElInput v-model="formModel.phone" :placeholder="t('store.form.phonePlaceholder')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.managerName')" prop="managerName">
|
||||
<ElInput
|
||||
v-model="formModel.managerName"
|
||||
:placeholder="t('store.form.managerNamePlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
:label="t('store.form.signboardImageUrl')"
|
||||
prop="signboardImageUrl"
|
||||
class="col-span-full"
|
||||
>
|
||||
<ImageUpload
|
||||
v-model="formModel.signboardImageUrl"
|
||||
upload-type="other"
|
||||
box-width="100%"
|
||||
box-height="150px"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 2. 位置信息 -->
|
||||
<ElTabPane :label="t('store.form.tabs.location')" name="location">
|
||||
<div class="grid gap-4 py-4 md:grid-cols-2">
|
||||
<ElFormItem :label="t('tenant.manualCreate.field.region')" class="col-span-full">
|
||||
<ElCascader
|
||||
v-model="regionCodes"
|
||||
class="w-full"
|
||||
filterable
|
||||
clearable
|
||||
:options="regionOptions"
|
||||
:props="regionProps"
|
||||
:placeholder="t('tenant.manualCreate.placeholder.region')"
|
||||
@change="handleRegionChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.province')" prop="province">
|
||||
<ElInput v-model="formModel.province" disabled />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.city')" prop="city">
|
||||
<ElInput v-model="formModel.city" disabled />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.district')" prop="district">
|
||||
<ElInput v-model="formModel.district" disabled />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.address')" prop="address" class="col-span-full">
|
||||
<ElInput
|
||||
v-model="formModel.address"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('store.form.addressPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
:label="t('store.form.coordinate')"
|
||||
prop="coordinateText"
|
||||
class="col-span-full"
|
||||
>
|
||||
<div class="flex w-full flex-wrap items-center gap-2">
|
||||
<ElInput
|
||||
v-model="formModel.coordinateText"
|
||||
class="min-w-[220px] flex-1"
|
||||
:placeholder="t('store.form.coordinatePlaceholder')"
|
||||
@blur="handleCoordinateBlur"
|
||||
>
|
||||
<template #append>
|
||||
<ElButton :loading="pasting" type="primary" link @click="handlePasteCoordinate">
|
||||
{{ t('store.form.coordinatePasteAction') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElButton plain @click="openCoordinatePicker">
|
||||
{{ t('store.form.coordinatePickerAction') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ t('store.form.coordinateHint') }}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.deliveryRadiusKm')" prop="deliveryRadiusKm">
|
||||
<ElInputNumber
|
||||
v-model="formModel.deliveryRadiusKm"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:step="0.1"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 3. 经营设置 -->
|
||||
<ElTabPane :label="t('store.form.tabs.settings')" name="settings">
|
||||
<div class="grid gap-4 py-4 md:grid-cols-2">
|
||||
<ElFormItem :label="t('store.form.status')" prop="status">
|
||||
<ElSelect v-model="formModel.status" class="w-full">
|
||||
<ElOption
|
||||
v-for="option in statusOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.ownershipType')" prop="ownershipType">
|
||||
<ElSelect v-model="formModel.ownershipType" class="w-full" :disabled="isEdit">
|
||||
<ElOption
|
||||
v-for="option in ownershipTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.categoryId')" prop="categoryId">
|
||||
<CategorySelect
|
||||
v-model="formModel.categoryId"
|
||||
:placeholder="t('store.form.categoryIdPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.announcement')" prop="announcement">
|
||||
<ElInput
|
||||
v-model="formModel.announcement"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('store.form.announcementPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('store.form.tags')" prop="tags">
|
||||
<ElInput v-model="formModel.tags" :placeholder="t('store.form.tagsPlaceholder')" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 4. 服务设施 -->
|
||||
<ElTabPane :label="t('store.form.tabs.services')" name="services">
|
||||
<div class="grid gap-6 py-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="item in serviceOptions"
|
||||
:key="item.prop"
|
||||
class="flex items-center justify-between rounded-lg border border-[var(--el-border-color-lighter)] p-4 transition-all hover:bg-[var(--el-fill-color-lighter)]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]"
|
||||
>
|
||||
<ArtSvgIcon :icon="item.icon" :size="20" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium">{{ t(`store.form.${item.prop}`) }}</span>
|
||||
<span class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ item.desc }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ElSwitch v-model="formModel[item.prop]" />
|
||||
</div>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton v-if="currentTabIndex > 0" @click="handlePrev">
|
||||
{{ t('common.prevStep') }}
|
||||
</ElButton>
|
||||
<ElButton v-if="!isLastTab" type="primary" @click="handleNext">
|
||||
{{ t('common.nextStep') }}
|
||||
</ElButton>
|
||||
<ElButton v-else type="primary" :loading="saving" @click="handleSubmit">
|
||||
{{ t('common.confirm') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTabs,
|
||||
ElTabPane
|
||||
} from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { fetchCreateStore, fetchUpdateStore } from '@/api/store'
|
||||
import { StoreOwnershipType } from '@/enums/StoreOwnershipType'
|
||||
import { StoreStatus } from '@/enums/StoreStatus'
|
||||
import type { FormInstance, FormRules, CascaderValue, CascaderOption } from 'element-plus'
|
||||
import ImageUpload from '@/components/common/ImageUpload.vue'
|
||||
import MerchantSelect from '@/components/common/MerchantSelect.vue'
|
||||
import CategorySelect from '@/components/common/CategorySelect.vue'
|
||||
import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
|
||||
import { regionData, codeToText } from 'element-china-area-data'
|
||||
|
||||
defineOptions({ name: 'StoreFormDialog' })
|
||||
|
||||
interface StoreFormState {
|
||||
merchantId: string
|
||||
code: string
|
||||
name: string
|
||||
phone?: string
|
||||
managerName?: string
|
||||
status: StoreStatus
|
||||
signboardImageUrl: string
|
||||
ownershipType: StoreOwnershipType
|
||||
categoryId?: string
|
||||
province?: string
|
||||
city?: string
|
||||
district?: string
|
||||
address?: string
|
||||
longitude?: number
|
||||
latitude?: number
|
||||
coordinateText: string
|
||||
announcement?: string
|
||||
tags?: string
|
||||
deliveryRadiusKm: number
|
||||
supportsDineIn: boolean
|
||||
supportsPickup: boolean
|
||||
supportsDelivery: boolean
|
||||
supportsReservation: boolean
|
||||
supportsQueueing: boolean
|
||||
}
|
||||
|
||||
// 1. 基础输入输出
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
store?: Api.Store.StoreDto | null
|
||||
}>(),
|
||||
{ store: null }
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'saved'): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const { modelValue, store } = toRefs(props)
|
||||
|
||||
const activeTab = ref('basic')
|
||||
const regionCodes = ref<CascaderValue>([])
|
||||
|
||||
/** 随机生成门店编码 */
|
||||
const handleGenerateCode = () => {
|
||||
const timestamp = Date.now().toString(36).toUpperCase()
|
||||
const random = Math.random().toString(36).substring(2, 5).toUpperCase()
|
||||
formModel.value.code = `S${timestamp}${random}`
|
||||
}
|
||||
|
||||
const regionProps = {
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children'
|
||||
} as const
|
||||
const regionOptions = regionData as unknown as CascaderOption[]
|
||||
|
||||
/** 处理行政区划变更 */
|
||||
const handleRegionChange = (value: CascaderValue | null | undefined) => {
|
||||
const codes = (Array.isArray(value) ? value : []) as string[]
|
||||
const [pCode, cCode, dCode] = codes
|
||||
|
||||
formModel.value.province = pCode ? (codeToText as any)[pCode] || '' : ''
|
||||
formModel.value.city = cCode ? (codeToText as any)[cCode] || '' : ''
|
||||
formModel.value.district = dCode ? (codeToText as any)[dCode] || '' : ''
|
||||
}
|
||||
|
||||
const createDefaultForm = (): StoreFormState => ({
|
||||
merchantId: '',
|
||||
code: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
managerName: '',
|
||||
status: StoreStatus.Closed,
|
||||
signboardImageUrl: '',
|
||||
ownershipType: StoreOwnershipType.SameEntity,
|
||||
categoryId: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
longitude: undefined,
|
||||
latitude: undefined,
|
||||
coordinateText: '',
|
||||
announcement: '',
|
||||
tags: '',
|
||||
deliveryRadiusKm: 0,
|
||||
supportsDineIn: true,
|
||||
supportsPickup: true,
|
||||
supportsDelivery: true,
|
||||
supportsReservation: false,
|
||||
supportsQueueing: false
|
||||
})
|
||||
|
||||
// 2. 表单状态
|
||||
const formRef = ref<FormInstance>()
|
||||
const saving = ref(false)
|
||||
const formModel = ref<StoreFormState>(createDefaultForm())
|
||||
const pasting = ref(false)
|
||||
|
||||
// 3. 下拉选项
|
||||
const ownershipTypeOptions = [
|
||||
{ value: StoreOwnershipType.SameEntity, label: t('store.ownership.same') },
|
||||
{ value: StoreOwnershipType.DifferentEntity, label: t('store.ownership.different') }
|
||||
]
|
||||
const statusOptions = [
|
||||
{ value: StoreStatus.Closed, label: t('store.status.closed') },
|
||||
{ value: StoreStatus.Preparing, label: t('store.status.preparing') },
|
||||
{ value: StoreStatus.Operating, label: t('store.status.operating') },
|
||||
{ value: StoreStatus.Suspended, label: t('store.status.suspended') }
|
||||
]
|
||||
|
||||
const serviceOptions: { prop: keyof StoreFormState; icon: string; desc: string }[] = [
|
||||
{ prop: 'supportsDineIn', icon: 'ri:restaurant-line', desc: '店内堂食服务' },
|
||||
{ prop: 'supportsPickup', icon: 'ri:shopping-bag-3-line', desc: '到店自提服务' },
|
||||
{ prop: 'supportsDelivery', icon: 'ri:bike-line', desc: '外卖配送服务' },
|
||||
{ prop: 'supportsReservation', icon: 'ri:calendar-check-line', desc: '提前预约订座' },
|
||||
{ prop: 'supportsQueueing', icon: 'ri:group-line', desc: '在线排队取号' }
|
||||
]
|
||||
|
||||
// 4. 校验规则
|
||||
const formRules: FormRules<StoreFormState> = {
|
||||
merchantId: [{ required: true, message: t('store.rules.merchantId'), trigger: 'blur' }],
|
||||
code: [{ required: true, message: t('store.rules.code'), trigger: 'blur' }],
|
||||
name: [{ required: true, message: t('store.rules.name'), trigger: 'blur' }],
|
||||
signboardImageUrl: [
|
||||
{ required: true, message: t('store.rules.signboardImageUrl'), trigger: 'blur' }
|
||||
],
|
||||
ownershipType: [{ required: true, message: t('store.rules.ownershipType'), trigger: 'change' }],
|
||||
coordinateText: [
|
||||
{ required: true, message: t('store.rules.coordinateText'), trigger: 'blur' },
|
||||
{
|
||||
validator: (_, value, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(t('store.rules.coordinateText')))
|
||||
return
|
||||
}
|
||||
const parsed = parseCoordinateText(value)
|
||||
if (!parsed) {
|
||||
callback(new Error(t('store.rules.coordinateTextFormat')))
|
||||
return
|
||||
}
|
||||
formModel.value.longitude = parsed.lng
|
||||
formModel.value.latitude = parsed.lat
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 5. 视图计算
|
||||
const isEdit = computed(() => Boolean(store.value))
|
||||
const dialogVisible = computed({
|
||||
get: () => modelValue.value,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
const normalizeCoordinateText = (lat: number, lng: number) =>
|
||||
`${lat.toFixed(6)},${lng.toFixed(6)}`
|
||||
|
||||
const parseCoordinateText = (value: string): { lat: number; lng: number } | null => {
|
||||
if (!value?.trim()) return null
|
||||
const parts = value
|
||||
.trim()
|
||||
.replace(/[,\s]+/g, ',')
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
if (parts.length < 2) return null
|
||||
const first = Number(parts[0])
|
||||
const second = Number(parts[1])
|
||||
if (Number.isNaN(first) || Number.isNaN(second)) return null
|
||||
const absFirst = Math.abs(first)
|
||||
const absSecond = Math.abs(second)
|
||||
const isLngLat = absFirst > 90 && absSecond <= 90
|
||||
const isLatLng = absFirst <= 90 && absSecond <= 180
|
||||
if (isLngLat) {
|
||||
return { lat: second, lng: first }
|
||||
}
|
||||
if (isLatLng) {
|
||||
return { lat: first, lng: second }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const syncCoordinateText = (value: string, normalize = false) => {
|
||||
const parsed = parseCoordinateText(value)
|
||||
if (!parsed) return false
|
||||
formModel.value.longitude = parsed.lng
|
||||
formModel.value.latitude = parsed.lat
|
||||
if (normalize) {
|
||||
formModel.value.coordinateText = normalizeCoordinateText(parsed.lat, parsed.lng)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleCoordinateBlur = () => {
|
||||
if (!formModel.value.coordinateText) return
|
||||
syncCoordinateText(formModel.value.coordinateText, true)
|
||||
}
|
||||
|
||||
const handlePasteCoordinate = async () => {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
ElMessage.warning(t('store.form.coordinatePasteUnavailable'))
|
||||
return
|
||||
}
|
||||
pasting.value = true
|
||||
try {
|
||||
const text = (await navigator.clipboard.readText())?.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning(t('store.form.coordinatePasteEmpty'))
|
||||
return
|
||||
}
|
||||
formModel.value.coordinateText = text
|
||||
if (!syncCoordinateText(text, true)) {
|
||||
ElMessage.warning(t('store.form.coordinateInvalid'))
|
||||
}
|
||||
} catch {
|
||||
ElMessage.warning(t('store.form.coordinatePasteUnavailable'))
|
||||
} finally {
|
||||
pasting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCoordinatePicker = () => {
|
||||
window.open('https://lbs.qq.com/getPoint/', '_blank')
|
||||
}
|
||||
|
||||
// Tab 顺序定义
|
||||
const tabs = ['basic', 'location', 'settings', 'services']
|
||||
const currentTabIndex = computed(() => tabs.indexOf(activeTab.value))
|
||||
const isLastTab = computed(() => currentTabIndex.value === tabs.length - 1)
|
||||
|
||||
// 每个 Tab 对应的需要校验的字段
|
||||
const tabFields: Record<string, (keyof StoreFormState)[]> = {
|
||||
basic: ['merchantId', 'code', 'name', 'phone', 'managerName', 'signboardImageUrl'],
|
||||
location: ['province', 'city', 'district', 'address', 'coordinateText', 'deliveryRadiusKm'],
|
||||
settings: ['status', 'ownershipType', 'categoryId', 'announcement', 'tags'],
|
||||
services: [
|
||||
'supportsDineIn',
|
||||
'supportsPickup',
|
||||
'supportsDelivery',
|
||||
'supportsReservation',
|
||||
'supportsQueueing'
|
||||
]
|
||||
}
|
||||
|
||||
// 6. 同步编辑数据
|
||||
watch(
|
||||
store,
|
||||
(current) => {
|
||||
if (!current) {
|
||||
formModel.value = createDefaultForm()
|
||||
handleGenerateCode() // 新增时生成编码
|
||||
regionCodes.value = []
|
||||
return
|
||||
}
|
||||
formModel.value = {
|
||||
merchantId: String(current.merchantId),
|
||||
code: current.code,
|
||||
name: current.name,
|
||||
phone: current.phone ?? '',
|
||||
managerName: current.managerName ?? '',
|
||||
status: current.status,
|
||||
signboardImageUrl: current.signboardImageUrl ?? '',
|
||||
ownershipType: current.ownershipType,
|
||||
categoryId: current.categoryId ? String(current.categoryId) : '',
|
||||
province: current.province ?? '',
|
||||
city: current.city ?? '',
|
||||
district: current.district ?? '',
|
||||
address: current.address ?? '',
|
||||
longitude: current.longitude ?? undefined,
|
||||
latitude: current.latitude ?? undefined,
|
||||
coordinateText:
|
||||
current.latitude !== null &&
|
||||
current.latitude !== undefined &&
|
||||
current.longitude !== null &&
|
||||
current.longitude !== undefined
|
||||
? normalizeCoordinateText(current.latitude, current.longitude)
|
||||
: '',
|
||||
announcement: current.announcement ?? '',
|
||||
tags: current.tags ?? '',
|
||||
deliveryRadiusKm: current.deliveryRadiusKm,
|
||||
supportsDineIn: current.supportsDineIn,
|
||||
supportsPickup: current.supportsPickup,
|
||||
supportsDelivery: current.supportsDelivery,
|
||||
supportsReservation: current.supportsReservation,
|
||||
supportsQueueing: current.supportsQueueing
|
||||
}
|
||||
|
||||
// 如果有详细位置信息,尝试反推级联选择器的值(这里暂不处理反推,因为 codeToText 是单向的,
|
||||
// 除非后端存了区域编码。通常建议后端存区域编码以支持回显)
|
||||
regionCodes.value = []
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const buildCreatePayload = (): Api.Store.CreateStoreRequest => ({
|
||||
merchantId: formModel.value.merchantId.trim(),
|
||||
code: formModel.value.code.trim(),
|
||||
name: formModel.value.name.trim(),
|
||||
phone: formModel.value.phone?.trim() || undefined,
|
||||
managerName: formModel.value.managerName?.trim() || undefined,
|
||||
status: formModel.value.status,
|
||||
signboardImageUrl: formModel.value.signboardImageUrl.trim(),
|
||||
ownershipType: formModel.value.ownershipType,
|
||||
categoryId: formModel.value.categoryId?.trim() || undefined,
|
||||
province: formModel.value.province?.trim() || undefined,
|
||||
city: formModel.value.city?.trim() || undefined,
|
||||
district: formModel.value.district?.trim() || undefined,
|
||||
address: formModel.value.address?.trim() || undefined,
|
||||
longitude: formModel.value.longitude ?? undefined,
|
||||
latitude: formModel.value.latitude ?? undefined,
|
||||
announcement: formModel.value.announcement?.trim() || undefined,
|
||||
tags: formModel.value.tags?.trim() || undefined,
|
||||
deliveryRadiusKm: formModel.value.deliveryRadiusKm,
|
||||
supportsDineIn: formModel.value.supportsDineIn,
|
||||
supportsPickup: formModel.value.supportsPickup,
|
||||
supportsDelivery: formModel.value.supportsDelivery,
|
||||
supportsReservation: formModel.value.supportsReservation,
|
||||
supportsQueueing: formModel.value.supportsQueueing
|
||||
})
|
||||
|
||||
const buildUpdatePayload = (): Api.Store.UpdateStoreRequest => ({
|
||||
merchantId: formModel.value.merchantId.trim(),
|
||||
code: formModel.value.code.trim(),
|
||||
name: formModel.value.name.trim(),
|
||||
phone: formModel.value.phone?.trim() || undefined,
|
||||
managerName: formModel.value.managerName?.trim() || undefined,
|
||||
status: formModel.value.status,
|
||||
signboardImageUrl: formModel.value.signboardImageUrl.trim(),
|
||||
categoryId: formModel.value.categoryId?.trim() || undefined,
|
||||
province: formModel.value.province?.trim() || undefined,
|
||||
city: formModel.value.city?.trim() || undefined,
|
||||
district: formModel.value.district?.trim() || undefined,
|
||||
address: formModel.value.address?.trim() || undefined,
|
||||
longitude: formModel.value.longitude ?? undefined,
|
||||
latitude: formModel.value.latitude ?? undefined,
|
||||
announcement: formModel.value.announcement?.trim() || undefined,
|
||||
tags: formModel.value.tags?.trim() || undefined,
|
||||
deliveryRadiusKm: formModel.value.deliveryRadiusKm,
|
||||
supportsDineIn: formModel.value.supportsDineIn,
|
||||
supportsPickup: formModel.value.supportsPickup,
|
||||
supportsDelivery: formModel.value.supportsDelivery,
|
||||
supportsReservation: formModel.value.supportsReservation,
|
||||
supportsQueueing: formModel.value.supportsQueueing
|
||||
})
|
||||
|
||||
/** 下一步 */
|
||||
const handleNext = async () => {
|
||||
if (!formRef.value) return
|
||||
const currentTabName = activeTab.value
|
||||
const fieldsToValidate = tabFields[currentTabName]
|
||||
|
||||
// 校验当前页字段
|
||||
if (fieldsToValidate) {
|
||||
try {
|
||||
await formRef.value.validateField(fieldsToValidate)
|
||||
} catch {
|
||||
return // 校验失败不跳转
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTabIndex.value < tabs.length - 1) {
|
||||
activeTab.value = tabs[currentTabIndex.value + 1]
|
||||
}
|
||||
}
|
||||
|
||||
/** 上一步 */
|
||||
const handlePrev = () => {
|
||||
if (currentTabIndex.value > 0) {
|
||||
activeTab.value = tabs[currentTabIndex.value - 1]
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (formModel.value.coordinateText) {
|
||||
syncCoordinateText(formModel.value.coordinateText, true)
|
||||
}
|
||||
|
||||
// 1. 校验表单 (最后提交时再全量校验一次,以防万一)
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 执行提交
|
||||
saving.value = true
|
||||
try {
|
||||
if (store.value) {
|
||||
await fetchUpdateStore(String(store.value.id), buildUpdatePayload())
|
||||
ElMessage.success(t('store.message.updateSuccess'))
|
||||
} else {
|
||||
await fetchCreateStore(buildCreatePayload())
|
||||
ElMessage.success(t('store.message.createSuccess'))
|
||||
}
|
||||
emit('saved')
|
||||
dialogVisible.value = false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
// 1. 清理校验状态
|
||||
formRef.value?.clearValidate()
|
||||
|
||||
// 2. 重置 Tab 和数据
|
||||
activeTab.value = 'basic'
|
||||
if (!store.value) {
|
||||
formModel.value = createDefaultForm()
|
||||
regionCodes.value = []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.store-form-tabs {
|
||||
:deep(.el-tabs__content) {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
326
src/views/store/store-list/index.vue
Normal file
326
src/views/store/store-list/index.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="art-page-view">
|
||||
<StoreSearch v-model="formFilters" @search="handleSearch" @reset="handleReset" />
|
||||
|
||||
<ElCard class="art-table-card" shadow="never">
|
||||
<ArtTableHeader
|
||||
v-model:columns="columns"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #actions>
|
||||
<ElButton type="primary" @click="handleCreate">
|
||||
{{ t('store.list.action.create') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
>
|
||||
<template #auditStatus="{ row }">
|
||||
<ElTag :type="getAuditStatusType(row.auditStatus)">
|
||||
{{ getAuditStatusText(row.auditStatus) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<template #businessStatus="{ row }">
|
||||
<ElTag :type="getBusinessStatusType(row.businessStatus)">
|
||||
{{ getBusinessStatusText(row.businessStatus) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<template #ownershipType="{ row }">
|
||||
<span>{{ getOwnershipTypeText(row.ownershipType) }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<div class="action-wrap">
|
||||
<button class="art-action-btn" @click="handleDetail(row)">
|
||||
<ArtSvgIcon icon="ri:information-line" class="art-action-icon" />
|
||||
<span>{{ t('dictionary.common.detail') }}</span>
|
||||
</button>
|
||||
<button class="art-action-btn primary" @click="handleEdit(row)">
|
||||
<ArtSvgIcon icon="ri:edit-2-line" class="art-action-icon" />
|
||||
<span>{{ t('store.list.action.edit') }}</span>
|
||||
</button>
|
||||
|
||||
<ElDropdown trigger="click" @command="(cmd) => handleCommand(cmd, row)">
|
||||
<button class="art-action-btn info">
|
||||
<ArtSvgIcon icon="ri:more-2-line" class="art-action-icon" />
|
||||
<span>{{ t('user.action.more') }}</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
:disabled="row.businessStatus === StoreBusinessStatus.ForceClosed"
|
||||
command="toggleStatus"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ArtSvgIcon icon="ri:shut-down-line" />
|
||||
<span>{{ t('store.list.action.toggleStatus') }}</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="canSubmitAudit(row)"
|
||||
:disabled="submittingId === row.id"
|
||||
command="submitAudit"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ArtSvgIcon icon="ri:check-double-line" />
|
||||
<span>{{ t('store.list.action.submitAudit') }}</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
|
||||
<StoreFormDialog v-model="formDialogVisible" :store="editingStore" @saved="refreshData" />
|
||||
<BusinessStatusDialog v-model="statusDialogVisible" :store="statusStore" @saved="refreshData" />
|
||||
<StoreDetailDrawer ref="detailDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElMessage,
|
||||
ElTag,
|
||||
ElDropdown,
|
||||
ElDropdownMenu,
|
||||
ElDropdownItem
|
||||
} from 'element-plus'
|
||||
import ArtTable from '@/components/core/tables/art-table/index.vue'
|
||||
import ArtTableHeader from '@/components/core/tables/art-table-header/index.vue'
|
||||
import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
|
||||
import { useTable } from '@/hooks/core/useTable'
|
||||
import { fetchStoreList, fetchSubmitStoreAudit } from '@/api/store'
|
||||
import { StoreAuditStatus } from '@/enums/StoreAuditStatus'
|
||||
import { StoreBusinessStatus } from '@/enums/StoreBusinessStatus'
|
||||
import { StoreOwnershipType } from '@/enums/StoreOwnershipType'
|
||||
import { formatDateTime } from '@/utils/billing'
|
||||
import StoreFormDialog from './components/StoreFormDialog.vue'
|
||||
import BusinessStatusDialog from './components/BusinessStatusDialog.vue'
|
||||
import StoreDetailDrawer from './components/StoreDetailDrawer.vue'
|
||||
import StoreSearch from './modules/store-search.vue'
|
||||
import type { ColumnOption } from '@/types/component'
|
||||
|
||||
defineOptions({ name: 'StoreList' })
|
||||
|
||||
interface StoreListFilters {
|
||||
keyword: string
|
||||
merchantId: string
|
||||
auditStatus?: StoreAuditStatus
|
||||
businessStatus?: StoreBusinessStatus
|
||||
ownershipType?: StoreOwnershipType
|
||||
}
|
||||
|
||||
// 1. 基础状态
|
||||
const { t } = useI18n()
|
||||
const showSearchBar = ref(true)
|
||||
const formDialogVisible = ref(false)
|
||||
const statusDialogVisible = ref(false)
|
||||
const editingStore = ref<Api.Store.StoreDto | null>(null)
|
||||
const statusStore = ref<Api.Store.StoreDto | null>(null)
|
||||
const submittingId = ref<string>()
|
||||
const detailDrawerRef = ref<InstanceType<typeof StoreDetailDrawer>>()
|
||||
|
||||
// 2. 搜索筛选
|
||||
const formFilters = ref<StoreListFilters>({
|
||||
keyword: '',
|
||||
merchantId: '',
|
||||
auditStatus: undefined,
|
||||
businessStatus: undefined,
|
||||
ownershipType: undefined
|
||||
})
|
||||
|
||||
// 3. 表格配置
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
columns,
|
||||
searchParams,
|
||||
refreshData,
|
||||
handleCurrentChange,
|
||||
handleSizeChange
|
||||
} = useTable<typeof fetchStoreList, Api.Store.StoreDto>({
|
||||
core: {
|
||||
apiFn: fetchStoreList,
|
||||
apiParams: {
|
||||
Page: 1,
|
||||
PageSize: 20
|
||||
},
|
||||
paginationKey: {
|
||||
current: 'Page',
|
||||
size: 'PageSize'
|
||||
},
|
||||
columnsFactory: (): ColumnOption<Api.Store.StoreDto>[] => [
|
||||
{ type: 'globalIndex', label: '#', width: 60, fixed: 'left' },
|
||||
{ prop: 'name', label: t('store.list.table.name'), minWidth: 160 },
|
||||
{ prop: 'code', label: t('store.list.table.code'), minWidth: 140 },
|
||||
{
|
||||
prop: 'ownershipType',
|
||||
label: t('store.list.table.ownershipType'),
|
||||
width: 140,
|
||||
useSlot: true
|
||||
},
|
||||
{
|
||||
prop: 'auditStatus',
|
||||
label: t('store.list.table.auditStatus'),
|
||||
width: 120,
|
||||
useSlot: true
|
||||
},
|
||||
{
|
||||
prop: 'businessStatus',
|
||||
label: t('store.list.table.businessStatus'),
|
||||
width: 120,
|
||||
useSlot: true
|
||||
},
|
||||
{ prop: 'phone', label: t('store.list.table.phone'), minWidth: 140 },
|
||||
{
|
||||
prop: 'createdAt',
|
||||
label: t('store.list.table.createdAt'),
|
||||
width: 180,
|
||||
formatter: (row) => formatDateTime(row.createdAt)
|
||||
},
|
||||
{ prop: 'action', label: t('common.action'), width: 280, fixed: 'right', useSlot: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 5. 搜索与操作
|
||||
const handleSearch = () => {
|
||||
searchParams.Keyword = formFilters.value.keyword.trim() || undefined
|
||||
searchParams.MerchantId = formFilters.value.merchantId.trim() || undefined
|
||||
searchParams.AuditStatus = formFilters.value.auditStatus
|
||||
searchParams.BusinessStatus = formFilters.value.businessStatus
|
||||
searchParams.OwnershipType = formFilters.value.ownershipType
|
||||
refreshData()
|
||||
}
|
||||
const handleReset = () => {
|
||||
formFilters.value = {
|
||||
keyword: '',
|
||||
merchantId: '',
|
||||
auditStatus: undefined,
|
||||
businessStatus: undefined,
|
||||
ownershipType: undefined
|
||||
}
|
||||
searchParams.Keyword = undefined
|
||||
searchParams.MerchantId = undefined
|
||||
searchParams.AuditStatus = undefined
|
||||
searchParams.BusinessStatus = undefined
|
||||
searchParams.OwnershipType = undefined
|
||||
refreshData()
|
||||
}
|
||||
const handleCreate = () => {
|
||||
editingStore.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
const handleEdit = (row: Api.Store.StoreDto) => {
|
||||
editingStore.value = row
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
const handleToggleStatus = (row: Api.Store.StoreDto) => {
|
||||
statusStore.value = row
|
||||
statusDialogVisible.value = true
|
||||
}
|
||||
const handleDetail = (row: Api.Store.StoreDto) => {
|
||||
detailDrawerRef.value?.open(row.id)
|
||||
}
|
||||
|
||||
const handleCommand = (command: string | number | object, row: Api.Store.StoreDto) => {
|
||||
if (command === 'toggleStatus') {
|
||||
handleToggleStatus(row)
|
||||
} else if (command === 'submitAudit') {
|
||||
handleSubmitAudit(row)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmitAudit = (row: Api.Store.StoreDto) =>
|
||||
row.ownershipType === StoreOwnershipType.DifferentEntity &&
|
||||
[StoreAuditStatus.Draft, StoreAuditStatus.Rejected].includes(row.auditStatus)
|
||||
const handleSubmitAudit = async (row: Api.Store.StoreDto) => {
|
||||
submittingId.value = row.id
|
||||
try {
|
||||
await fetchSubmitStoreAudit(row.id)
|
||||
ElMessage.success(t('store.message.submitAuditSuccess'))
|
||||
} finally {
|
||||
submittingId.value = undefined
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
// 6. 状态显示
|
||||
const getAuditStatusType = (status: StoreAuditStatus) => {
|
||||
switch (status) {
|
||||
case StoreAuditStatus.Draft:
|
||||
return 'info'
|
||||
case StoreAuditStatus.Pending:
|
||||
return 'warning'
|
||||
case StoreAuditStatus.Activated:
|
||||
return 'success'
|
||||
case StoreAuditStatus.Rejected:
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
const getAuditStatusText = (status: StoreAuditStatus) => {
|
||||
switch (status) {
|
||||
case StoreAuditStatus.Draft:
|
||||
return t('store.auditStatus.draft')
|
||||
case StoreAuditStatus.Pending:
|
||||
return t('store.auditStatus.pending')
|
||||
case StoreAuditStatus.Activated:
|
||||
return t('store.auditStatus.activated')
|
||||
case StoreAuditStatus.Rejected:
|
||||
return t('store.auditStatus.rejected')
|
||||
default:
|
||||
return t('store.auditStatus.unknown')
|
||||
}
|
||||
}
|
||||
const getBusinessStatusType = (status: StoreBusinessStatus) => {
|
||||
switch (status) {
|
||||
case StoreBusinessStatus.Open:
|
||||
return 'success'
|
||||
case StoreBusinessStatus.Resting:
|
||||
return 'warning'
|
||||
case StoreBusinessStatus.ForceClosed:
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
const getBusinessStatusText = (status: StoreBusinessStatus) => {
|
||||
switch (status) {
|
||||
case StoreBusinessStatus.Open:
|
||||
return t('store.businessStatus.open')
|
||||
case StoreBusinessStatus.Resting:
|
||||
return t('store.businessStatus.resting')
|
||||
case StoreBusinessStatus.ForceClosed:
|
||||
return t('store.businessStatus.forceClosed')
|
||||
default:
|
||||
return t('store.businessStatus.unknown')
|
||||
}
|
||||
}
|
||||
const getOwnershipTypeText = (type?: StoreOwnershipType) => {
|
||||
switch (type) {
|
||||
case StoreOwnershipType.SameEntity:
|
||||
return t('store.ownership.same')
|
||||
case StoreOwnershipType.DifferentEntity:
|
||||
return t('store.ownership.different')
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
111
src/views/store/store-list/modules/store-search.vue
Normal file
111
src/views/store/store-list/modules/store-search.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<ArtSearchBar
|
||||
ref="searchBarRef"
|
||||
v-model="formData"
|
||||
:items="formItems"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { StoreAuditStatus } from '@/enums/StoreAuditStatus'
|
||||
import { StoreBusinessStatus } from '@/enums/StoreBusinessStatus'
|
||||
import { StoreOwnershipType } from '@/enums/StoreOwnershipType'
|
||||
|
||||
defineOptions({ name: 'StoreSearch' })
|
||||
|
||||
interface StoreListFilters {
|
||||
keyword: string
|
||||
merchantId: string
|
||||
auditStatus?: StoreAuditStatus
|
||||
businessStatus?: StoreBusinessStatus
|
||||
ownershipType?: StoreOwnershipType
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: StoreListFilters
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: StoreListFilters): void
|
||||
(e: 'search', params: StoreListFilters): void
|
||||
(e: 'reset'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const searchBarRef = ref()
|
||||
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const formItems = computed(() => [
|
||||
{
|
||||
label: t('store.list.search.keyword'),
|
||||
key: 'keyword',
|
||||
type: 'input',
|
||||
placeholder: t('store.list.search.keywordPlaceholder'),
|
||||
width: '240px',
|
||||
clearable: true
|
||||
},
|
||||
{
|
||||
label: t('store.list.search.merchantId'),
|
||||
key: 'merchantId',
|
||||
type: 'input',
|
||||
placeholder: t('store.list.search.merchantIdPlaceholder'),
|
||||
width: '200px',
|
||||
clearable: true
|
||||
},
|
||||
{
|
||||
label: t('store.list.search.auditStatus'),
|
||||
key: 'auditStatus',
|
||||
type: 'select',
|
||||
width: '180px',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ value: StoreAuditStatus.Draft, label: t('store.auditStatus.draft') },
|
||||
{ value: StoreAuditStatus.Pending, label: t('store.auditStatus.pending') },
|
||||
{ value: StoreAuditStatus.Activated, label: t('store.auditStatus.activated') },
|
||||
{ value: StoreAuditStatus.Rejected, label: t('store.auditStatus.rejected') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('store.list.search.businessStatus'),
|
||||
key: 'businessStatus',
|
||||
type: 'select',
|
||||
width: '180px',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ value: StoreBusinessStatus.Open, label: t('store.businessStatus.open') },
|
||||
{ value: StoreBusinessStatus.Resting, label: t('store.businessStatus.resting') },
|
||||
{ value: StoreBusinessStatus.ForceClosed, label: t('store.businessStatus.forceClosed') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('store.list.search.ownershipType'),
|
||||
key: 'ownershipType',
|
||||
type: 'select',
|
||||
width: '180px',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ value: StoreOwnershipType.SameEntity, label: t('store.ownership.same') },
|
||||
{ value: StoreOwnershipType.DifferentEntity, label: t('store.ownership.different') }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const handleSearch = () => {
|
||||
emit('search', formData.value)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user