chore: 初始化平台管理端
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user