From d4b8bc4c944494bc70626f1910b59c36a9368353 Mon Sep 17 00:00:00 2001 From: hwj Date: Tue, 24 Mar 2026 15:30:55 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=AE=A2=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86-=E5=9B=BD=E5=AE=B6=E3=80=81=E7=9C=81=E3=80=81?= =?UTF-8?q?=E5=9F=8E=E5=B8=82=E7=BB=84=E4=BB=B6=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.ts | 35 +- src/locales/zh-CN.ts | 29 +- src/views/cus/management/ManagementForm.vue | 578 ++++++++++++-------- src/views/cus/management/index.vue | 196 ++----- 4 files changed, 459 insertions(+), 379 deletions(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index bd4c0b4..2dbfbd0 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -141,9 +141,9 @@ export default { qrcode: 'Scan the QR code to log in', btnRegister: 'Sign up', SmsSendMsg: 'code has been sent', - resetPassword: "Reset Password", - resetPasswordSuccess: "Reset Password Success", - invalidTenantName:"Invalid Tenant Name" + resetPassword: 'Reset Password', + resetPasswordSuccess: 'Reset Password Success', + invalidTenantName: 'Invalid Tenant Name' }, captcha: { verify: 'Verify', @@ -315,6 +315,33 @@ export default { dataUpdate: 'Dict Data Eidt', fileUpload: 'File Upload' }, + cus: { + management: { + fields: { + customerCode: 'Customer Code', + customerName: 'Customer Name', + region: 'Region', + afterSalesManager: 'After-sales Manager', + country: 'Country', + provinceCN: 'Province', + stateProvince: 'State/Province', + city: 'City', + address: 'Address', + longitude: 'Longitude', + latitude: 'Latitude' + }, + actions: { + batchDelete: 'Batch Delete' + }, + sections: { + regionInfo: 'Location' + }, + loading: { + countries: 'Loading countries...' + }, + exportFileName: 'customer-management.xls' + } + }, dialog: { dialog: 'Dialog', open: 'Open', @@ -459,4 +486,4 @@ export default { btn_zoom_out: 'Zoom out', preview: 'Preivew' } -} \ No newline at end of file +} diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 3b6c2e9..080a7c1 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -316,6 +316,33 @@ export default { dataCreate: '字典数据新增', dataUpdate: '字典数据编辑' }, + cus: { + management: { + fields: { + customerCode: '客户编码', + customerName: '客户名称', + region: '区域', + afterSalesManager: '售后负责人', + country: '国家', + provinceCN: '省', + stateProvince: '省/州', + city: '城市', + address: '详细地址', + longitude: '经度', + latitude: '纬度' + }, + actions: { + batchDelete: '批量删除' + }, + sections: { + regionInfo: '区域信息' + }, + loading: { + countries: '加载国家列表...' + }, + exportFileName: '客户管理.xls' + } + }, dialog: { dialog: '弹窗', open: '打开', @@ -455,4 +482,4 @@ export default { preview: '预览' }, 'OAuth 2.0': 'OAuth 2.0' // 避免菜单名是 OAuth 2.0 时,一直 warn 报错 -} \ No newline at end of file +} diff --git a/src/views/cus/management/ManagementForm.vue b/src/views/cus/management/ManagementForm.vue index 7d97e26..62fa932 100644 --- a/src/views/cus/management/ManagementForm.vue +++ b/src/views/cus/management/ManagementForm.vue @@ -4,30 +4,42 @@ ref="formRef" :model="formData" :rules="formRules" - label-width="100px" - v-loading="formLoading" + min-label-width="100px" + v-loading="formLoading || geoInitLoading" + :element-loading-text="t('cus.management.loading.countries')" > - 基本信息 - - + + - - + + - - + + - + - - + + - 区域信息 + {{ t('cus.management.sections.regionInfo') }} - - - + + - + :placeholder="`${t('common.selectText')} ${t('cus.management.fields.country')}`" + filterable + clearable + :loading="countryLoading" + @change="handleCountryChange" + > + + - - - + + - + clearable + :loading="provinceLoading" + :disabled="!formData.countryCode" + @change="handleProvinceChange" + > + + - - - - - + + + + + - - + + - - + + - - + + @@ -134,12 +166,11 @@ import { ManagementApi, Management } from '@/api/cus/management' import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { CommonStatusEnum } from '@/utils/constants' -import { getAreaTree } from '@/api/system/area' /** 客户管理 表单 */ defineOptions({ name: 'ManagementForm' }) -const { t } = useI18n() // 国际化 +const { t, locale } = useI18n() // 国际化 const message = useMessage() // 消息弹窗 const dialogVisible = ref(false) // 弹窗的是否展示 @@ -147,10 +178,9 @@ const dialogTitle = ref('') // 弹窗的标题 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 const formType = ref('') // 表单的类型:create - 新增;update - 修改 -const countrySelectReady = ref(false) -type FormModel = Partial & { - areaIds?: number[] -} +type FormModel = Partial + +const geoInitLoading = ref(false) const formData = ref({ id: undefined, @@ -160,8 +190,7 @@ const formData = ref({ remark: undefined, afterSalesManager: undefined, countryCode: 'CN', - countryName: '中国', - areaIds: undefined, + countryName: undefined, provinceCode: undefined, provinceName: undefined, cityCode: undefined, @@ -175,162 +204,303 @@ const formData = ref({ dbPassword: undefined }) const formRules = reactive({ - customerName: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }], - customerCode: [{ required: true, message: '客户编码不能为空', trigger: 'blur' }], - status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] + customerName: [{ required: true, message: t('common.required'), trigger: 'blur' }], + customerCode: [{ required: true, message: t('common.required'), trigger: 'blur' }], + status: [{ required: true, message: t('common.required'), trigger: 'blur' }] }) const formRef = ref() // 表单 Ref -const isChina = computed(() => formData.value.countryCode === 'CN') - -const countryMetaLoaded = ref(false) -const countryCodeToName = ref(new Map([['CN', '中国']])) -const countryNameToCode = ref(new Map([['中国', 'CN']])) const syncingForm = ref(false) -type AreaVO = { - id: number - name: string +type GeoNamesCountry = { code: string - parentId?: number - children?: AreaVO[] + name: string + geonameId: number } -const areaTree = ref([]) -const areaCascaderProps = { - label: 'name', - value: 'id', - children: 'children', - emitPath: true, - checkStrictly: true +type GeoNamesPlace = { + geonameId: number + name: string } -const areaIdToNode = ref(new Map()) -const areaIdToParentId = ref(new Map()) -const areaCodeToId = ref(new Map()) - -const rebuildAreaIndex = (tree: AreaVO[]) => { - const idToNode = new Map() - const idToParent = new Map() - const codeToId = new Map() - const walk = (nodes: AreaVO[], parentId?: number) => { - nodes.forEach((n) => { - idToNode.set(n.id, n) - idToParent.set(n.id, parentId) - if (n.code) { - codeToId.set(n.code, n.id) - } - if (n.children?.length) walk(n.children, n.id) + +const countryOptions = ref([]) +const provinceOptions = ref([]) +const cityOptions = ref([]) + +const countryLoading = ref(false) +const provinceLoading = ref(false) +const cityLoading = ref(false) + +const provinceLabel = computed(() => { + return formData.value.countryCode === 'CN' + ? t('cus.management.fields.provinceCN') + : t('cus.management.fields.stateProvince') +}) + +const provincePlaceholder = computed(() => { + return formData.value.countryCode === 'CN' + ? `${t('common.selectText')} ${t('cus.management.fields.provinceCN')}` + : `${t('common.selectText')} ${t('cus.management.fields.stateProvince')}` +}) + +const getGeoNamesLang = () => { + const current = String(locale.value || '').toLowerCase() + return current.startsWith('zh') ? 'zh' : 'en' +} + +const buildGeoNamesUrl = (path: string, params: Record) => { + const url = new URL(`http://api.geonames.org/${path}`) + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)) + return url.toString() +} + +const countryCache = new Map() +const childrenCache = new Map() + +const loadCountries = async () => { + const lang = getGeoNamesLang() + if (countryCache.has(lang)) { + countryOptions.value = countryCache.get(lang) || [] + return + } + countryLoading.value = true + try { + const url = buildGeoNamesUrl('countryInfoJSON', { + username: 'withcomb', + lang }) + const res = await fetch(url) + const data = (await res.json()) as { geonames?: any[] } + const items: GeoNamesCountry[] = (data.geonames || []) + .map((n) => ({ + code: String(n.countryCode || ''), + name: String(n.countryName || ''), + geonameId: Number(n.geonameId) + })) + .filter((n) => n.code && n.name && Number.isFinite(n.geonameId)) + .sort((a, b) => a.name.localeCompare(b.name, lang === 'zh' ? 'zh-Hans' : 'en')) + countryCache.set(lang, items) + countryOptions.value = items + } catch (e) { + countryOptions.value = [] + } finally { + countryLoading.value = false } - walk(tree) - areaIdToNode.value = idToNode - areaIdToParentId.value = idToParent - areaCodeToId.value = codeToId } -const buildPathById = (id: number) => { - const path: number[] = [] - let current: number | undefined = id - while (current !== undefined) { - path.push(current) - current = areaIdToParentId.value.get(current) +const loadChildrenByGeonameId = async (parentGeonameId: number) => { + const lang = getGeoNamesLang() + const cacheKey = `${lang}|${parentGeonameId}` + if (childrenCache.has(cacheKey)) return childrenCache.get(cacheKey) || [] + const url = buildGeoNamesUrl('childrenJSON', { + geonameId: String(parentGeonameId), + username: 'withcomb', + lang + }) + try { + const res = await fetch(url) + const data = (await res.json()) as { geonames?: any[] } + const items: GeoNamesPlace[] = (data.geonames || []) + .map((n) => ({ + geonameId: Number(n.geonameId), + name: String(n.name || '') + })) + .filter((n) => n.name && Number.isFinite(n.geonameId)) + .sort((a, b) => a.name.localeCompare(b.name, lang === 'zh' ? 'zh-Hans' : 'en')) + childrenCache.set(cacheKey, items) + return items + } catch (e) { + return [] + } finally { } - return path.reverse() } -const syncAreaIdsFromCodes = () => { - const cityId = formData.value.cityCode - ? areaCodeToId.value.get(formData.value.cityCode) - : undefined - const provinceId = formData.value.provinceCode - ? areaCodeToId.value.get(formData.value.provinceCode) - : undefined - const targetId = cityId ?? provinceId - if (!targetId) return - formData.value.areaIds = buildPathById(targetId) +const loadProvincesByCountryCode = async (countryCode?: string) => { + if (!countryCode) { + provinceOptions.value = [] + return + } + const country = countryOptions.value.find((c) => c.code === countryCode) + if (!country) { + provinceOptions.value = [] + return + } + provinceLoading.value = true + try { + provinceOptions.value = await loadChildrenByGeonameId(country.geonameId) + } finally { + provinceLoading.value = false + } } -const handleAreaChange = (value?: number[]) => { - if (!value?.length) { - formData.value.provinceCode = undefined - formData.value.provinceName = undefined - formData.value.cityCode = undefined - formData.value.cityName = undefined +const loadCitiesByProvinceGeonameId = async (provinceGeonameId?: string) => { + if (!provinceGeonameId) { + cityOptions.value = [] return } - const ids = value - const provinceNode = ids[0] ? areaIdToNode.value.get(ids[0]) : undefined - const cityNode = ids[1] ? areaIdToNode.value.get(ids[1]) : undefined - - formData.value.provinceCode = provinceNode?.code - formData.value.provinceName = provinceNode?.name - formData.value.cityCode = cityNode?.code - formData.value.cityName = cityNode?.name + const id = Number(provinceGeonameId) + if (!Number.isFinite(id)) { + cityOptions.value = [] + return + } + cityLoading.value = true + try { + cityOptions.value = await loadChildrenByGeonameId(id) + } finally { + cityLoading.value = false + } } -const handleCountryChange = async (countryCode?: string) => { - const code = countryCode || undefined - formData.value.countryName = code ? countryCodeToName.value.get(code) || code : undefined - formData.value.areaIds = undefined - handleAreaChange(undefined) - if (code) await ensureAreaTreeLoaded(code) +const syncCountryNameFromCode = () => { + const code = formData.value.countryCode + if (!code) { + formData.value.countryName = undefined + return + } + const item = countryOptions.value.find((c) => c.code === code) + formData.value.countryName = item?.name || formData.value.countryName || code } -watch( - () => formData.value.countryCode, - (code) => { - if (!dialogVisible.value) return - if (syncingForm.value) return - void handleCountryChange(code) +const syncCountryCodeFromName = () => { + const name = formData.value.countryName + if (!name || formData.value.countryCode) return + const item = countryOptions.value.find((c) => c.name === name) + if (item) formData.value.countryCode = item.code +} + +const syncPlaceCodeByName = (name: string | undefined, list: GeoNamesPlace[]) => { + if (!name) return undefined + const item = list.find((c) => c.name === name) + return item ? String(item.geonameId) : undefined +} + +const syncPlaceNameByCode = (code: string | undefined, list: GeoNamesPlace[]) => { + if (!code) return undefined + const item = list.find((c) => String(c.geonameId) === String(code)) + return item?.name +} + +const handleCountryChange = async (val?: string) => { + const code = val || undefined + formData.value.countryCode = code + formData.value.provinceCode = undefined + formData.value.provinceName = undefined + syncCountryNameFromCode() + provinceOptions.value = [] + cityOptions.value = [] + formData.value.cityCode = undefined + formData.value.cityName = undefined + await loadProvincesByCountryCode(code) +} + +const loadCityDetail = async (geonameId: string) => { + const lang = getGeoNamesLang() + const url = buildGeoNamesUrl('getJSON', { + geonameId: String(geonameId), + username: 'withcomb', + lang + }) + const res = await fetch(url) + const data = (await res.json()) as any + if (data && data.geonameId) { + if (data.name) formData.value.cityName = String(data.name) + if (data.lng !== undefined && data.lng !== null) formData.value.longitude = String(data.lng) + if (data.lat !== undefined && data.lat !== null) formData.value.latitude = String(data.lat) } -) +} -const loadedCountryCode = ref(undefined) -const ensureAreaTreeLoaded = async (countryCode?: string) => { - if (countryCode && countryCode !== 'CN') { - areaTree.value = [] - rebuildAreaIndex([]) - loadedCountryCode.value = countryCode - return +const handleProvinceChange = async (val?: string) => { + const code = val || undefined + formData.value.provinceCode = code + formData.value.cityCode = undefined + formData.value.cityName = undefined + formData.value.provinceName = syncPlaceNameByCode(code, provinceOptions.value) || undefined + cityOptions.value = [] + await loadCitiesByProvinceGeonameId(code) +} + +const handleCityChange = async (val?: string) => { + const code = val || undefined + formData.value.cityCode = code + formData.value.cityName = syncPlaceNameByCode(code, cityOptions.value) || undefined + if (code) { + try { + await loadCityDetail(code) + } catch (e) {} } - if (loadedCountryCode.value === countryCode && areaTree.value.length) return - const tree = (await getAreaTree(countryCode)) || [] - areaTree.value = tree - rebuildAreaIndex(tree) - loadedCountryCode.value = countryCode } +watch( + () => locale.value, + () => { + if (!dialogVisible.value) return + void loadCountries().then(async () => { + syncCountryNameFromCode() + await loadProvincesByCountryCode(formData.value.countryCode) + if (!formData.value.provinceCode && formData.value.provinceName) { + formData.value.provinceCode = syncPlaceCodeByName( + formData.value.provinceName, + provinceOptions.value + ) + } + formData.value.provinceName = + syncPlaceNameByCode(formData.value.provinceCode, provinceOptions.value) || + formData.value.provinceName + await loadCitiesByProvinceGeonameId(formData.value.provinceCode) + if (!formData.value.cityCode && formData.value.cityName) { + formData.value.cityCode = syncPlaceCodeByName(formData.value.cityName, cityOptions.value) + } + formData.value.cityName = + syncPlaceNameByCode(formData.value.cityCode, cityOptions.value) || formData.value.cityName + }) + } +) + /** 打开弹窗 */ const open = async (type: string, id?: number) => { dialogVisible.value = true dialogTitle.value = t('action.' + type) formType.value = type syncingForm.value = true - countrySelectReady.value = false resetForm() - void nextTick(() => { - requestAnimationFrame(() => { - countrySelectReady.value = true - }) - }) - if (!formData.value.countryCode && formData.value.countryName) { - formData.value.countryCode = countryNameToCode.value.get(formData.value.countryName) + geoInitLoading.value = true + try { + await loadCountries() + } finally { + geoInitLoading.value = false } - await ensureAreaTreeLoaded(formData.value.countryCode) + syncCountryCodeFromName() + syncCountryNameFromCode() + await loadProvincesByCountryCode(formData.value.countryCode) // 修改时,设置数据 if (id) { formLoading.value = true try { formData.value = await ManagementApi.getManagement(id) - if (!formData.value.countryCode && formData.value.countryName) { - formData.value.countryCode = countryNameToCode.value.get(formData.value.countryName) + await loadCountries() + syncCountryCodeFromName() + syncCountryNameFromCode() + await loadProvincesByCountryCode(formData.value.countryCode) + if (!formData.value.provinceCode && formData.value.provinceName) { + formData.value.provinceCode = syncPlaceCodeByName( + formData.value.provinceName, + provinceOptions.value + ) + } + formData.value.provinceName = + syncPlaceNameByCode(formData.value.provinceCode, provinceOptions.value) || + formData.value.provinceName + await loadCitiesByProvinceGeonameId(formData.value.provinceCode) + if (!formData.value.cityCode && formData.value.cityName) { + formData.value.cityCode = syncPlaceCodeByName(formData.value.cityName, cityOptions.value) + } + formData.value.cityName = + syncPlaceNameByCode(formData.value.cityCode, cityOptions.value) || formData.value.cityName + if (formData.value.cityCode) { + try { + await loadCityDetail(String(formData.value.cityCode)) + } catch (e) {} } - formData.value.countryName = formData.value.countryCode - ? countryCodeToName.value.get(formData.value.countryCode) || formData.value.countryName - : formData.value.countryName - await ensureAreaTreeLoaded(formData.value.countryCode) - syncAreaIdsFromCodes() - handleAreaChange(formData.value.areaIds as any) } finally { formLoading.value = false } @@ -374,7 +544,6 @@ const resetForm = () => { afterSalesManager: undefined, countryCode: 'CN', countryName: '中国', - areaIds: undefined, provinceCode: undefined, provinceName: undefined, cityCode: undefined, @@ -390,32 +559,3 @@ const resetForm = () => { formRef.value?.resetFields() } - - diff --git a/src/views/cus/management/index.vue b/src/views/cus/management/index.vue index 10b136c..ddb530f 100644 --- a/src/views/cus/management/index.vue +++ b/src/views/cus/management/index.vue @@ -6,28 +6,33 @@ :model="queryParams" ref="queryFormRef" :inline="true" - label-width="68px" + min-label-width="68px" > - + - + - - + + - 搜索 - 重置 + + {{ t('common.query') }} + + + {{ t('common.reset') }} + - 新增 + {{ t('action.create') }} - 导出 + {{ t('action.export') }} - 批量删除 + {{ t('cus.management.actions.batchDelete') }} @@ -80,9 +89,19 @@ @selection-change="handleRowCheckboxChange" > - - - + + + - + - + @@ -157,46 +160,6 @@ - - - - - - - - - - - - - - - - - -