chore: 初始化平台管理端

This commit is contained in:
msumshk
2026-01-29 04:21:09 +00:00
commit 914dcc4166
533 changed files with 104838 additions and 0 deletions

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>