瀏覽代碼

feat(risk): 地图选点组件 + 字典化 + 审计 UI 重做 + 401 修复

新增:
- src/components/MapPickerDialog: 腾讯地图弹框选点,自动定位/逆地理/联想搜索

修复:
- request.ts 401 处理: 同步 removeToken + 绝对路径 location.href 兜底,避免 pinia 未就绪导致 '重新登录' 没反应
- personnel/index.vue 人员编辑: storeId 不在 storeOptions 时临时塞回,避免显示裸 ID
- personnel/index.vue 人员 cert 保存: 物理删+insert 绕过软删 PRIMARY KEY 冲突
- workReport/index.vue 作业上报: auditStatus 字典值统一为中文(待审核/审核通过/审核不通过)
- hiddenDanger/index.vue 安全隐患: dangerType 改字典驱动 risk_danger_type
- personnelChange/index.vue 审计工作: 加载 audit_change_type 字典 + 文件网格 3 列 + 圆形彩色 icon + 表格 table-layout fixed
- utils/request.ts 加 removeToken import

环境:
- .env.development / .env.production 加 VITE_QQMAP_KEY 腾讯地图 key
yangjingjing 1 天之前
父節點
當前提交
a22af8d1d6

+ 3 - 0
.env.development

@@ -11,6 +11,9 @@ VITE_APP_BASE_API = '/dev-api'
 # 应用访问路径 例如使用前缀 /admin/
 # 应用访问路径 例如使用前缀 /admin/
 VITE_APP_CONTEXT_PATH = '/'
 VITE_APP_CONTEXT_PATH = '/'
 
 
+# 腾讯地图 API Key(地图选点组件使用)
+VITE_QQMAP_KEY = 'I4OBZ-ULG6Q-JXZ5N-4HSCN-PE4AZ-3TFRU'
+
 # 监控地址
 # 监控地址
 VITE_APP_MONITOR_ADMIN = 'http://localhost:9090/admin/applications'
 VITE_APP_MONITOR_ADMIN = 'http://localhost:9090/admin/applications'
 
 

+ 3 - 0
.env.production

@@ -8,6 +8,9 @@ VITE_APP_ENV = 'production'
 # 应用访问路径 例如使用前缀 /admin/
 # 应用访问路径 例如使用前缀 /admin/
 VITE_APP_CONTEXT_PATH = '/'
 VITE_APP_CONTEXT_PATH = '/'
 
 
+# 腾讯地图 API Key(地图选点组件使用)
+VITE_QQMAP_KEY = 'I4OBZ-ULG6Q-JXZ5N-4HSCN-PE4AZ-3TFRU'
+
 # 监控地址
 # 监控地址
 VITE_APP_MONITOR_ADMIN = '/admin/applications'
 VITE_APP_MONITOR_ADMIN = '/admin/applications'
 
 

+ 0 - 4
.eslintrc-auto-import.json

@@ -5,10 +5,6 @@
     "ComputedRef": true,
     "ComputedRef": true,
     "DirectiveBinding": true,
     "DirectiveBinding": true,
     "EffectScope": true,
     "EffectScope": true,
-    "ElLoading": true,
-    "ElMessage": true,
-    "ElMessageBox": true,
-    "ElNotification": true,
     "ExtractDefaultPropTypes": true,
     "ExtractDefaultPropTypes": true,
     "ExtractPropTypes": true,
     "ExtractPropTypes": true,
     "ExtractPublicPropTypes": true,
     "ExtractPublicPropTypes": true,

+ 388 - 0
src/components/MapPickerDialog/index.vue

