feat(shopExpressTemplateDetail): 支持省市两级多选配送区域

- 新增 model 字段 provinceIds 和 cityIds,兼容旧单值 provinceId 和 cityId
- 引入 RegionMultiSelect 组件替代旧的单选级联选择器
- 编辑弹窗支持多选城市和省份,提交时转换为逗号分隔字符串
- 列表页配送区域列支持多选展示,超长文本截断并显示 tooltip
- 新增 RegionMultiSelect 组件,实现省市两级树形多选穿梭弹窗
- 通过 computed 和 watch 同步组件选中状态和表单数据
- 更新相关类型定义和导入,确保编辑及展示逻辑正确
- 新增样式优化,提示默认配送全国规则说明
This commit is contained in:
2026-08-11 09:01:30 +08:00
parent aae0c03dd8
commit ae2ea4d841
6 changed files with 493 additions and 31 deletions
+28
View File
@@ -0,0 +1,28 @@
# 2026-08-11 工作记录
## 运费模板配送地区改为「省+市两级多选穿梭弹窗」
### 需求背景
客户要求运费模板的配送地区选择改成仿截图的双栏穿梭弹窗(左侧候选 + 右侧已选),
选择粒度从原「省+市单选(级联)」升级为「多条规则覆盖多城市」,并保留「不选=全国」规则。
### 改动清单(纯前端,后端字段由用户确认可改)
1. **新建组件** `src/components/RegionMultiSelect/index.vue`
- 树形可展开:省级可展开看市级;省级"选择"=选该省全部市,市级可单独选。
- 左栏搜索(按省/市名过滤)、右栏已选列表(单项是"省/市"+取消)。
- 底部:全部选择 / 取消 / 确认。
- `v-model:value` 绑定已选**城市ID数组**;额外 emit `update:provinceIds`(由城市推导的省份ID数组)。
- 复用 `src/components/RegionsSelect/load-data.ts` 的 getRegionsData()public/json/regions-data.json,取省+市两级,丢弃区级)。
2. **model** `src/api/shop/shopExpressTemplateDetail/model/index.ts`
- 新增 `provinceIds?: string`(逗号分隔)、`cityIds?: string`(逗号分隔);保留旧 `provinceId/cityId` 兼容历史数据。
3. **编辑弹窗** `src/views/shop/shopExpressTemplateDetail/components/shopExpressTemplateDetailEdit.vue`
- 移除 `RegionsSelect type="provinceCity"`,改用 `RegionMultiSelect`
- 打开时从 `record.cityIds`(兼容旧 `cityId`)回填;保存时数组→逗号串提交 `cityIds/provinceIds`
4. **列表页** `src/views/shop/shopExpressTemplateDetail/index.vue`
- "配送地区"列 customRender 改为遍历 `cityIds` 多值反查 regionMap;超22字截断+tooltip;旧单值数据兼容。
- 新增 `h` 函数 import。
### 待办 / 风险
- **后端需同步改造**:表加 `province_ids` / `city_ids`(varchar 逗号分隔),接口 add/update 接收这两个字段。前端已按此约定提交,后端未改则数据落不了库。
- antd 为 v4`var(--ant-color-border)` 在暗色下可能回退浅灰(边框略浅,不影响功能);如要完美暗色适配可后续改 less 变量。
- 构建验证(vite build)已触发,待结果确认无编译错误。
+8
View File
@@ -55,3 +55,11 @@
- 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。 - 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。
- 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。 - 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。
### 运费模板配送地区多选(2026-08-11 已实现前端)
- 需求:配送地区选择从「省+市单选级联」升级为「省+市两级多选穿梭弹窗」(仿客户截图双栏:左候选/右已选)。
- 前端组件:`src/components/RegionMultiSelect/index.vue`(树形可展开,省级"选择"=选该省全部市,市级可单独选;@update:value 城市ID数组 + @update:provinceIds 省份ID数组)。
- 数据字段:model 新增 `provinceIds` / `cityIds`(逗号分隔字符串);保留旧 `provinceId`/`cityId` 兼容(编辑回显时旧单值会自动补进 cityIds)。
- 列表多值展示:`index.vue` customRender 遍历 `cityIds` 反查 regionMap,超 22 字截断 + tooltip。
- ⚠️ 后端待改:`shop_express_template_detail` 表加 `province_ids`/`city_ids`varchar 逗号分隔),add/update 接口接收这两个字段;前端已按此约定提交,后端未改前数据落不了库。
- 注:与"不发货地区(黑名单) regionMode/regionIds"是不同需求,勿混淆。
@@ -12,10 +12,14 @@ export interface ShopExpressTemplateDetail {
templateId?: number; templateId?: number;
/** 计费方式: true=按件, false=按重量 */ /** 计费方式: true=按件, false=按重量 */
type?: boolean; type?: boolean;
/** 省份ID */ /** 省份ID(旧单值字段,兼容历史数据) */
provinceId?: number; provinceId?: number;
/** 城市ID */ /** 城市ID(旧单值字段,兼容历史数据) */
cityId?: number; cityId?: number;
/** 省份IDs(逗号分隔,多选配送区域) */
provinceIds?: string;
/** 城市IDs(逗号分隔,多选配送区域) */
cityIds?: string;
/** 首件数量/重量 */ /** 首件数量/重量 */
firstNum?: string; firstNum?: string;
/** 首件价格 */ /** 首件价格 */
+375
View File
@@ -0,0 +1,375 @@
<!-- +市两级多选穿梭弹窗 -->
<template>
<div class="region-multi-select">
<!-- 触发区 -->
<div class="rms-trigger">
<a-button class="rms-trigger-btn" @click="openModal">
{{ triggerText }}
</a-button>
<span v-if="modelValue.length" class="rms-summary">
已选 {{ modelValue.length }} 个城市
</span>
</div>
<!-- 弹窗 -->
<a-modal
v-model:visible="visible"
title="区域选择"
width="760px"
:mask-closable="false"
@cancel="handleCancel"
>
<div class="rms-body">
<!-- 左侧候选 -->
<div class="rms-panel">
<a-input-search
v-model:value="keyword"
placeholder="输入地区关键字"
allow-clear
class="rms-search"
/>
<div class="rms-scroll">
<div
v-for="p in filteredProvinces"
:key="p.value"
class="rms-province"
>
<div class="rms-province-row">
<span class="rms-expand" @click="toggleExpand(p.value)">
{{ isExpanded(p.value) ? '' : '' }}
</span>
<span class="rms-label" @click="toggleExpand(p.value)">
{{ p.label }}
</span>
<a-button
type="link"
size="small"
:disabled="isProvinceSelected(p)"
@click="selectProvince(p)"
>
{{ isProvinceSelected(p) ? '已选' : '选择' }}
</a-button>
</div>
<div v-if="isExpanded(p.value)" class="rms-city-list">
<div
v-for="c in p.cities"
:key="c.value"
class="rms-city-row"
>
<span class="rms-label">{{ c.label }}</span>
<a-button
type="link"
size="small"
:disabled="isCitySelected(c.value)"
@click="toggleCity(c.value)"
>
{{ isCitySelected(c.value) ? '已选' : '选择' }}
</a-button>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧已选 -->
<div class="rms-panel">
<div class="rms-selected-title">
已选择 ({{ selectedCities.length }})
</div>
<div class="rms-scroll">
<div
v-for="item in selectedList"
:key="item.cityValue"
class="rms-selected-item"
>
<span class="rms-selected-label">
{{ item.provinceLabel }} / {{ item.cityLabel }}
</span>
<a-button
type="link"
size="small"
danger
@click="removeCity(item.cityValue)"
>
取消
</a-button>
</div>
<a-empty
v-if="!selectedCities.length"
:image="simpleImage"
description="暂未选择"
/>
</div>
</div>
</div>
<template #footer>
<a-button @click="selectAll">全部选择</a-button>
<span class="rms-footer-spacer"></span>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" @click="handleConfirm">确认</a-button>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { Empty } from 'ant-design-vue';
import { getRegionsData } from '@/components/RegionsSelect/load-data';
const props = withDefaults(
defineProps<{
/** 已选城市ID数组(逗号分隔的行政区码) */
value?: string[];
placeholder?: string;
}>(),
{ value: () => [], placeholder: '选择配送地区' }
);
const emit = defineEmits<{
(e: 'update:value', value: string[]): void;
(e: 'update:provinceIds', value: string[]): void;
}>();
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const visible = ref(false);
const keyword = ref('');
const expandedSet = ref<Set<string>>(new Set());
const selectedSet = ref<Set<string>>(new Set());
interface CityItem {
value: string;
label: string;
}
interface ProvinceItem {
value: string;
label: string;
cities: CityItem[];
}
const provinceList = ref<ProvinceItem[]>([]);
const cityToProvince = ref<Record<string, string>>({});
const labelMap = ref<Record<string, string>>({});
// 加载省+市两级数据(丢弃区级)
getRegionsData().then((data) => {
const list: ProvinceItem[] = [];
const c2p: Record<string, string> = {};
const lm: Record<string, string> = {};
(data ?? []).forEach((p) => {
lm[p.value] = p.label;
const cities: CityItem[] = (p.children ?? []).map((c) => {
c2p[c.value] = p.value;
lm[c.value] = c.label;
return { value: c.value, label: c.label };
});
list.push({ value: p.value, label: p.label, cities });
});
provinceList.value = list;
cityToProvince.value = c2p;
labelMap.value = lm;
});
const modelValue = computed(() => props.value ?? []);
const triggerText = computed(() =>
modelValue.value.length
? `已选 ${modelValue.value.length} 个城市`
: props.placeholder
);
const isExpanded = (v: string) => expandedSet.value.has(v);
const toggleExpand = (v: string) => {
const s = new Set(expandedSet.value);
if (s.has(v)) {
s.delete(v);
} else {
s.add(v);
}
expandedSet.value = s;
};
const isCitySelected = (v: string) => selectedSet.value.has(v);
const isProvinceSelected = (p: ProvinceItem) =>
p.cities.length > 0 && p.cities.every((c) => selectedSet.value.has(c.value));
const filteredProvinces = computed(() => {
const kw = keyword.value.trim();
if (!kw) {
return provinceList.value;
}
return provinceList.value
.map((p) => {
const matchProvince = p.label.includes(kw);
if (matchProvince) {
return p;
}
const cities = p.cities.filter((c) => c.label.includes(kw));
if (cities.length) {
return { ...p, cities };
}
return null;
})
.filter(Boolean) as ProvinceItem[];
});
const selectedCities = computed(() => Array.from(selectedSet.value));
const selectedList = computed(() =>
selectedCities.value.map((cv) => ({
cityValue: cv,
cityLabel: labelMap.value[cv] || cv,
provinceValue: cityToProvince.value[cv],
provinceLabel: labelMap.value[cityToProvince.value[cv]] || ''
}))
);
const derivedProvinceIds = computed(() => {
const set = new Set<string>();
selectedSet.value.forEach((cv) => {
const pv = cityToProvince.value[cv];
if (pv) {
set.add(pv);
}
});
return Array.from(set);
});
const selectProvince = (p: ProvinceItem) => {
const s = new Set(selectedSet.value);
p.cities.forEach((c) => s.add(c.value));
selectedSet.value = s;
};
const toggleCity = (cv: string) => {
const s = new Set(selectedSet.value);
if (s.has(cv)) {
s.delete(cv);
} else {
s.add(cv);
}
selectedSet.value = s;
};
const removeCity = (cv: string) => {
const s = new Set(selectedSet.value);
s.delete(cv);
selectedSet.value = s;
};
const selectAll = () => {
const s = new Set<string>();
provinceList.value.forEach((p) =>
p.cities.forEach((c) => s.add(c.value))
);
selectedSet.value = s;
};
const openModal = () => {
selectedSet.value = new Set(modelValue.value);
keyword.value = '';
visible.value = true;
};
const handleConfirm = () => {
emit('update:value', selectedCities.value);
emit('update:provinceIds', derivedProvinceIds.value);
visible.value = false;
};
const handleCancel = () => {
visible.value = false;
};
// 外部清空时同步内部选中态
watch(
() => props.value,
(v) => {
if (!visible.value) {
selectedSet.value = new Set(v ?? []);
}
}
);
</script>
<style scoped>
.region-multi-select {
width: 100%;
}
.rms-trigger {
display: flex;
align-items: center;
gap: 12px;
}
.rms-trigger-btn {
flex: 1;
text-align: left;
}
.rms-summary {
color: var(--ant-color-text-secondary, #999);
font-size: 12px;
white-space: nowrap;
}
.rms-body {
display: flex;
gap: 16px;
height: 420px;
}
.rms-panel {
flex: 1;
border: 1px solid var(--ant-color-border, #f0f0f0);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.rms-search {
margin-bottom: 12px;
}
.rms-scroll {
flex: 1;
overflow-y: auto;
}
.rms-province-row {
display: flex;
align-items: center;
padding: 4px 0;
}
.rms-expand {
width: 20px;
cursor: pointer;
user-select: none;
color: var(--ant-color-text-secondary, #999);
}
.rms-label {
flex: 1;
cursor: default;
}
.rms-city-list {
padding-left: 24px;
}
.rms-city-row {
display: flex;
align-items: center;
padding: 2px 0;
}
.rms-selected-title {
font-weight: 600;
margin-bottom: 8px;
}
.rms-selected-item {
display: flex;
align-items: center;
padding: 4px 0;
border-bottom: 1px dashed var(--ant-color-border, #f5f5f5);
}
.rms-selected-label {
flex: 1;
}
.rms-footer-spacer {
flex: 1;
}
</style>
@@ -20,13 +20,13 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' } styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
" "
> >
<a-form-item label="配送地区" name="regions"> <a-form-item label="配送地区">
<RegionsSelect <RegionMultiSelect
v-model:value="regions" :value="cityIds"
type="provinceCity" @update:value="onCityIdsChange"
:show-search="true" @update:provinceIds="onProvinceIdsChange"
placeholder="请选择配送省份/城市(不选则默认全国)"
/> />
<div class="rms-tip">不选则默认全国</div>
</a-form-item> </a-form-item>
<a-form-item :label="templateType ? '首件数量' : '首重重量'" name="firstNum"> <a-form-item :label="templateType ? '首件数量' : '首重重量'" name="firstNum">
<a-input-number <a-input-number
@@ -94,7 +94,7 @@
import { import {
ShopExpressTemplateDetail ShopExpressTemplateDetail
} from '@/api/shop/shopExpressTemplateDetail/model'; } from '@/api/shop/shopExpressTemplateDetail/model';
import RegionsSelect from '@/components/RegionsSelect/index.vue'; import RegionMultiSelect from '@/components/RegionMultiSelect/index.vue';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form'; import { FormInstance } from 'ant-design-vue/es/form';
@@ -128,8 +128,10 @@
const maxable = ref(true); const maxable = ref(true);
// 表单实例 // 表单实例
const formRef = ref<FormInstance | null>(null); const formRef = ref<FormInstance | null>(null);
// 级联选择器选中值(字符串ID数组 // 已选城市ID数组
const regions = ref<string[]>([]); const cityIds = ref<string[]>([]);
// 已选省份ID数组(由城市推导,随城市变化同步)
const provinceIds = ref<string[]>([]);
// 表单数据 // 表单数据
const form = reactive<ShopExpressTemplateDetail>({ const form = reactive<ShopExpressTemplateDetail>({
@@ -138,6 +140,8 @@
type: true, type: true,
provinceId: undefined, provinceId: undefined,
cityId: undefined, cityId: undefined,
provinceIds: undefined,
cityIds: undefined,
firstNum: undefined, firstNum: undefined,
firstAmount: undefined, firstAmount: undefined,
extraAmount: undefined, extraAmount: undefined,
@@ -186,16 +190,17 @@
const { resetFields } = useForm(form, rules); const { resetFields } = useForm(form, rules);
/* 级联选择器值变化,同步到 form.provinceId / form.cityId */ /* 城市选中变化,同步到 form.cityIds */
watch(regions, (val) => { const onCityIdsChange = (val: string[]) => {
if (val && val.length > 0) { cityIds.value = val;
form.provinceId = Number(val[0]); form.cityIds = val.length ? val.join(',') : undefined;
form.cityId = val.length > 1 ? Number(val[1]) : undefined; };
} else {
form.provinceId = undefined; /* 省份选中变化(由组件根据已选城市推导),同步到 form.provinceIds */
form.cityId = undefined; const onProvinceIdsChange = (val: string[]) => {
} provinceIds.value = val;
}); form.provinceIds = val.length ? val.join(',') : undefined;
};
/* 保存编辑 */ /* 保存编辑 */
const save = () => { const save = () => {
@@ -210,6 +215,13 @@
// 确保templateId和type从父级继承 // 确保templateId和type从父级继承
formData.templateId = props.templateId; formData.templateId = props.templateId;
formData.type = props.templateType ?? true; formData.type = props.templateType ?? true;
// 多选配送区域(逗号分隔,空则默认全国)
formData.cityIds = cityIds.value.length
? cityIds.value.join(',')
: undefined;
formData.provinceIds = provinceIds.value.length
? provinceIds.value.join(',')
: undefined;
const saveOrUpdate = isUpdate.value const saveOrUpdate = isUpdate.value
? updateShopExpressTemplateDetail ? updateShopExpressTemplateDetail
: addShopExpressTemplateDetail; : addShopExpressTemplateDetail;
@@ -240,21 +252,36 @@
form.templateId = props.templateId; form.templateId = props.templateId;
form.type = props.templateType ?? true; form.type = props.templateType ?? true;
} }
// 回显级联选择器 // 回显多选配送区域
if (form.provinceId) { if (form.cityIds) {
const arr = [String(form.provinceId)]; cityIds.value = form.cityIds.split(',').filter(Boolean);
if (form.cityId) { } else if (form.cityId) {
arr.push(String(form.cityId)); // 兼容旧单值数据
} cityIds.value = [String(form.cityId)];
regions.value = arr;
} else { } else {
regions.value = []; cityIds.value = [];
}
if (form.provinceIds) {
provinceIds.value = form.provinceIds.split(',').filter(Boolean);
} else if (form.provinceId) {
provinceIds.value = [String(form.provinceId)];
} else {
provinceIds.value = [];
} }
} else { } else {
resetFields(); resetFields();
regions.value = []; cityIds.value = [];
provinceIds.value = [];
} }
}, },
{ immediate: true } { immediate: true }
); );
</script> </script>
<style scoped>
.rms-tip {
margin-top: 4px;
color: var(--ant-color-text-secondary, #999);
font-size: 12px;
}
</style>
@@ -51,7 +51,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { createVNode, ref, computed, onMounted } from 'vue'; import { createVNode, ref, computed, onMounted, h } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { message, Modal } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'; import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
@@ -128,6 +128,26 @@
align: 'center', align: 'center',
width: 150, width: 150,
customRender: ({ record }) => { customRender: ({ record }) => {
// 多选配送区域(cityIds 逗号分隔)
const cityIds = (record.cityIds || '')
.split(',')
.filter(Boolean);
if (cityIds.length) {
const names = cityIds.map(
(id: string) => regionMap.value[id] || id
);
const text = names.join('、');
// 过长截断并支持 tooltip 悬浮查看全部
if (text.length > 22) {
return h(
'span',
{ title: text },
text.slice(0, 22) + '…'
);
}
return text;
}
// 兼容旧单值数据
const province = record.provinceId const province = record.provinceId
? regionMap.value[String(record.provinceId)] ? regionMap.value[String(record.provinceId)]
: ''; : '';