diff --git a/.env.dev b/.env.dev index 35f5f738..52f869ff 100644 --- a/.env.dev +++ b/.env.dev @@ -9,7 +9,9 @@ ENV = 'dev' # ADMIN 服务地址 # VITE_ADMIN_PROXY_PATH = https://www.zianxin.com -VITE_ADMIN_PROXY_PATH = https://rj4jv5rwqs.by.takin.cc +# VITE_ADMIN_PROXY_PATH = https://654173oc0eq0.vicp.fun +VITE_ADMIN_PROXY_PATH = http://192.168.31.130:9999 +#VITE_ADMIN_PROXY_PATH = https://rj4jv5rwqs.by.takin.cc # 图片访问基础地址 VITE_IMAGE_BASE_PATH = https://www.zianxin.com/api @@ -17,3 +19,6 @@ VITE_IMAGE_BASE_PATH = https://www.zianxin.com/api # 代码生成服务地址 VITE_GEN_PROXY_PATH = http://47.96.250.9:5003 + +# 腾讯位置服务 Key(PC 地图选点) +VITE_TENCENT_MAP_KEY=EF4BZ-BT537-77HXI-HP6W3-NPTX7-HJFJW diff --git a/.env.development b/.env.development index ec72fec3..8cc9e032 100644 --- a/.env.development +++ b/.env.development @@ -15,8 +15,14 @@ VITE_ADMIN_PROXY_PATH = http://localhost:9999/ # VITE_ADMIN_PROXY_PATH = http://192.168.101.196:9999 # 邓-本地 # VITE_ADMIN_PROXY_PATH = http://59.202.170.152:81/api # 正式环境线上 -# 食堂配送:骑手角色编码(用于楼栋/骑手池可选用户列表,与 /admin/user/getUserListByRoleCodeAndDept 的 code 一致) -# VITE_CANTEEN_RIDER_ROLE_CODE = CANTEEN_RIDER +# ========== 内网穿透 / 微信 PC 扫码调试 ========== +# 日常开发请用 http://localhost:8888 ,不要用穿透域名跑 npm run dev(大文件会 ERR_CONTENT_LENGTH_MISMATCH) +# 微信回调须用穿透域名时: +# 1. 三方账号 redirectUrl 填 https://654173oc0eq0.vicp.fun +# 2. 花生壳映射到本机 8888 +# 3. 执行 npm run preview:tunnel(build 后 preview,不要用 dev) +# VITE_TUNNEL_MODE = true +# VITE_TUNNEL_HOST = 654173oc0eq0.vicp.fun # 图片访问基础地址 # VITE_IMAGE_BASE_PATH = http://123.157.250.162:8089/api/ # 测试线上 @@ -31,3 +37,6 @@ VITE_GEN_PROXY_PATH = http://192.168.110.156:5003 # 饭卡打印:本机打印助手 mock 开关(true=无助手/打印机也能演示打印全流程) VITE_PLUGIN_MOCK = false + +# 腾讯位置服务 Key(酒店等业务地图选点;与 .env 同源时可省略,此处便于本地覆盖) +VITE_TENCENT_MAP_KEY=EF4BZ-BT537-77HXI-HP6W3-NPTX7-HJFJW diff --git a/.env.prod b/.env.prod index e410599d..ca766afe 100644 --- a/.env.prod +++ b/.env.prod @@ -21,4 +21,4 @@ VITE_IMAGE_BASE_PATH = https://www.zianxin.com/api VITE_GEN_PROXY_PATH = http://123.156.230.15:5003 # 腾讯位置服务 Key(PC 地图选点) -VITE_TENCENT_MAP_KEY=CGABZ-3MH66-6VGST-MEMS3-K6U3V-DGBKA +VITE_TENCENT_MAP_KEY=EF4BZ-BT537-77HXI-HP6W3-NPTX7-HJFJW diff --git a/src/api/admin/sysArea.ts b/src/api/admin/sysArea.ts index 8e80a106..5370da3c 100644 --- a/src/api/admin/sysArea.ts +++ b/src/api/admin/sysArea.ts @@ -16,6 +16,24 @@ export function fetchList(query?: Object) { }) } +/** App/联动:下级区域 */ +export function fetchChildren(query?: Object) { + return request({ + url: '/admin/sysArea/app/children', + method: 'get', + params: query + }) +} + +/** App:按名称查区域 */ +export function searchArea(query?: Object) { + return request({ + url: '/admin/sysArea/app/search', + method: 'get', + params: query + }) +} + export function addObj(obj?: Object) { return request({ url: '/admin/sysArea', diff --git a/src/components/ChinaArea/index.vue b/src/components/ChinaArea/index.vue index 55a79f8e..abdd3634 100644 --- a/src/components/ChinaArea/index.vue +++ b/src/components/ChinaArea/index.vue @@ -56,11 +56,12 @@ const optionProps: CascaderProps = { value: 'adcode', lazy: props.lazy, lazyLoad: async (node: Node, resolve: Resolve) => { - // 初始化数据 + // 初始化数据;pid 用 adcode(树节点 id 也是 adcode),不要用库表主键 const {level} = node if (level < props.type) { - const {data} = await fetchTree({pid: node.data.id || 100000}) - resolve(data) + const parentAdcode = node?.data?.adcode ?? node?.data?.id ?? node?.value ?? 100000 + const {data} = await fetchTree({pid: parentAdcode}) + resolve(data || []) } else { resolve([]) } diff --git a/src/components/Upload/Images.vue b/src/components/Upload/Images.vue index 6b232005..c1d81175 100644 --- a/src/components/Upload/Images.vue +++ b/src/components/Upload/Images.vue @@ -53,9 +53,35 @@ const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_PATH const dialogVisible = ref(false) const fileList = ref([]) const dialogImageUrl = ref("") +/** 上传成功写回 v-model 时跳过 watch,避免多选并发上传时冲掉未完成的文件 */ +const syncingFromUpload = ref(false) // 获取 el-form 组件上下文 const formContext = inject(formContextKey, void 0) +const fileUrlOf = (item: any) => { + const raw = item?.response?.data?.url ?? item?.fileUrl ?? "" + return typeof raw === "string" ? raw : "" +} + +const toDisplayUrl = (path: string) => { + if (!path) return "" + return path.startsWith("http") ? path : `${imageBaseUrl || ""}${path}` +} + +const collectUrls = (files: any[]) => + (files || []) + .map((item) => fileUrlOf(item)) + .filter(Boolean) + .join(",") + +const setModelUrls = (urls: string) => { + syncingFromUpload.value = true + imageUrls.value = urls + nextTick(() => { + syncingFromUpload.value = false + }) +} + // 自定义上传方法 const handleHttpUpload = async (options: UploadRequestOptions) => { let formData = new FormData() @@ -72,6 +98,7 @@ const handleHttpUpload = async (options: UploadRequestOptions) => { if (res.code == 0) { return res } + options.onError(new Error(res.msg || "上传失败") as any) } catch (error) { options.onError(error as any) } @@ -102,22 +129,15 @@ const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => { } /** - * @description 图片上传成功 + * @description 图片上传成功(多选时会多次回调,不能据此重建 fileList) * */ const uploadSuccess = (response: any, uploadFile: UploadFile, uploadFiles: any) => { - console.log("success", uploadFiles) - - const urls = uploadFiles - .map((item: any) => { - // 处理新上传和已有的图片URL - return item.response ? item.response.data.url : item.fileUrl - }) - .filter(Boolean) - .join(",") - imageUrls.value = urls - - console.log("fileList.value", fileList.value) - console.log("urls", urls) + const url = response?.data?.url + if (url) { + ;(uploadFile as any).fileUrl = url + uploadFile.url = toDisplayUrl(url) + } + setModelUrls(collectUrls(uploadFiles)) ElNotification({ title: "温馨提示", @@ -128,19 +148,13 @@ const uploadSuccess = (response: any, uploadFile: UploadFile, uploadFiles: any) // 预览 const handlePictureCardPreview = (uploadFile: any) => { - dialogImageUrl.value = uploadFile.url + dialogImageUrl.value = uploadFile.url || toDisplayUrl(fileUrlOf(uploadFile)) dialogVisible.value = true } // 移除 -const handleRemove = (uploadFile: any, uploadFiles: any[]) => { - const urls = uploadFiles - .map((item) => { - return item.response ? item.response.data.url : item.fileUrl - }) - .filter(Boolean) - .join(",") - imageUrls.value = urls +const handleRemove = (_uploadFile: any, uploadFiles: any[]) => { + setModelUrls(collectUrls(uploadFiles)) } /** @@ -162,6 +176,8 @@ const self_disabled = computed(() => { watch( () => imageUrls.value, (newValue) => { + // 上传过程中写回 model 时不重建列表,否则多选只会留下已成功的那一张 + if (syncingFromUpload.value) return if (!newValue || Object.prototype.toString.call(newValue) !== "[object String]") { fileList.value = [] return @@ -170,17 +186,14 @@ watch( if (!newValue.match(/x-oss-process/g)) { fileList.value = newValue .split(",") - .map((item: string) => { - const url = item.startsWith("http") ? item : imageBaseUrl + item - return { - name: item.split("/").pop() || "", - url: url, - fileUrl: item, - } - }) + .map((item: string) => item.trim()) + .filter(Boolean) + .map((item: string) => ({ + name: item.split("/").pop() || "", + url: toDisplayUrl(item), + fileUrl: item, + })) .filter((item) => item.url) - - console.log("fileList.value", fileList.value) } else { fileList.value = newValue .split("?") @@ -188,17 +201,12 @@ watch( (item) => item.endsWith(".jpg") || item.endsWith(".png") || item.endsWith(".gif") || item.endsWith(".jpeg") || item.endsWith(".webp"), ) - .map((item: string) => { - const url = item.startsWith("http") ? item : imageBaseUrl + item - return { - name: item.split("/").pop() || "", - url: url, - fileUrl: item, - } - }) + .map((item: string) => ({ + name: item.split("/").pop() || "", + url: toDisplayUrl(item), + fileUrl: item, + })) .filter((item) => item.url) - - console.log("fileList.value", fileList.value) } }, { immediate: true }, diff --git a/src/components/map/map-dialog.vue b/src/components/map/map-dialog.vue index 3dfa6308..32149a3f 100644 --- a/src/components/map/map-dialog.vue +++ b/src/components/map/map-dialog.vue @@ -10,59 +10,50 @@ class="map-picker-dialog" destroy-on-close @opened="onDialogOpened" + @closed="onDialogClosed" >
+
{{ state.searchError }}
+ +
+
可选地址({{ state.searchResults.length }})点一条定位
+
+
+
{{ item.title }}
+
{{ item.address }}
+
+
- -
- - - - - - -
+ +
+
+
- 经度: {{ state.hoverLocation.lng.toFixed(6) }} - 纬度: {{ state.hoverLocation.lat.toFixed(6) }} + 经度: - + 纬度: -
@@ -71,7 +62,7 @@ -
点击地图选点,或上方搜索定位
+
+ {{ state.locating ? "正在定位当前位置…" : "点击地图选点,或上方搜索 / 定位当前位置" }} +
+ + diff --git a/src/views/supermarket/smHotel/detail.vue b/src/views/supermarket/smHotel/detail.vue index 0498b986..fe1d1b96 100644 --- a/src/views/supermarket/smHotel/detail.vue +++ b/src/views/supermarket/smHotel/detail.vue @@ -24,6 +24,14 @@ {{ hotel.address || "-" }} + + {{ hotel.district || "-" }} + ({{ hotel.districtAdcode }}) + + {{ hotel.bizArea || "-" }} + {{ starLabel(hotel.starLevel) }} + {{ hotel.score != null ? hotel.score : "-" }} + {{ hotel.facilityTags || "-" }} {{ hotel.longitude }}, {{ hotel.latitude }} @@ -522,6 +530,11 @@ const orderForm = reactive({ const title = computed(() => (hotel.value?.hotelName ? `酒店详情 - ${hotel.value.hotelName}` : "酒店详情")) const isLocal = computed(() => String(hotel.value?.sheetName || "").toLowerCase() === "local") +const starLabel = (lv: any) => { + const map: Record = { 1: "民宿", 2: "经济", 3: "舒适", 4: "高档", 5: "五星" } + const n = Number(lv) + return map[n] || "-" +} const localSaving = ref(false) const roomDialogVisible = ref(false) diff --git a/src/views/supermarket/smHotel/form.vue b/src/views/supermarket/smHotel/form.vue index d8848ca7..e0242463 100644 --- a/src/views/supermarket/smHotel/form.vue +++ b/src/views/supermarket/smHotel/form.vue @@ -69,6 +69,34 @@
支持多张,可连续选择上传;保存为逗号分隔 URL
+ + + +
区域加载中…
+
{{ form.district || "-" }} {{ form.districtAdcode ? `(${form.districtAdcode})` : "" }}
+
已选:{{ form.district }}({{ form.districtAdcode || "-" }})
+
+
+ + + + +
@@ -77,7 +105,8 @@ class="map-pick-part" :disabled="!canEditProfile" :show-coordinates="false" - placeholder="搜索或点击地图选择位置" + :prefer-address="mapPreferAddress" + placeholder="先选区域,再点这里搜索定位" @change="onMapChange" />
@@ -96,6 +125,7 @@ />
+
支持模糊搜索小区/酒店/路名;打开地图会按「位置区域」落到对应城市
@@ -110,6 +140,48 @@ /> + + + + + + + + + + + + + + +
0.0 ~ 5.0,移动端可按评分筛选
+
+
+ + + + + + + import("/@/components/ChinaArea/index.vue")) const emit = defineEmits(["refresh"]) const { sm_hotel_data_source } = useDict("sm_hotel_data_source") @@ -482,6 +557,8 @@ const subSaving = ref(false) const formRef = ref() const activeTab = ref("base") +const facilityTagOptions = ["含早餐", "免费取消", "免费停车", "立即确认", "限时优惠", "接站服务", "室内泳池", "健身中心"] + const defaultForm = () => ({ id: undefined as string | undefined, hotelCode: "", @@ -491,6 +568,12 @@ const defaultForm = () => ({ coverImage: "", carouselImages: "", address: "", + district: "", + districtAdcode: undefined as number | undefined, + bizArea: "", + starLevel: undefined as number | undefined, + score: undefined as number | undefined, + facilityTagList: [] as string[], longitude: "" as string | number, latitude: "" as string | number, intro: "", @@ -505,7 +588,81 @@ const defaultForm = () => ({ }) const form = reactive(defaultForm()) +/** 省市区级联路径,逗号分隔 adcode(必须是完整链路,单写区县码级联无法回显) */ +const districtPath = ref("") +const districtPathReady = ref(true) +const districtCascaderKey = ref(0) const mapLocation = ref(null) + +/** 判断是否为「经度,纬度」形态,避免选点把坐标写进详细地址 */ +const looksLikeCoordinates = (text: string) => + /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test((text || "").trim()) + +const pickAreaRow = (res: any) => { + const d = res?.data + if (Array.isArray(d)) return d[0] || null + return d || null +} + +/** 从区县 adcode 向上拼出 省,市,区 路径,供 china-area 回显 */ +const buildDistrictPathByAdcode = async (adcode: number | string) => { + const codes: string[] = [] + let cur = Number(adcode) + let guard = 0 + while (Number.isFinite(cur) && cur > 0 && cur !== 100000 && guard++ < 10) { + codes.unshift(String(cur)) + try { + const res: any = await getAreaObj({ adcode: cur }) + const row = pickAreaRow(res) + const pid = Number(row?.pid) + if (!Number.isFinite(pid) || pid === 100000 || pid === 0 || pid === cur) break + cur = pid + } catch { + break + } + } + return codes.join(",") +} + +/** 地图初始定位文案:区域 + 商圈 + 详细地址,便于落到嘉兴等正确城市 */ +const mapPreferAddress = computed(() => { + const parts = [form.district, form.bizArea, form.address] + .map((s) => String(s || "").trim()) + .filter((s) => s && !looksLikeCoordinates(s) && s !== "已选择位置") + return parts.join("") +}) + +const onDistrictChange = async (val: string) => { + districtPath.value = val || "" + const parts = String(val || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + const last = parts[parts.length - 1] + if (!last) { + form.district = "" + form.districtAdcode = undefined + return + } + form.districtAdcode = Number(last) + // 拼完整「省市区」文案,地图打开/搜索才能落到正确城市,而不是只拿「南湖区」 + try { + const names: string[] = [] + for (const code of parts) { + const res: any = await getAreaObj({ adcode: code }) + const name = pickAreaRow(res)?.name + if (name) names.push(name) + } + form.district = names.join("") || form.district || "" + } catch { + try { + const res: any = await getAreaObj({ adcode: last }) + form.district = pickAreaRow(res)?.name || form.district || String(last) + } catch { + form.district = form.district || String(last) + } + } +} const rooms = ref([]) const products = ref([]) const dailyList = ref([]) @@ -586,10 +743,6 @@ const syncMapFromForm = () => { } } -/** 判断是否为「经度,纬度」形态,避免选点把坐标写进详细地址 */ -const looksLikeCoordinates = (text: string) => - /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test((text || "").trim()) - const onMapChange = (val: any) => { if (!val) { form.longitude = "" @@ -644,6 +797,9 @@ const openDialog = async (id?: string) => { visible.value = true activeTab.value = "base" Object.assign(form, defaultForm()) + districtPath.value = "" + districtPathReady.value = true + districtCascaderKey.value++ mapLocation.value = null rooms.value = [] products.value = [] @@ -654,6 +810,7 @@ const openDialog = async (id?: string) => { nextTick(() => formRef.value?.clearValidate()) if (!id) return loading.value = true + districtPathReady.value = false try { const res = await getObj(id) const data = res.data || {} @@ -665,6 +822,25 @@ const openDialog = async (id?: string) => { form.coverImage = data.coverImage || "" form.carouselImages = data.carouselImages || "" form.address = data.address || "" + form.district = data.district || "" + form.districtAdcode = data.districtAdcode != null ? Number(data.districtAdcode) : undefined + // 库里只存区县 adcode,回显必须还原完整省市区路径,否则级联是空的 + if (form.districtAdcode != null && Number.isFinite(form.districtAdcode)) { + districtPath.value = await buildDistrictPathByAdcode(form.districtAdcode) + if (!form.district && districtPath.value) { + await onDistrictChange(districtPath.value) + } + } else { + districtPath.value = "" + } + districtCascaderKey.value++ + form.bizArea = data.bizArea || "" + form.starLevel = data.starLevel != null ? Number(data.starLevel) : undefined + form.score = data.score != null && data.score !== "" ? Number(data.score) : undefined + form.facilityTagList = String(data.facilityTags || "") + .split(/[,,]/) + .map((s: string) => s.trim()) + .filter(Boolean) form.longitude = data.longitude ?? "" form.latitude = data.latitude ?? "" form.intro = data.intro || "" @@ -681,6 +857,7 @@ const openDialog = async (id?: string) => { } catch (err: any) { useMessage().error(err.msg || "加载失败") } finally { + districtPathReady.value = true loading.value = false } } @@ -704,6 +881,12 @@ const submitBase = async () => { coverImage: form.coverImage || null, carouselImages: form.carouselImages || null, address: form.address?.trim() || null, + district: form.district?.trim() || null, + districtAdcode: form.districtAdcode ?? null, + bizArea: form.bizArea?.trim() || null, + starLevel: form.starLevel ?? null, + score: form.score ?? null, + facilityTags: (form.facilityTagList || []).filter(Boolean).join(",") || null, longitude: form.longitude === "" || form.longitude == null ? null : form.longitude, latitude: form.latitude === "" || form.latitude == null ? null : form.latitude, intro: form.intro || null, diff --git a/vite.config.ts b/vite.config.ts index 4e11267a..b95874cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -121,6 +121,19 @@ const viteConfig = defineConfig((mode: ConfigEnv) => { ws: true, // 是否启用 WebSocket changeOrigin: true, }, + // 本地调试腾讯 WebService:浏览器走同源代理,转发时带已白名单的生产域名 Referer + // (localhost 写进腾讯白名单经常仍 110,生产部署仍用浏览器直连 JSONP) + "/qqmap": { + target: "https://apis.map.qq.com", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/qqmap/, ""), + configure: (proxy) => { + proxy.on("proxyReq", (proxyReq) => { + proxyReq.setHeader("Referer", "https://zianxin.com/") + proxyReq.setHeader("Origin", "https://zianxin.com") + }) + }, + }, }, }, build: {