@@ -0,0 +1,388 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="地图选点"
+    width="800px"
+    :close-on-click-modal="false"
+    append-to-body
+    @open="onOpen"
+    @close="onClose"
+  >
+    <div class="map-picker">
+      <div class="map-toolbar">
+        <el-autocomplete
+          v-model="keyword"
+          :fetch-suggestions="fetchSuggestions"
+          :trigger-on-focus="true"
+          placeholder="搜索地点(输入联想,回车或点选)"
+          clearable
+          style="width: 280px"
+          @keyup.enter="searchPlace"
+          @select="onSelectSuggestion"
+        >
+          <template #append>
+            <el-button :icon="Search" @click="searchPlace" />
+          </template>
+          <template #default="{ item }">
+            <div class="suggestion-item">
+              <div class="suggestion-title">{{ item.title }}</div>
+              <div class="suggestion-addr">{{ item.address }}</div>
+            </div>
+          </template>
+        </el-autocomplete>
+        <el-tooltip content="定位到我的位置" placement="top">
+          <el-button
+            class="map-locate-btn"
+            :icon="Aim"
+            circle
+            :loading="locating"
+            @click="locateMe"
+          />
+        </el-tooltip>
+        <span class="map-tip">点击地图或搜索结果选择位置</span>
+        <span class="map-coord">当前: {{ coordText }}</span>
+      </div>
+      <div ref="mapContainer" class="map-container" />
+    </div>
+    <template #footer>
+      <el-button @click="visible = false">取消</el-button>
+      <el-button type="primary" :disabled="!pickedLat" @click="confirm">确定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Search, Aim } from '@element-plus/icons-vue'
+
+const props = defineProps<{
+  modelValue: boolean
+  /** 初始纬度,弹窗打开时地图中心 */
+  initLat?: number
+  /** 初始经度 */
+  initLng?: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: boolean): void
+  (e: 'pick', payload: { lat: number; lng: number; address?: string }): void
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: v => emit('update:modelValue', v)
+})
+
+const keyword = ref('')
+const mapContainer = ref<HTMLDivElement | null>(null)
+const pickedLat = ref<number | null>(null)
+const pickedLng = ref<number | null>(null)
+const pickedAddress = ref('')
+const locating = ref(false)
+const coordText = computed(() => {
+  if (pickedLat.value == null || pickedLng.value == null) return '未选点'
+  return `纬度 ${pickedLat.value.toFixed(6)}, 经度 ${pickedLng.value.toFixed(6)}`
+})
+
+let TMap: any = null
+let map: any = null
+let marker: any = null
+let scriptLoaded = false
+let scriptLoading: Promise<void> | null = null
+
+function getBrowserLocation(timeoutMs = 8000, highAccuracy = true): Promise<{ lat: number; lng: number } | null> {
+  return new Promise(resolve => {
+    if (!navigator?.geolocation) return resolve(null)
+    const timer = setTimeout(() => resolve(null), timeoutMs)
+    navigator.geolocation.getCurrentPosition(
+      pos => {
+        clearTimeout(timer)
+        resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude })
+      },
+      () => {
+        clearTimeout(timer)
+        resolve(null)
+      },
+      { enableHighAccuracy: highAccuracy, timeout: timeoutMs, maximumAge: 30000 }
+    )
+  })
+}
+
+async function locateMe() {
+  if (!map) return
+  locating.value = true
+  try {
+    const geo = await getBrowserLocation()
+    if (!geo) {
+      ElMessage.warning('无法获取位置,请检查浏览器定位权限')
+      return
+    }
+    map.setCenter({ lat: geo.lat, lng: geo.lng })
+    map.setZoom(18)
+    placeMarker(geo.lat, geo.lng)
+  } finally {
+    locating.value = false
+  }
+}
+
+async function resolveInitialCenter(): Promise<{ lat: number; lng: number }> {
+  // 已填了经纬度 → 用填的
+  if (typeof props.initLat === 'number' && typeof props.initLng === 'number' && (props.initLat !== 0 || props.initLng !== 0)) {
+    return { lat: props.initLat, lng: props.initLng }
+  }
+  // 没填 → 自动定位
+  const geo = await getBrowserLocation()
+  if (geo) return geo
+  return { lat: 39.908823, lng: 116.397470 } // 默认北京
+}
+
+function loadScript(): Promise<void> {
+  if (scriptLoaded) return Promise.resolve()
+  if (scriptLoading) return scriptLoading
+  const key = import.meta.env.VITE_QQMAP_KEY
+  if (!key) {
+    return Promise.reject(new Error('缺少腾讯地图 VITE_QQMAP_KEY 配置'))
+  }
+  scriptLoading = new Promise((resolve, reject) => {
+    const existing = document.querySelector('script[data-qqmap-gljs]') as HTMLScriptElement | null
+    if (existing) {
+      scriptLoaded = true
+      resolve()
+      return
+    }
+    const s = document.createElement('script')
+    s.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}&libraries=service`
+    s.async = true
+    s.dataset.qqmapGljs = '1'
+    s.onload = () => {
+      scriptLoaded = true
+      resolve()
+    }
+    s.onerror = () => reject(new Error('腾讯地图脚本加载失败'))
+    document.head.appendChild(s)
+  })
+  return scriptLoading
+}
+
+async function onOpen() {
+  pickedLat.value = null
+  pickedLng.value = null
+  pickedAddress.value = ''
+  keyword.value = ''
+  try {
+    await loadScript()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '地图加载失败')
+    return
+  }
+  // 等待 DOM 容器就绪
+  await new Promise(r => setTimeout(r, 50))
+  if (!mapContainer.value) return
+  const win = window as any
+  TMap = win.TMap || win.tMap
+  if (!TMap) {
+    ElMessage.error('腾讯地图 SDK 未挂载')
+    return
+  }
+  // 自动定位: 优先级 form.lat/lng > 浏览器 Geolocation > 默认北京
+  const center = await resolveInitialCenter()
+  map = new TMap.Map(mapContainer.value, {
+    center: new TMap.LatLng(center.lat, center.lng),
+    zoom: 18,
+    pitch: 0,
+    rotation: 0
+  })
+  map.on('click', (evt: any) => {
+    const { lat, lng } = evt.latLng
+    placeMarker(lat, lng)
+  })
+  // 如果有初始坐标,放一个 marker
+  if (typeof props.initLat === 'number' && typeof props.initLng === 'number') {
+    placeMarker(props.initLat, props.initLng)
+  }
+  // 异步逆地理得到地址(用 WebService API,避免依赖 GL service 库)
+  reverseGeocode(pickedLat.value, pickedLng.value).then(addr => {
+    if (addr) pickedAddress.value = addr
+  }).catch(() => {})
+}
+
+function placeMarker(lat: number, lng: number) {
+  if (!map || !TMap) return
+  pickedLat.value = lat
+  pickedLng.value = lng
+  if (marker) {
+    // 腾讯 GL JS MultiMarker 没有 setPosition,用 updateGeometries 更新位置
+    try {
+      marker.updateGeometries([{ id: 'pick', position: new TMap.LatLng(lat, lng) }])
+    } catch {
+      // 旧版本 API: 先删再加
+      try { marker.del(['pick']) } catch {}
+      marker.add({ id: 'pick', position: new TMap.LatLng(lat, lng) })
+    }
+  } else {
+    marker = new TMap.MultiMarker({
+      map,
+      styles: {
+        marker: new TMap.MarkerStyle({
+          width: 24,
+          height: 32,
+          src: 'https://mapapi.qq.com/web/lbs/javascriptV2/img/marker.png',
+          anchor: { x: 12, y: 32 }
+        })
+      },
+      geometries: [{ id: 'pick', position: new TMap.LatLng(lat, lng) }]
+    })
+  }
+  // 异步逆地理得到地址(不阻塞选点)
+  reverseGeocode(lat, lng).then(addr => {
+    if (addr) pickedAddress.value = addr
+  }).catch(() => {})
+}
+
+async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
+  const key = import.meta.env.VITE_QQMAP_KEY
+  if (!key) return null
+  try {
+    const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${encodeURIComponent(key)}&get_poi=0`
+    const res = await fetch(url)
+    const data = await res.json()
+    if (data?.status === 0 && data?.result?.address) return data.result.address
+  } catch {}
+  return null
+}
+
+async function searchPlace() {
+  if (!keyword.value.trim()) return
+  const kw = keyword.value.trim()
+  try {
+    // 1. 先不传 region 搜(让腾讯自己判断)
+    let data = await fetchSearch(kw)
+    // 2. 没结果 → 用 cluster 第一个城市重试
+    if ((!data || data.length === 0) && lastClusterCity.value) {
+      data = await fetchSearch(kw, lastClusterCity.value)
+    }
+    if (!data || data.length === 0) {
+      ElMessage.warning('未找到该地点,请尝试更详细的关键词(如"城市+地点")')
+      return
+    }
+    applyPoi(data[0])
+  } catch (e: any) {
+    ElMessage.error(e?.message || '搜索失败')
+  }
+}
+
+// 应用一个 POI 到地图(选点 + 平移 + 缩放)
+function applyPoi(poi: any) {
+  if (!map || !poi?.location) return
+  const { lat, lng } = poi.location
+  map.setCenter({ lat, lng })
+  map.setZoom(18)
+  placeMarker(lat, lng)
+  if (poi.address && !pickedAddress.value) pickedAddress.value = poi.address
+  if (poi.title) keyword.value = poi.title
+}
+
+// el-autocomplete 联想回调
+async function fetchSuggestions(queryString: string, cb: (suggestions: any[]) => void) {
+  if (!queryString || queryString.trim().length < 2) {
+    cb([])
+    return
+  }
+  try {
+    const data = await fetchSearch(queryString.trim())
+    // 透传后端原始顺序(腾讯 place API 默认按权重+距离综合排序)
+    cb(data.slice(0, 8))
+  } catch {
+    cb([])
+  }
+}
+
+// 点击联想项
+function onSelectSuggestion(item: any) {
+  applyPoi(item)
+}
+
+const lastClusterCity = ref<string | null>(null)
+
+async function fetchSearch(keyword: string, region?: string): Promise<any[]> {
+  const params = new URLSearchParams({ keyword })
+  if (region) params.set('region', region)
+  const url = `/dev-api/risk/map/search?${params.toString()}`
+  const res = await fetch(url, { credentials: 'include' })
+  const json: any = await res.json()
+  if (json?.status !== 0) {
+    throw new Error(json?.message || '搜索失败')
+  }
+  // 记录第一个 cluster 城市,供下次没结果时用
+  if (json.cluster?.[0]?.title) {
+    lastClusterCity.value = json.cluster[0].title
+  }
+  return json.data || []
+}
+
+function confirm() {
+  if (pickedLat.value == null || pickedLng.value == null) return
+  emit('pick', { lat: pickedLat.value, lng: pickedLng.value, address: pickedAddress.value })
+  visible.value = false
+}
+
+function onClose() {
+  // 不销毁 map 也没事,下次 onOpen 会重建
+  map = null
+  marker = null
+  TMap = null
+}
+</script>
+
+<style scoped lang="scss">
+.map-picker {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.map-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+.map-tip {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
+.map-coord {
+  color: var(--el-color-primary);
+  font-size: 12px;
+  font-weight: 500;
+  margin-left: auto;
+}
+.map-locate-btn {
+  flex-shrink: 0;
+  &:hover {
+    color: var(--el-color-primary);
+    border-color: var(--el-color-primary);
+  }
+}
+.suggestion-item {
+  padding: 4px 0;
+  line-height: 1.3;
+}
+.suggestion-title {
+  font-size: 13px;
+  color: var(--el-text-color-primary);
+}
+.suggestion-addr {
+  font-size: 11px;
+  color: var(--el-text-color-secondary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.map-container {
+  width: 100%;
+  height: 480px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 4px;
+}
+</style>

+ 21 - 14
src/utils/request.ts

@@ -1,6 +1,6 @@
 import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
 import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
 import { useUserStore } from '@/store/modules/user';
 import { useUserStore } from '@/store/modules/user';
-import { getToken } from '@/utils/auth';
+import { getToken, removeToken } from '@/utils/auth';
 import { tansParams, blobValidate } from '@/utils/ruoyi';
 import { tansParams, blobValidate } from '@/utils/ruoyi';
 import cache from '@/plugins/cache';
 import cache from '@/plugins/cache';
 import { HttpStatus } from '@/enums/RespEnum';
 import { HttpStatus } from '@/enums/RespEnum';
@@ -143,25 +143,32 @@ service.interceptors.response.use(
           // ★ 关键:先重置标记 + 同步清本地状态,再跳转
           // ★ 关键:先重置标记 + 同步清本地状态,再跳转
           // (避免 .then 嵌套 await 卡住,导致用户感觉"点了没反应")
           // (避免 .then 嵌套 await 卡住,导致用户感觉"点了没反应")
           isRelogin.show = false;
           isRelogin.show = false;
+          // 同步清 token(不依赖 useUserStore,避免 axios 拦截器作用域 pinia 未就绪抛错)
           try {
           try {
-            // 静默退出:不调 /auth/logout 后端接口,直接清本地状态
-            // (避免 token 过期时 logoutApi 返回 401 再次触发本拦截器递归弹窗)
-            useUserStore().logout(true);
+            removeToken();
           } catch (e) {
           } catch (e) {
-            console.error('[relogin] 清空本地登录态失败:', e);
+            console.error('[relogin] removeToken 失败:', e);
           }
           }
-          // 强制跳转:router.replace 偶发会被中断,加 try/catch + 兜底 location.href
+          // 异步清 store(失败不影响跳转)
           try {
           try {
-            router.replace({
-              path: '/login',
-              query: {
-                redirect: encodeURIComponent(router.currentRoute.value.fullPath || '/')
+            useUserStore().logout(true).catch(() => {});
+          } catch (e) {
+            console.error('[relogin] 清空 store 状态失败:', e);
+          }
+          // 强制跳转:router.push 偶发会被中断,加 try/catch + 兜底 location.href
+          const targetPath = '/login?redirect=' + encodeURIComponent(router.currentRoute.value.fullPath || '/');
+          try {
+            router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath || '/' } });
+            // 兜底:200ms 后如果路径没变,强制 location.href
+            setTimeout(() => {
+              if (window.location.pathname !== '/login') {
+                console.warn('[relogin] router.push 未生效,location.href 兜底');
+                window.location.href = window.location.origin + targetPath;
               }
               }
-            });
+            }, 200);
           } catch (e) {
           } catch (e) {
-            console.error('[relogin] router.replace 失败,改用 location.href 兜底:', e);
-            const redirect = encodeURIComponent(router.currentRoute.value.fullPath || '/');
-            window.location.href = `/login?redirect=${redirect}`;
+            console.error('[relogin] router.push 失败,改用 location.href 兜底:', e);
+            window.location.href = window.location.origin + targetPath;
           }
           }
         }).catch(() => {
         }).catch(() => {
           // 用户点"取消"或弹窗被关闭 — 重置标记,允许下次 401 再次弹窗
           // 用户点"取消"或弹窗被关闭 — 重置标记,允许下次 401 再次弹窗

+ 39 - 6
src/views/risk/hiddenDanger/index.vue

@@ -36,9 +36,12 @@
             @change="handleQuery"
             @change="handleQuery"
           >
           >
             <el-option label="全部类型" value="" />
             <el-option label="全部类型" value="" />
-            <el-option label="门店安全隐患" value="门店安全隐患" />
-            <el-option label="物流安全隐患" value="物流安全隐患" />
-            <el-option label="市场安全隐患" value="市场安全隐患" />
+            <el-option
+              v-for="d in dangerTypeOptions"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
           </el-select>
           </el-select>
         </el-form-item>
         </el-form-item>
         <el-form-item label="处理状态">
         <el-form-item label="处理状态">
@@ -156,9 +159,12 @@
           <el-col :span="12">
           <el-col :span="12">
             <el-form-item label="隐患类型" prop="dangerType">
             <el-form-item label="隐患类型" prop="dangerType">
               <el-select v-model="form.dangerType" placeholder="请选择隐患类型" style="width: 100%">
               <el-select v-model="form.dangerType" placeholder="请选择隐患类型" style="width: 100%">
-                <el-option label="门店安全隐患" value="门店安全隐患" />
-                <el-option label="物流安全隐患" value="物流安全隐患" />
-                <el-option label="市场安全隐患" value="市场安全隐患" />
+                <el-option
+                  v-for="d in dangerTypeOptions"
+                  :key="d.value"
+                  :label="d.label"
+                  :value="d.value"
+                />
               </el-select>
               </el-select>
             </el-form-item>
             </el-form-item>
           </el-col>
           </el-col>
@@ -382,6 +388,7 @@ import {
   getHiddenDangerStats
   getHiddenDangerStats
 } from '@/api/risk/hiddenDanger';
 } from '@/api/risk/hiddenDanger';
 import { uploadFile } from '@/api/risk/file';
 import { uploadFile } from '@/api/risk/file';
+import { getDicts } from '@/api/system/dict/data';
 import type { RiskHiddenDangerBo, RiskHiddenDangerVo } from '@/api/risk/types';
 import type { RiskHiddenDangerBo, RiskHiddenDangerVo } from '@/api/risk/types';
 import { ElMessage, FormInstance } from 'element-plus';
 import { ElMessage, FormInstance } from 'element-plus';
 import { Plus, Warning, Clock, CircleCheck, CircleCloseFilled } from '@element-plus/icons-vue';
 import { Plus, Warning, Clock, CircleCheck, CircleCloseFilled } from '@element-plus/icons-vue';
@@ -730,6 +737,31 @@ const handleResultOptions = ref<Array<{ label: string; value: string }>>([
   { label: '隐患误报', value: '隐患误报' }
   { label: '隐患误报', value: '隐患误报' }
 ]);
 ]);
 
 
