酒店
This commit is contained in:
1 parent
7670ae8329
commit
426f1fb515
15 files changed
+1590
-366
No files matched your search
@@ -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
|
||||
+11
-2
@@ -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
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
|
||||
@@ -53,9 +53,35 @@ const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_PATH
|
||||
const dialogVisible = ref(false)
|
||||
const fileList = ref<any>([])
|
||||
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 },
|
||||
|
||||
+712
-179
File diff suppressed because it is too large.
Load diff
@@ -53,13 +53,16 @@ const props = withDefaults(
|
||||
clearable?: boolean
|
||||
showCoordinates?: boolean
|
||||
returnFormat?: 'object' | 'coordinates' | 'address'
|
||||
/** 无坐标时,用地图打开前的地址/城市文案做初始定位(如「嘉兴市南湖区」) */
|
||||
preferAddress?: string
|
||||
}>(),
|
||||
{
|
||||
placeholder: "请点击选择地图位置",
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
showCoordinates: true,
|
||||
returnFormat: 'object'
|
||||
returnFormat: 'object',
|
||||
preferAddress: "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -86,7 +89,8 @@ const handleClear = () => {
|
||||
// 打开地图选择器
|
||||
const handleMapClick = () => {
|
||||
if (props.disabled) return
|
||||
mapDialogRef.value?.open(state.selectedLocation, state.selectAddress)
|
||||
const hint = (props.preferAddress || state.selectAddress || "").trim()
|
||||
mapDialogRef.value?.open(state.selectedLocation, state.selectAddress, hint)
|
||||
}
|
||||
|
||||
// 处理位置确认
|
||||
|
||||
+77
-15
@@ -65,6 +65,37 @@ export function jsonp(url: string, options: JsonpOptions = {}): Promise<any> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 开发环境走 Vite /qqmap 代理,绕过 localhost WebService 域名白名单 110 */
|
||||
const useQqMapDevProxy = import.meta.env.DEV
|
||||
|
||||
/**
|
||||
* 请求腾讯 WebService:开发用同源 fetch+代理,生产用 JSONP(Referer=真实域名)
|
||||
* @param pathWithQuery 如 /ws/place/v1/suggestion/?keyword=xx&key=yy
|
||||
*/
|
||||
async function requestTencentWs(pathWithQuery: string, timeout = 10000): Promise<any> {
|
||||
if (useQqMapDevProxy) {
|
||||
const path = pathWithQuery.startsWith("/") ? pathWithQuery : `/${pathWithQuery}`
|
||||
// 代理侧用 JSON,不要 jsonp
|
||||
const qs = path.includes("?")
|
||||
? path.replace(/([?&])output=jsonp\b/i, "$1output=json").replace(/\?$/, "")
|
||||
: path
|
||||
const hasOutput = /[?&]output=/.test(qs)
|
||||
const url = `/qqmap${qs}${hasOutput ? "" : (qs.includes("?") ? "&" : "?") + "output=json"}`
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), timeout)
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctrl.signal })
|
||||
if (!res.ok) throw new Error(`地图接口 HTTP ${res.status}`)
|
||||
return await res.json()
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
const abs = `https://apis.map.qq.com${pathWithQuery.startsWith("/") ? pathWithQuery : `/${pathWithQuery}`}`
|
||||
const withJsonp = /[?&]output=/.test(abs) ? abs : `${abs}${abs.includes("?") ? "&" : "?"}output=jsonp`
|
||||
return jsonp(withJsonp.replace(/output=json\b/i, "output=jsonp"), { timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* 腾讯地图 API JSONP 请求封装
|
||||
*/
|
||||
@@ -82,48 +113,79 @@ export class TencentMapAPI {
|
||||
* @returns Promise
|
||||
*/
|
||||
async reverseGeocode(lat: number, lng: number): Promise<any> {
|
||||
const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${this.apiKey}&get_poi=1&output=jsonp`
|
||||
|
||||
try {
|
||||
const data = await jsonp(url)
|
||||
return data
|
||||
return await requestTencentWs(
|
||||
`/ws/geocoder/v1/?location=${lat},${lng}&key=${this.apiKey}&get_poi=1&output=jsonp`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("逆地址解析失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** IP 粗定位(无浏览器定位权限时的兜底) */
|
||||
async ipLocation(): Promise<any> {
|
||||
try {
|
||||
return await requestTencentWs(`/ws/location/v1/ip?key=${this.apiKey}&output=jsonp`, 8000)
|
||||
} catch (error) {
|
||||
console.error("IP定位失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址搜索
|
||||
* @param keyword 搜索关键词
|
||||
* @param boundary 搜索范围
|
||||
* @returns Promise
|
||||
*/
|
||||
async searchLocation(keyword: string, boundary: string = "region(全国,0)"): Promise<any> {
|
||||
const url = `https://apis.map.qq.com/ws/place/v1/search?boundary=${encodeURIComponent(boundary)}&keyword=${encodeURIComponent(
|
||||
keyword,
|
||||
)}&page_size=10&page_index=1&key=${this.apiKey}&output=jsonp`
|
||||
|
||||
async searchLocation(keyword: string, boundary: string = "region(全国,0)", pageSize = 20): Promise<any> {
|
||||
try {
|
||||
const data = await jsonp(url)
|
||||
return data
|
||||
return await requestTencentWs(
|
||||
`/ws/place/v1/search?boundary=${encodeURIComponent(boundary)}&keyword=${encodeURIComponent(
|
||||
keyword,
|
||||
)}&page_size=${pageSize}&page_index=1&key=${this.apiKey}&output=jsonp`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("地址搜索失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词输入提示(模糊联想)
|
||||
* @see https://lbs.qq.com/service/webService/webServiceGuide/webServiceSuggestion
|
||||
*/
|
||||
async suggestLocation(keyword: string, region = "全国"): Promise<any> {
|
||||
const params = [
|
||||
`keyword=${encodeURIComponent(keyword)}`,
|
||||
`region=${encodeURIComponent(region || "全国")}`,
|
||||
"region_fix=0",
|
||||
// policy=0 更偏地点列表;1 偏检索词补全,可选条数往往更少
|
||||
"policy=0",
|
||||
"page_index=1",
|
||||
"page_size=20",
|
||||
`key=${this.apiKey}`,
|
||||
"output=jsonp",
|
||||
]
|
||||
try {
|
||||
return await requestTencentWs(`/ws/place/v1/suggestion/?${params.join("&")}`, 8000)
|
||||
} catch (error) {
|
||||
console.error("地址联想失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址解析(正向地理编码)
|
||||
* @param address 地址
|
||||
* @returns Promise
|
||||
*/
|
||||
async geocode(address: string): Promise<any> {
|
||||
const url = `https://apis.map.qq.com/ws/geocoder/v1/?address=${encodeURIComponent(address)}&key=${this.apiKey}&output=jsonp`
|
||||
|
||||
try {
|
||||
const data = await jsonp(url)
|
||||
return data
|
||||
return await requestTencentWs(
|
||||
`/ws/geocoder/v1/?address=${encodeURIComponent(address)}&key=${this.apiKey}&output=jsonp`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("地址解析失败:", error)
|
||||
throw error
|
||||
|
||||
@@ -5,7 +5,21 @@
|
||||
<el-row>
|
||||
<el-col :span="24" class="mb20" v-if="!form.id">
|
||||
<el-form-item :label="t('area.pid')" prop="pid">
|
||||
<china-area class="w-full" :placeholder="t('area.inputPidByTip')" :plus="true" @change="handleChange"/>
|
||||
<div v-if="presetParentName" class="mb-2 text-sm text-gray-500">
|
||||
已预填上级:<el-tag size="small">{{ presetParentName }}</el-tag>
|
||||
<span class="ml-2">编码 {{ form.pid }},也可重新选择</span>
|
||||
</div>
|
||||
<china-area
|
||||
class="w-full"
|
||||
:placeholder="t('area.inputPidByTip')"
|
||||
:plus="true"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20" v-else>
|
||||
<el-form-item label="上级">
|
||||
<span>{{ form.parentName || form.pid || "-" }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
@@ -94,10 +108,13 @@ const area_type_dict = [
|
||||
{value: '4', label: '街道'}
|
||||
]
|
||||
|
||||
const presetParentName = ref("")
|
||||
|
||||
// 提交表单数据
|
||||
const form = reactive({
|
||||
id: '',
|
||||
pid: 100000,
|
||||
pid: 100000 as number | string,
|
||||
parentName: '',
|
||||
name: '',
|
||||
letter: '',
|
||||
adcode: 0,
|
||||
@@ -109,6 +126,12 @@ const form = reactive({
|
||||
cityCode: '',
|
||||
});
|
||||
|
||||
const suggestChildType = (parentType?: string) => {
|
||||
const n = Number(parentType)
|
||||
if (!Number.isFinite(n)) return "2"
|
||||
return String(Math.min(n + 1, 4))
|
||||
}
|
||||
|
||||
// 定义校验规则
|
||||
const dataRules = ref({
|
||||
name: [
|
||||
@@ -123,30 +146,52 @@ const dataRules = ref({
|
||||
],
|
||||
adcode: [
|
||||
{ validator: rule.overLength, trigger: 'blur' },
|
||||
{required: true, message: '编码不能为空', trigger: 'blur'},
|
||||
{ required: true, message: '编码不能为空', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
validateExist(rule, value, callback, form.id !== '');
|
||||
if (value == null || value === '' || Number(value) <= 0) {
|
||||
callback(new Error('请填写正确区划编码(如嘉兴市 330400),不能为 0'))
|
||||
return
|
||||
}
|
||||
validateExist(rule, value, callback, form.id !== '')
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
// 打开弹窗
|
||||
const openDialog = (id: string) => {
|
||||
// 打开弹窗;parent 用于「新增下级」预填
|
||||
const openDialog = (
|
||||
id?: string | number,
|
||||
parent?: { pid: number | string; parentName?: string; parentType?: string },
|
||||
adcode?: string | number,
|
||||
) => {
|
||||
visible.value = true
|
||||
form.id = ''
|
||||
presetParentName.value = ""
|
||||
|
||||
// 重置表单数据
|
||||
nextTick(() => {
|
||||
dataFormRef.value?.resetFields();
|
||||
form.pid = 100000
|
||||
form.areaType = "2"
|
||||
form.areaStatus = "1"
|
||||
form.hot = "0"
|
||||
form.areaSort = 0
|
||||
if (parent?.pid != null) {
|
||||
form.pid = parent.pid
|
||||
presetParentName.value = parent.parentName || String(parent.pid)
|
||||
form.areaType = suggestChildType(parent.parentType)
|
||||
}
|
||||
});
|
||||
|
||||
// 获取sysArea信息
|
||||
if (id) {
|
||||
form.id = id
|
||||
getsysAreaData(id)
|
||||
form.id = String(id)
|
||||
getsysAreaData({ id })
|
||||
return
|
||||
}
|
||||
if (adcode) {
|
||||
getsysAreaData({ adcode })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,7 +203,16 @@ const onSubmit = async () => {
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
form.id ? await putObj(form) : await addObj(form);
|
||||
const payload: any = { ...form }
|
||||
delete payload.parentName
|
||||
delete payload.hasChildren
|
||||
if (!payload.id) delete payload.id
|
||||
payload.pid = payload.pid === "" || payload.pid == null ? 100000 : Number(payload.pid)
|
||||
// 移动端 App 接口依赖有效区划;保存时默认置为有效
|
||||
if (payload.areaStatus == null || payload.areaStatus === "") {
|
||||
payload.areaStatus = "1"
|
||||
}
|
||||
form.id ? await putObj(payload) : await addObj(payload);
|
||||
useMessage().success(form.id ? t('common.editSuccessText') : t('common.addSuccessText'));
|
||||
visible.value = false;
|
||||
emit('refresh');
|
||||
@@ -171,11 +225,19 @@ const onSubmit = async () => {
|
||||
|
||||
|
||||
// 初始化表单数据
|
||||
const getsysAreaData = (id: string) => {
|
||||
// 获取数据
|
||||
const getsysAreaData = (query: { id?: string | number; adcode?: string | number }) => {
|
||||
loading.value = true
|
||||
getObj({id: id}).then((res: any) => {
|
||||
Object.assign(form, res.data)
|
||||
getObj(query).then(async (res: any) => {
|
||||
Object.assign(form, res.data || {})
|
||||
if (form.id != null) form.id = String(form.id)
|
||||
if (form.pid) {
|
||||
try {
|
||||
const p: any = await getObj({ adcode: form.pid })
|
||||
form.parentName = p?.data?.name || form.parentName || ""
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
areaSort:'排序值',
|
||||
hot:'热门',
|
||||
areaStatus:'有效',
|
||||
parentName: '上级',
|
||||
inputAdCodeByTip: '请选择编码',
|
||||
inputPidByTip: '请选择父级地区',
|
||||
inputNameByTip: '请输入地区名称',
|
||||
|
||||
+413
-101
@@ -1,105 +1,184 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<el-row v-show="showSearch">
|
||||
<SearchPanel
|
||||
ref="queryRef"
|
||||
:model="state.queryForm"
|
||||
:loading="Boolean(state.loading)"
|
||||
:search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')"
|
||||
@search="getDataList"
|
||||
@reset="resetQuery"
|
||||
<div class="layout-padding area-page">
|
||||
<div class="layout-padding-auto layout-padding-view area-layout">
|
||||
<!-- 左侧:行政区划树 -->
|
||||
<div class="area-tree-pane">
|
||||
<div class="area-tree-pane__head">
|
||||
<span class="area-tree-pane__title">行政区划</span>
|
||||
<div class="area-tree-pane__actions">
|
||||
<el-button type="primary" size="small" @click="openAdd()">新增</el-button>
|
||||
<el-button type="primary" plain size="small" :disabled="!currentNode" @click="openEditCurrent()">修改</el-button>
|
||||
<el-button link type="primary" @click="reloadTree">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="treeFilter"
|
||||
clearable
|
||||
placeholder="过滤树节点"
|
||||
class="mb-2"
|
||||
@input="onTreeFilter"
|
||||
/>
|
||||
<el-tree
|
||||
:key="treeKey"
|
||||
ref="treeRef"
|
||||
class="area-tree"
|
||||
node-key="id"
|
||||
lazy
|
||||
highlight-current
|
||||
:indent="16"
|
||||
:load="loadTreeNode"
|
||||
:props="treeProps"
|
||||
:expand-on-click-node="false"
|
||||
:filter-node-method="filterTreeNode"
|
||||
@node-click="onTreeClick"
|
||||
>
|
||||
<el-form-item :label="t('area.pid')" prop="adcode">
|
||||
<china-area :type="3" :placeholder="t('area.inputPidByTip')" v-model="pid" :plus="true" @change="handleChange" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('area.name')" prop="name">
|
||||
<el-input :placeholder="t('area.inputNameByTip')" v-model="state.queryForm.name" />
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()" v-auth="'sys_sysArea_add'">
|
||||
{{ $t("common.addBtn") }}
|
||||
</el-button>
|
||||
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'sys_sysArea_del'" @click="handleDelete(selectObjs)">
|
||||
{{ $t("common.delBtn") }}
|
||||
</el-button>
|
||||
<template #default="{ data }">
|
||||
<span class="area-tree-node">
|
||||
<el-tag size="small" effect="plain" :type="areaTypeTag(data.areaType)" class="mr-1">
|
||||
{{ areaTypeLabel(data.areaType) }}
|
||||
</el-tag>
|
||||
<span class="area-tree-node__name" :title="data.name || data.label">{{ data.name || data.label }}</span>
|
||||
<el-button class="area-tree-node__edit" link type="primary" @click.stop="openEdit(data)">修改</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:当前节点下的子级列表 -->
|
||||
<div class="area-table-pane">
|
||||
<div class="area-breadcrumb mb-3">
|
||||
<span class="text-gray-500 mr-2">当前:</span>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item>
|
||||
<a href="javascript:;" @click="goRoot">中国</a>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-for="(p, i) in breadcrumb" :key="p.adcode">
|
||||
<a v-if="i < breadcrumb.length - 1" href="javascript:;" @click="goBreadcrumb(i)">{{ p.name }}</a>
|
||||
<span v-else>{{ p.name }}</span>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<el-tag v-if="currentNode" size="small" class="ml-2" :type="areaTypeTag(currentNode.areaType)">
|
||||
{{ areaTypeLabel(currentNode.areaType) }} · {{ currentNode.adcode }}
|
||||
</el-tag>
|
||||
<el-button v-if="currentNode" class="ml-2" type="primary" link @click="openEditCurrent()">修改当前</el-button>
|
||||
</div>
|
||||
|
||||
<div class="area-toolbar mb8">
|
||||
<el-button type="primary" @click="openAdd()">新增</el-button>
|
||||
<el-button type="primary" plain :disabled="!currentNode" @click="openEditCurrent()">修改当前</el-button>
|
||||
<el-button type="primary" plain @click="openAddChild()">在当前下新增</el-button>
|
||||
<el-button plain :disabled="multiple" type="danger" @click="handleDelete(selectObjs)">删除</el-button>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
:export="'sys_sysArea_export'"
|
||||
@exportExcel="exportExcel"
|
||||
class="ml10 mr20"
|
||||
style="float: right"
|
||||
class="ml10"
|
||||
style="margin-left: auto"
|
||||
@queryTable="getDataList"
|
||||
></right-toolbar>
|
||||
/>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table
|
||||
:data="state.dataList"
|
||||
v-loading="state.loading"
|
||||
border
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle"
|
||||
>
|
||||
<el-table-column type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="40" />
|
||||
<el-table-column prop="name" :label="t('area.name')" show-overflow-tooltip />
|
||||
<el-table-column prop="adcode" :label="t('area.adcode')" show-overflow-tooltip />
|
||||
<el-table-column prop="areaType" :label="t('area.areaType')" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<dict-tag :options="area_type_dict" :value="scope.row.areaType"></dict-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hot" :label="t('area.hot')" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<dict-tag :options="yes_no_type" :value="scope.row.hot"></dict-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="areaStatus" :label="t('area.areaStatus')" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<dict-tag :options="yes_no_type" :value="scope.row.areaStatus"></dict-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="areaSort" :label="t('area.areaSort')" width="100" sortable="custom" show-overflow-tooltip />
|
||||
<el-table-column :label="$t('common.action')" width="150">
|
||||
<template #default="scope">
|
||||
<el-button icon="edit-pen" text type="primary" v-auth="'sys_sysArea_edit'" @click="formDialogRef.openDialog(scope.row.id)"
|
||||
>{{ $t("common.editBtn") }}
|
||||
</el-button>
|
||||
<el-button icon="delete" text type="primary" v-auth="'sys_sysArea_del'" @click="handleDelete([scope.row.id])"
|
||||
>{{ $t("common.delBtn") }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
|
||||
<el-row v-show="showSearch">
|
||||
<SearchPanel
|
||||
ref="queryRef"
|
||||
:model="state.queryForm"
|
||||
:loading="Boolean(state.loading)"
|
||||
:search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')"
|
||||
@search="getDataList"
|
||||
@reset="resetQuery"
|
||||
>
|
||||
<el-form-item :label="t('area.name')" prop="name">
|
||||
<el-input :placeholder="t('area.inputNameByTip')" v-model="state.queryForm.name" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="areaType">
|
||||
<el-select v-model="state.queryForm.areaType" clearable placeholder="全部类型" style="width: 140px">
|
||||
<el-option v-for="item in area_type_dict" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
</el-row>
|
||||
|
||||
<el-table
|
||||
:data="state.dataList"
|
||||
v-loading="state.loading"
|
||||
border
|
||||
row-key="id"
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle"
|
||||
@row-dblclick="openEdit"
|
||||
>
|
||||
<el-table-column type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="name" :label="t('area.name')" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="name-cell">
|
||||
<span class="name-cell__text">{{ scope.row.name }}</span>
|
||||
<el-tag v-if="scope.row.hasChildren" size="small" type="info" effect="plain">有下级</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="parentName" label="上级" min-width="120" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ scope.row.parentName || (scope.row.pid === 100000 ? "中国" : "-") }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="adcode" :label="t('area.adcode')" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="areaType" :label="t('area.areaType')" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="areaTypeTag(scope.row.areaType)">{{ areaTypeLabel(scope.row.areaType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hot" :label="t('area.hot')" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<dict-tag :options="yes_no_type" :value="scope.row.hot" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="areaStatus" :label="t('area.areaStatus')" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<dict-tag :options="yes_no_type" :value="scope.row.areaStatus" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="areaSort" :label="t('area.areaSort')" width="90" sortable="custom" />
|
||||
<el-table-column :label="$t('common.action')" width="280" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link @click="openEdit(scope.row)">修改</el-button>
|
||||
<el-button type="primary" link @click="openAddChild(scope.row)">新增下级</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.hasChildren"
|
||||
type="primary"
|
||||
link
|
||||
@click="enterChildren(scope.row)"
|
||||
>
|
||||
下级
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete([scope.row.id])">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑、新增 -->
|
||||
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
|
||||
<form-dialog ref="formDialogRef" @refresh="onFormRefresh" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemSysArea">
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { delObjs, fetchList } from "/@/api/admin/sysArea"
|
||||
import { delObjs, fetchList, fetchChildren } from "/@/api/admin/sysArea"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { useI18n } from "vue-i18n"
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 省市区查询组件
|
||||
const ChinaArea = defineAsyncComponent(() => import("/@/components/ChinaArea/index.vue"))
|
||||
// 引入组件
|
||||
const FormDialog = defineAsyncComponent(() => import("./form.vue"))
|
||||
// 定义查询字典
|
||||
const { yes_no_type } = useDict("yes_no_type")
|
||||
|
||||
const ROOT_ADCODE = 100000
|
||||
|
||||
const area_type_dict = [
|
||||
{ value: "0", label: "国家" },
|
||||
{ value: "1", label: "省份" },
|
||||
@@ -107,57 +186,188 @@ const area_type_dict = [
|
||||
{ value: "3", label: "县区" },
|
||||
{ value: "4", label: "街道" },
|
||||
]
|
||||
// 定义变量内容
|
||||
|
||||
const areaTypeLabel = (v: string | number) => area_type_dict.find((i) => i.value === String(v))?.label || "-"
|
||||
const areaTypeTag = (v: string | number) => {
|
||||
const map: Record<string, string> = { "0": "danger", "1": "", "2": "success", "3": "warning", "4": "info" }
|
||||
return (map[String(v)] || "info") as any
|
||||
}
|
||||
|
||||
const formDialogRef = ref()
|
||||
// 搜索变量
|
||||
const queryRef = ref()
|
||||
const treeRef = ref()
|
||||
const showSearch = ref(true)
|
||||
// 多选变量
|
||||
const selectObjs = ref([]) as any
|
||||
const multiple = ref(true)
|
||||
const pid = ref()
|
||||
const treeFilter = ref("")
|
||||
const treeKey = ref(0)
|
||||
|
||||
type Crumb = { adcode: number; name: string; areaType?: string; dbId?: string | number }
|
||||
const breadcrumb = ref<Crumb[]>([])
|
||||
const currentNode = ref<Crumb | null>(null)
|
||||
|
||||
const treeProps = { label: "name", children: "children", isLeaf: "isLeaf" }
|
||||
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
queryForm: {
|
||||
adcode: "",
|
||||
adcode: ROOT_ADCODE,
|
||||
name: "",
|
||||
areaType: "",
|
||||
},
|
||||
ascs: ["adcode"],
|
||||
pageList: fetchList,
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const normalizeTreeNode = (raw: any) => {
|
||||
const adcode = Number(raw.adcode ?? raw.id)
|
||||
const name = raw.name || raw.label || String(adcode)
|
||||
const areaType = String(raw.areaType ?? "")
|
||||
const hasChildren = raw.hasChildren === true || raw.hasChildren === "true"
|
||||
// 街道为末级;其余以是否有子级为准,不能因空 children 误判成叶子(否则展开箭头没有、下级看不见)
|
||||
const isLeaf = areaType === "4" || !hasChildren
|
||||
return {
|
||||
id: adcode,
|
||||
dbId: raw.id,
|
||||
adcode,
|
||||
pid: raw.pid,
|
||||
name,
|
||||
label: name,
|
||||
areaType,
|
||||
hot: raw.hot,
|
||||
hasChildren,
|
||||
isLeaf,
|
||||
}
|
||||
}
|
||||
|
||||
const loadTreeNode = async (node: any, resolve: (data: any[]) => void) => {
|
||||
try {
|
||||
const pid = node.level === 0 ? ROOT_ADCODE : Number(node.data?.adcode ?? node.data?.id)
|
||||
if (!pid) {
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
const res: any = await fetchChildren({ pid })
|
||||
resolve((res?.data || []).map(normalizeTreeNode))
|
||||
} catch {
|
||||
resolve([])
|
||||
}
|
||||
}
|
||||
|
||||
const filterTreeNode = (value: string, data: any) => {
|
||||
if (!value) return true
|
||||
return String(data.name || data.label || "").includes(value)
|
||||
}
|
||||
|
||||
const onTreeFilter = (val: string) => {
|
||||
treeRef.value?.filter(val)
|
||||
}
|
||||
|
||||
const reloadTree = () => {
|
||||
treeKey.value += 1
|
||||
// 强制重建:通过 key 不够时清空并重新点根
|
||||
breadcrumb.value = []
|
||||
currentNode.value = null
|
||||
state.queryForm.adcode = ROOT_ADCODE
|
||||
getDataList()
|
||||
nextTick(() => {
|
||||
// el-tree lazy 无直接 reload,用替换 ref 外层 key
|
||||
})
|
||||
}
|
||||
|
||||
const onTreeClick = (data: any) => {
|
||||
const adcode = Number(data.adcode ?? data.id)
|
||||
const name = data.name || data.label
|
||||
currentNode.value = { adcode, name, areaType: data.areaType, dbId: data.dbId }
|
||||
// 简化面包屑:点击树节点时用当前节点作为末级(完整路径依赖用户逐级点选)
|
||||
const idx = breadcrumb.value.findIndex((b) => b.adcode === adcode)
|
||||
if (idx >= 0) {
|
||||
breadcrumb.value = breadcrumb.value.slice(0, idx + 1)
|
||||
} else {
|
||||
breadcrumb.value = [...breadcrumb.value.filter((b) => b.adcode !== adcode), { adcode, name, areaType: data.areaType }]
|
||||
}
|
||||
state.queryForm.adcode = adcode
|
||||
getDataList()
|
||||
}
|
||||
|
||||
const goRoot = () => {
|
||||
breadcrumb.value = []
|
||||
currentNode.value = null
|
||||
state.queryForm.adcode = ROOT_ADCODE
|
||||
treeRef.value?.setCurrentKey?.(null)
|
||||
getDataList()
|
||||
}
|
||||
|
||||
const goBreadcrumb = (index: number) => {
|
||||
breadcrumb.value = breadcrumb.value.slice(0, index + 1)
|
||||
const last = breadcrumb.value[breadcrumb.value.length - 1]
|
||||
currentNode.value = last
|
||||
state.queryForm.adcode = last.adcode
|
||||
getDataList()
|
||||
}
|
||||
|
||||
const enterChildren = (row: any) => {
|
||||
const adcode = Number(row.adcode)
|
||||
const crumb = { adcode, name: row.name, areaType: row.areaType, dbId: row.id ?? row.dbId }
|
||||
currentNode.value = crumb
|
||||
if (!breadcrumb.value.some((b) => b.adcode === adcode)) {
|
||||
breadcrumb.value = [...breadcrumb.value, crumb]
|
||||
}
|
||||
state.queryForm.adcode = adcode
|
||||
getDataList()
|
||||
}
|
||||
|
||||
const openAdd = () => {
|
||||
formDialogRef.value?.openDialog()
|
||||
}
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
const pk = row?.dbId ?? row?.id
|
||||
const adcode = row?.adcode
|
||||
formDialogRef.value?.openDialog(pk, undefined, adcode)
|
||||
}
|
||||
|
||||
const openEditCurrent = () => {
|
||||
if (!currentNode.value) return
|
||||
formDialogRef.value?.openDialog(currentNode.value.dbId, undefined, currentNode.value.adcode)
|
||||
}
|
||||
|
||||
const openAddChild = (row?: any) => {
|
||||
const parentAdcode = row?.adcode ?? currentNode.value?.adcode ?? ROOT_ADCODE
|
||||
const parentName = row?.name ?? currentNode.value?.name ?? "中国"
|
||||
const parentType = row?.areaType ?? currentNode.value?.areaType ?? "0"
|
||||
formDialogRef.value?.openDialog(undefined, { pid: parentAdcode, parentName, parentType })
|
||||
}
|
||||
|
||||
const onFormRefresh = () => {
|
||||
getDataList()
|
||||
treeKey.value += 1
|
||||
}
|
||||
|
||||
const resetQuery = () => {
|
||||
// 清空搜索条件
|
||||
queryRef.value?.resetFields()
|
||||
pid.value = ""
|
||||
// 清空多选
|
||||
state.queryForm.name = ""
|
||||
state.queryForm.areaType = ""
|
||||
selectObjs.value = []
|
||||
getDataList()
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
downBlobFile("/admin/sysArea/export", Object.assign(state.queryForm, { ids: selectObjs }), "行政区域.xlsx")
|
||||
downBlobFile("/admin/sysArea/export", Object.assign({}, state.queryForm, { ids: selectObjs }), "行政区域.xlsx")
|
||||
}
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
selectObjs.value = objs.map(({ id }) => id)
|
||||
multiple.value = !objs.length
|
||||
}
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = async (ids: string[]) => {
|
||||
try {
|
||||
await useMessageBox().confirm(t("common.delConfirmText"))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await delObjs(ids)
|
||||
getDataList()
|
||||
@@ -166,10 +376,112 @@ const handleDelete = async (ids: string[]) => {
|
||||
useMessage().error(err.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 地区查询
|
||||
const handleChange = (data: string) => {
|
||||
let dataArray = data.split(",")
|
||||
state.queryForm.adcode = dataArray[dataArray.length - 1]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.area-page {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.area-page :deep(.layout-padding-auto) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.area-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.area-tree-pane {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--el-bg-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.area-tree-pane__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.area-tree-pane__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.area-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 8px 0;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
.area-tree-pane__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.area-tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.area-tree-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
width: 100%;
|
||||
}
|
||||
.area-tree-node__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.area-tree-node__edit {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.area-table-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.area-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.name-cell__text {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,14 @@
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item label="详细地址" :span="2">{{ hotel.address || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="位置区域">
|
||||
{{ hotel.district || "-" }}
|
||||
<span v-if="hotel.districtAdcode" class="text-gray-400">({{ hotel.districtAdcode }})</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商圈地标">{{ hotel.bizArea || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="星级档次">{{ starLabel(hotel.starLevel) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="评分">{{ hotel.score != null ? hotel.score : "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="设施标签" :span="2">{{ hotel.facilityTags || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="坐标">
|
||||
<span v-if="hotel.longitude != null && hotel.latitude != null">
|
||||
{{ 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<number, string> = { 1: "民宿", 2: "经济", 3: "舒适", 4: "高档", 5: "五星" }
|
||||
const n = Number(lv)
|
||||
return map[n] || "-"
|
||||
}
|
||||
|
||||
const localSaving = ref(false)
|
||||
const roomDialogVisible = ref(false)
|
||||
|
||||
@@ -69,6 +69,34 @@
|
||||
<div class="form-tip">支持多张,可连续选择上传;保存为逗号分隔 URL</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" class="form-col">
|
||||
<el-form-item label="位置区域">
|
||||
<china-area
|
||||
v-if="canEditProfile && districtPathReady"
|
||||
:key="districtCascaderKey"
|
||||
v-model="districtPath"
|
||||
class="w-full"
|
||||
:type="3"
|
||||
:plus="true"
|
||||
placeholder="先选省/市/区县,地图会跟到该区域"
|
||||
@change="onDistrictChange"
|
||||
/>
|
||||
<div v-else-if="canEditProfile" class="text-gray-400 text-sm">区域加载中…</div>
|
||||
<div v-else class="text-gray-500">{{ form.district || "-" }} {{ form.districtAdcode ? `(${form.districtAdcode})` : "" }}</div>
|
||||
<div v-if="form.district" class="form-tip">已选:{{ form.district }}({{ form.districtAdcode || "-" }})</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" class="form-col">
|
||||
<el-form-item label="商圈地标">
|
||||
<el-input
|
||||
v-model="form.bizArea"
|
||||
class="w-full"
|
||||
placeholder="如:嘉兴南站附近"
|
||||
maxlength="128"
|
||||
:disabled="!canEditProfile"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="form-col">
|
||||
<el-form-item label="地图选点">
|
||||
<div class="map-lng-lat-row">
|
||||
@@ -77,7 +105,8 @@
|
||||
class="map-pick-part"
|
||||
:disabled="!canEditProfile"
|
||||
:show-coordinates="false"
|
||||
placeholder="搜索或点击地图选择位置"
|
||||
:prefer-address="mapPreferAddress"
|
||||
placeholder="先选区域,再点这里搜索定位"
|
||||
@change="onMapChange"
|
||||
/>
|
||||
<div class="lng-lat-inline">
|
||||
@@ -96,6 +125,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-tip">支持模糊搜索小区/酒店/路名;打开地图会按「位置区域」落到对应城市</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="form-col">
|
||||
@@ -110,6 +140,48 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" class="form-col">
|
||||
<el-form-item label="星级档次">
|
||||
<el-select v-model="form.starLevel" class="w-full" clearable placeholder="请选择" :disabled="!canEditProfile">
|
||||
<el-option :value="5" label="五星" />
|
||||
<el-option :value="4" label="高档" />
|
||||
<el-option :value="3" label="舒适" />
|
||||
<el-option :value="2" label="经济" />
|
||||
<el-option :value="1" label="民宿" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" class="form-col">
|
||||
<el-form-item label="评分">
|
||||
<el-input-number
|
||||
v-model="form.score"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="5"
|
||||
:precision="1"
|
||||
:step="0.1"
|
||||
controls-position="right"
|
||||
:disabled="!canEditProfile"
|
||||
/>
|
||||
<div class="form-tip">0.0 ~ 5.0,移动端可按评分筛选</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="form-col">
|
||||
<el-form-item label="设施标签">
|
||||
<el-select
|
||||
v-model="form.facilityTagList"
|
||||
class="w-full"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="含早餐 / 免费取消 / 免费停车 等"
|
||||
:disabled="!canEditProfile"
|
||||
>
|
||||
<el-option v-for="t in facilityTagOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="form-col">
|
||||
<el-form-item label="酒店简介">
|
||||
<editor
|
||||
@@ -469,6 +541,9 @@ import {
|
||||
removeLocalDaily,
|
||||
} from "/@/api/supermarket/smHotelMid"
|
||||
import MapSelect from "/@/components/map/map-select.vue"
|
||||
import { getObj as getAreaObj } from "/@/api/admin/sysArea"
|
||||
|
||||
const ChinaArea = defineAsyncComponent(() => 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<FormInstance>()
|
||||
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<any>(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<any[]>([])
|
||||
const products = ref<any[]>([])
|
||||
const dailyList = ref<any[]>([])
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user