+// ============ 字典(隐患类型 risk_danger_type: 门店/物流/市场) ============
+const dangerTypeOptions = ref<Array<{ label: string; value: string }>>([]);
+
+const mapDictData = (resOrList: any) => {
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  return list
+    .filter((d) => d.status === '0' || d.status === undefined)
+    .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
+    .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
+};
+
+const loadDicts = async () => {
+  try {
+    const res = await getDicts('risk_danger_type');
+    dangerTypeOptions.value = mapDictData(res);
+  } catch (e) {
+    console.error('加载隐患类型字典失败', e);
+    dangerTypeOptions.value = [];
+  }
+};
+
 // ============ 照片上传(自管理文件 + 预览) ============
 // ============ 照片上传(自管理文件 + 预览) ============
 const parsePhotoList = (val: any): string[] => {
 const parsePhotoList = (val: any): string[] => {
   if (!val) return [];
   if (!val) return [];
@@ -812,6 +844,7 @@ const applyRouteQuery = () => {
 
 
 onMounted(() => {
 onMounted(() => {
   applyRouteQuery();
   applyRouteQuery();
+  loadDicts();
   getList();
   getList();
 });
 });
 </script>
 </script>

+ 69 - 4
src/views/risk/inspectionTask/index.vue

@@ -242,13 +242,35 @@
         </el-row>
         </el-row>
         <el-row :gutter="20">
         <el-row :gutter="20">
           <el-col :span="12">
           <el-col :span="12">
-            <el-form-item label="纬度" prop="lat">
-              <el-input-number v-model="form.lat" :precision="6" :min="-90" :max="90" controls-position="right" style="width: 100%" />
+            <el-form-item prop="lat">
+              <template #label>
+                <div class="label-with-btn">
+                  <el-tooltip content="在地图上选点自动填经纬度" placement="top">
+                    <el-button
+                      class="map-pick-btn"
+                      type="primary"
+                      :icon="Location"
+                      circle
+                      @click="openMapPicker"
+                    />
+                  </el-tooltip>
+                  <span>纬度</span>
+                </div>
+              </template>
+              <el-input-number
+                v-model="form.lat"
+                :precision="6"
+                :min="-90"
+                :max="90"
+                controls-position="right"
+                style="width: 100%"
+                placeholder="纬度"
+              />
             </el-form-item>
             </el-form-item>
           </el-col>
           </el-col>
           <el-col :span="12">
           <el-col :span="12">
             <el-form-item label="经度" prop="lng">
             <el-form-item label="经度" prop="lng">
-              <el-input-number v-model="form.lng" :precision="6" :min="-180" :max="180" controls-position="right" style="width: 100%" />
+              <el-input-number v-model="form.lng" :precision="6" :min="-180" :max="180" controls-position="right" style="width: 100%" placeholder="经度" />
             </el-form-item>
             </el-form-item>
           </el-col>
           </el-col>
         </el-row>
         </el-row>
@@ -512,6 +534,14 @@
       </template>
       </template>
     </el-dialog>
     </el-dialog>
 
 
+    <!-- 地图选点弹框 -->
+    <MapPickerDialog
+      v-model="mapPickerVisible"
+      :init-lat="form.lat"
+      :init-lng="form.lng"
+      @pick="onMapPick"
+    />
+
   </div>
   </div>
 </template>
 </template>
 
 
@@ -542,7 +572,8 @@ import type { UserVO } from '@/api/system/user/types';
 import { listDept } from '@/api/system/dept';
 import { listDept } from '@/api/system/dept';
 import { FormInstance } from 'element-plus';
 import { FormInstance } from 'element-plus';
 import type { ComponentInternalInstance } from 'vue';
 import type { ComponentInternalInstance } from 'vue';
-import { Calendar, Check, Warning, Clock } from '@element-plus/icons-vue';
+import { Calendar, Check, Warning, Clock, Location } from '@element-plus/icons-vue';
+import MapPickerDialog from '@/components/MapPickerDialog/index.vue';
 
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 
@@ -594,6 +625,20 @@ const stats = ref<InspectionStatsVo>({
   unchecked: 0
   unchecked: 0
 });
 });
 
 
+// 地图选点弹框
+const mapPickerVisible = ref(false);
+function openMapPicker() {
+  mapPickerVisible.value = true;
+}
+function onMapPick(payload: { lat: number; lng: number; address?: string }) {
+  form.value.lat = payload.lat;
+  form.value.lng = payload.lng;
+  // 选完顺便把地址带回位置区域(只有空时才填,不覆盖已有)
+  if (payload.address && !form.value.location) {
+    form.value.location = payload.address;
+  }
+}
+
 // 时间配置
 // 时间配置
 const timeSlotList = ref<string[]>([]);
 const timeSlotList = ref<string[]>([]);
 const weekDayList = ref<string[]>([]);
 const weekDayList = ref<string[]>([]);
@@ -1122,6 +1167,26 @@ const loadStores = async () => {
   overflow-y: auto;
   overflow-y: auto;
   box-sizing: border-box;
   box-sizing: border-box;
 }
 }
+.lat-input-wrap {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+}
+.label-with-btn {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  white-space: nowrap;
+}
+.map-pick-btn {
+  flex-shrink: 0;
+  box-shadow: 0 2px 6px rgba(64, 158, 255, 0.25);
+  &:hover {
+    transform: scale(1.05);
+    transition: transform 0.15s ease;
+  }
+}
 .stat-card {
 .stat-card {
   border-radius: 8px;
   border-radius: 8px;
   :deep(.el-card__body) { padding: 20px; }
   :deep(.el-card__body) { padding: 20px; }

+ 13 - 1
src/views/risk/personnel/index.vue

@@ -721,6 +721,18 @@ const handleUpdate = async (row: RiskPersonnelVo) => {
   if (form.value.idCard) {
   if (form.value.idCard) {
     form.value.age = calculateAge(form.value.idCard);
     form.value.age = calculateAge(form.value.idCard);
   }
   }
+  // 兜底:如果当前 storeId 不在 storeOptions 里(脏数据 / 门店被删/改了分类),
+  //       临时塞一条 {label: storeName, value: storeId} 进 storeOptions,
+  //       保证 el-select 能显示中文名而不是裸 ID
+  if (
+    form.value.storeId != null &&
+    !storeOptions.value.find((s) => s.value === form.value.storeId)
+  ) {
+    storeOptions.value = [
+      ...storeOptions.value,
+      { label: form.value.storeName || `门店#${form.value.storeId}`, value: form.value.storeId }
+    ];
+  }
   try {
   try {
     const certRes = await listPersonnelCertByPersonnel(row.id);
     const certRes = await listPersonnelCertByPersonnel(row.id);
     const certs = certRes.data || [];
     const certs = certRes.data || [];
@@ -875,7 +887,7 @@ const submitForm = () => {
           }
           }
 
 
           return {
           return {
-            id: c.id,
+            // 不传 id:后端走物理删+insert 模式(AUTO_INCREMENT 分配新 id,避免主键冲突)
             certType: c.certType,
             certType: c.certType,
             certName: c.certType,
             certName: c.certType,
             certFile: c.certFile || '',
             certFile: c.certFile || '',

+ 82 - 20
src/views/risk/personnelChange/index.vue

@@ -3,7 +3,7 @@
     <!-- 页面标题 -->
     <!-- 页面标题 -->
     <div class="page-header">
     <div class="page-header">
       <h2 class="page-title">审计工作管理</h2>
       <h2 class="page-title">审计工作管理</h2>
-      <p class="page-desc">管理部门人员调动与离职信息,审计工作量统计、审计重点操作</p>
+      <p class="page-desc">管理部门人员变动、调动信息,审计重点操作</p>
     </div>
     </div>
 
 
     <!-- 统计卡片 -->
     <!-- 统计卡片 -->
@@ -95,10 +95,10 @@
         </div>
         </div>
       </div>
       </div>
 
 
-      <el-table v-loading="loading" :data="changeList" border stripe>
+      <el-table v-loading="loading" :data="changeList" border stripe class="change-table">
         <el-table-column prop="personnelName" label="姓名" align="center" width="100" />
         <el-table-column prop="personnelName" label="姓名" align="center" width="100" />
-        <el-table-column prop="beforeDept" label="部门" align="center" :show-overflow-tooltip="true" width="140" />
-        <el-table-column prop="beforePosition" label="原岗位" align="center" :show-overflow-tooltip="true" width="160" />
+        <el-table-column prop="beforeDept" label="部门" align="center" :show-overflow-tooltip="true" min-width="160" />
+        <el-table-column prop="beforePosition" label="原岗位" align="center" :show-overflow-tooltip="true" min-width="180" />
         <el-table-column label="变动类型" align="center" prop="changeType" width="100">
         <el-table-column label="变动类型" align="center" prop="changeType" width="100">
           <template #default="{ row }">
           <template #default="{ row }">
             <el-tag :type="getChangeTypeTag(row.changeType)" effect="light">
             <el-tag :type="getChangeTypeTag(row.changeType)" effect="light">
@@ -106,7 +106,7 @@
             </el-tag>
             </el-tag>
           </template>
           </template>
         </el-table-column>
         </el-table-column>
-        <el-table-column label="新岗位" align="center" prop="afterPosition" width="160">
+        <el-table-column label="新岗位" align="center" prop="afterPosition" min-width="180">
           <template #default="{ row }">
           <template #default="{ row }">
             {{ row.changeType === '离职' ? '-' : (row.afterPosition || '-') }}
             {{ row.changeType === '离职' ? '-' : (row.afterPosition || '-') }}
           </template>
           </template>
@@ -127,12 +127,17 @@
         </el-table-column>
         </el-table-column>
       </el-table>
       </el-table>
 
 
-      <pagination
+      <el-pagination
         v-show="total > 0"
         v-show="total > 0"
-        v-model:page="queryParams.pageNum"
-        v-model:limit="queryParams.pageSize"
+        v-model:current-page="queryParams.pageNum"
+        v-model:page-size="queryParams.pageSize"
         :total="total"
         :total="total"
-        @pagination="getList"
+        :page-sizes="[10, 20, 50]"
+        layout="prev, pager, next"
+        background
+        class="change-pagination"
+        @current-change="getList"
+        @size-change="getList"
       />
       />
     </el-card>
     </el-card>
 
 
@@ -146,8 +151,8 @@
       </template>
       </template>
       <div v-loading="filesLoading" class="files-grid">
       <div v-loading="filesLoading" class="files-grid">
         <div v-for="f in auditFiles" :key="f.id" class="file-card">
         <div v-for="f in auditFiles" :key="f.id" class="file-card">
-          <div class="file-icon-box">
-            <el-icon class="file-icon" :size="32">
+          <div class="file-icon-box" :style="{ background: getFileIconColor(f.fileName || f.fileUrl) }">
+            <el-icon class="file-icon" :size="20" color="#fff">
               <component :is="getFileIcon(f.fileName || f.fileUrl)" />
               <component :is="getFileIcon(f.fileName || f.fileUrl)" />
             </el-icon>
             </el-icon>
           </div>
           </div>
@@ -610,6 +615,18 @@ const getFileIcon = (name: string) => {
   return Document;
   return Document;
 };
 };
 
 
+/** 按文件类型返回圆形 icon 背景色(与设计稿一致) */
+const getFileIconColor = (name: string): string => {
+  if (!name) return '#909399';
+  const lower = name.toLowerCase();
+  if (lower.endsWith('.pdf')) return '#409EFF';           // 蓝
+  if (lower.match(/\.(xls|xlsx|csv)$/)) return '#67C23A'; // 绿
+  if (lower.match(/\.(doc|docx)$/)) return '#722ED1';     // 紫
+  if (lower.match(/\.(jpg|jpeg|png|gif|bmp|webp)$/)) return '#E6A23C'; // 橙
+  if (lower.match(/\.(zip|rar|7z|tar|gz)$/)) return '#F56C6C';          // 红
+  return '#909399';                                        // 灰
+};
+
 const getFileNameFromUrl = (url: string): string => {
 const getFileNameFromUrl = (url: string): string => {
   if (!url) return '';
   if (!url) return '';
   return url.split('/').pop() || '文件';
   return url.split('/').pop() || '文件';
@@ -1023,6 +1040,47 @@ onMounted(async () => {
 .list-card {
 .list-card {
   margin-bottom: 16px;
   margin-bottom: 16px;
 }
 }
+
+/* 表格按 min-width 自适应,避免右侧大片空白 */
+:deep(.change-table.el-table) {
+  table-layout: fixed;
+  width: 100%;
+}
+:deep(.change-table .el-table__inner-wrapper) {
+  width: 100%;
+}
+
+/* 分页器:简洁风"上一页 [1] 2 3 下一页" */
+.change-pagination {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+.change-pagination :deep(.btn-prev),
+.change-pagination :deep(.btn-next),
+.change-pagination :deep(.number) {
+  background: #fff;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  color: #409eff;
+  min-width: 32px;
+  height: 32px;
+  line-height: 30px;
+  font-weight: 500;
+  margin: 0 4px 0 0;
+}
+.change-pagination :deep(.number):hover {
+  color: #409eff;
+}
+.change-pagination :deep(.number.is-active) {
+  background: #409eff;
+  border-color: #409eff;
+  color: #fff;
+}
+.change-pagination :deep(.btn-prev),
+.change-pagination :deep(.btn-next) {
+  padding: 0 12px;
+}
 .filter-form {
 .filter-form {
   margin-bottom: 4px;
   margin-bottom: 4px;
 }
 }
@@ -1054,17 +1112,18 @@ onMounted(async () => {
 }
 }
 .files-grid {
 .files-grid {
   display: grid;
   display: grid;
-  grid-template-columns: repeat(2, 1fr);
+  grid-template-columns: repeat(3, 1fr);
   gap: 12px;
   gap: 12px;
   min-height: 80px;
   min-height: 80px;
 }
 }
 .file-card {
 .file-card {
   display: flex;
   display: flex;
+  align-items: center;
   gap: 12px;
   gap: 12px;
-  padding: 12px;
+  padding: 12px 14px;
   border: 1px solid #ebeef5;
   border: 1px solid #ebeef5;
   border-radius: 6px;
   border-radius: 6px;
-  background: #fafbfc;
+  background: #fff;
   transition: all 0.2s;
   transition: all 0.2s;
 }
 }
 .file-card:hover {
 .file-card:hover {
@@ -1072,19 +1131,18 @@ onMounted(async () => {
   border-color: #409eff;
   border-color: #409eff;
 }
 }
 .file-icon-box {
 .file-icon-box {
-  width: 44px;
-  height: 44px;
-  border-radius: 6px;
-  background: #e6a23c;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
   flex-shrink: 0;
   flex-shrink: 0;
-  color: #fff;
 }
 }
 .file-meta {
 .file-meta {
   flex: 1;
   flex: 1;
   min-width: 0;
   min-width: 0;
+  overflow: hidden;
 }
 }
 .file-name {
 .file-name {
   font-size: 13px;
   font-size: 13px;
@@ -1093,13 +1151,17 @@ onMounted(async () => {
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
+  margin-bottom: 2px;
 }
 }
 .file-sub {
 .file-sub {
   font-size: 12px;
   font-size: 12px;
   color: #909399;
   color: #909399;
-  margin: 2px 0;
   display: flex;
   display: flex;
   gap: 4px;
   gap: 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 4px;
 }
 }
 .file-actions {
 .file-actions {
   font-size: 12px;
   font-size: 12px;

+ 12 - 15
src/views/risk/workReport/index.vue

@@ -103,7 +103,7 @@
           <template #default="scope">
           <template #default="scope">
             <el-button link type="primary" @click="handleView(scope.row)">查看详情</el-button>
             <el-button link type="primary" @click="handleView(scope.row)">查看详情</el-button>
             <el-button
             <el-button
-              v-if="scope.row.auditStatus === '0' || scope.row.auditStatus === '待审核'"
+              v-if="scope.row.auditStatus === '待审核'"
               v-hasPermi="['risk:workReport:review']"
               v-hasPermi="['risk:workReport:review']"
               link
               link
               type="primary"
               type="primary"
@@ -326,7 +326,7 @@
           </div>
           </div>
         </el-descriptions-item>
         </el-descriptions-item>
         <el-descriptions-item v-if="viewForm.auditOpinion" label="审核意见" :span="2">
         <el-descriptions-item v-if="viewForm.auditOpinion" label="审核意见" :span="2">
-          <span :class="['audit-opinion', viewForm.auditStatus === '2' ? 'reject' : 'pass']">
+          <span :class="['audit-opinion', viewForm.auditStatus === '审核不通过' ? 'reject' : 'pass']">
             {{ viewForm.auditOpinion }}
             {{ viewForm.auditOpinion }}
           </span>
           </span>
         </el-descriptions-item>
         </el-descriptions-item>
@@ -354,11 +354,11 @@
         </el-form-item>
         </el-form-item>
         <el-form-item label="审核结果" prop="auditStatus">
         <el-form-item label="审核结果" prop="auditStatus">
           <el-radio-group v-model="auditForm.auditStatus">
           <el-radio-group v-model="auditForm.auditStatus">
-            <el-radio value="1">通过</el-radio>
-            <el-radio value="2">不通过</el-radio>
+            <el-radio value="审核通过">通过</el-radio>
+            <el-radio value="审核不通过">不通过</el-radio>
           </el-radio-group>
           </el-radio-group>
         </el-form-item>
         </el-form-item>
-        <el-form-item v-if="auditForm.auditStatus === '2'" label="不通过原因" prop="auditOpinion">
+        <el-form-item v-if="auditForm.auditStatus === '审核不通过'" label="不通过原因" prop="auditOpinion">
           <el-input
           <el-input
             v-model="auditForm.auditOpinion"
             v-model="auditForm.auditOpinion"
             type="textarea"
             type="textarea"
@@ -443,7 +443,7 @@ const data = reactive<any>({
     certFile: '',
     certFile: '',
     certName: '',
     certName: '',
     reportTime: '',
     reportTime: '',
-    auditStatus: '0'
+    auditStatus: '待审核'
   } as any,
   } as any,
   queryParams: {
   queryParams: {
     pageNum: 1,
     pageNum: 1,
@@ -452,6 +452,7 @@ const data = reactive<any>({
     workTitle: '',
     workTitle: '',
     auditStatus: ''
     auditStatus: ''
   },
   },
+  auditStatus: '待审核',
   rules: {
   rules: {
     workType: [{ required: true, message: '请选择作业类型', trigger: 'change' }],
     workType: [{ required: true, message: '请选择作业类型', trigger: 'change' }],
     workTitle: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
     workTitle: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
@@ -508,7 +509,7 @@ const auditData = reactive({
     id: undefined as string | number | undefined,
     id: undefined as string | number | undefined,
     workTitle: '',
     workTitle: '',
     reporterName: '',
     reporterName: '',
-    auditStatus: '1',
+    auditStatus: '审核通过',
     auditOpinion: ''
     auditOpinion: ''
   },
   },
   auditRules: {
   auditRules: {
@@ -516,7 +517,7 @@ const auditData = reactive({
     auditOpinion: [
     auditOpinion: [
       {
       {
         validator: (_rule: any, value: any, callback: any) => {
         validator: (_rule: any, value: any, callback: any) => {
-          if (auditData.auditForm.auditStatus === '2' && !value) {
+          if (auditData.auditForm.auditStatus === '审核不通过' && !value) {
             callback(new Error('审核不通过时必须填写原因'));
             callback(new Error('审核不通过时必须填写原因'));
           } else {
           } else {
             callback();
             callback();
@@ -597,7 +598,7 @@ const handleAudit = (row: RiskWorkReportVo) => {
     id: row.id,
     id: row.id,
     workTitle: row.workTitle,
     workTitle: row.workTitle,
     reporterName: row.reporterName,
     reporterName: row.reporterName,
-    auditStatus: '1',
+    auditStatus: '审核通过',
     auditOpinion: ''
     auditOpinion: ''
   };
   };
   auditDialog.visible = true;
   auditDialog.visible = true;
@@ -696,7 +697,7 @@ const reset = () => {
     certFile: '',
     certFile: '',
     certName: '',
     certName: '',
     reportTime: '',
     reportTime: '',
-    auditStatus: '0'
+    auditStatus: '待审核'
   } as any;
   } as any;
   photoFileList.value = [];
   photoFileList.value = [];
   certFileList.value = [];
   certFileList.value = [];
@@ -894,11 +895,7 @@ const getAuditStatusTag = (status: string): any => {
   const map: Record<string, string> = {
   const map: Record<string, string> = {
     待审核: 'warning',
     待审核: 'warning',
     审核通过: 'success',
     审核通过: 'success',
-    审核不通过: 'danger',
-    // 兼容老数据(如果还有 '0'/'1'/'2')
-    '0': 'warning',
-    '1': 'success',
-    '2': 'danger'
+    审核不通过: 'danger'
   };
   };
   return map[status] || 'info';
   return map[status] || 'info';
 };
 };