Bladeren bron

完成新增需求

Huanyi 2 weken geleden
bovenliggende
commit
12277db63e

+ 140 - 195
src/components/AddCustomerDialog/index.vue

@@ -1,108 +1,113 @@
 <template>
   <el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" :title="dialogTitle"
-    width="700px" destroy-on-close append-to-body class="add-customer-dialog">
+    width="760px" destroy-on-close append-to-body class="add-customer-dialog">
     <div class="dialog-body">
       <!-- 头像区 -->
       <div class="avatar-section">
         <el-upload action="#" :show-file-list="false" :auto-upload="false" :on-change="handleUploadFile">
-          <el-avatar :size="88"
+          <el-avatar :size="80"
             :src="avatarDisplayUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'"
             class="avatar-preview" />
           <div class="avatar-tip">点击修改头像</div>
         </el-upload>
       </div>
 
-      <el-form :model="form" label-width="90px" label-position="top" class="customer-form">
+      <el-form :model="form" label-position="top" class="customer-form">
         <!-- 基本资料 -->
-        <div class="section">
-          <div class="section-title">基本资料</div>
-          <el-row :gutter="24">
-            <el-col :span="24">
-              <el-form-item label="所属站点" required>
-                <el-cascader v-model="formAreaValue" :options="areaTreeOptions"
-                  :props="{ value: 'value', label: 'label' }" placeholder="请选择站点" style="width: 100%" clearable
-                  @change="handleFormAreaChange" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="姓名" required><el-input v-model="form.name" placeholder="请输入姓名" /></el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="电话" required><el-input v-model="form.phone" placeholder="请输入电话" /></el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="性别">
-                <el-select v-model="form.gender" placeholder="请选择">
-                  <el-option v-for="dict in sys_user_sex" :key="dict.value" :label="dict.label"
-                    :value="parseInt(dict.value)" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-          </el-row>
+        <div class="section-heading">
+          <span class="section-line"></span>
+          <span class="section-text">基本资料</span>
+          <span class="section-line"></span>
         </div>
+        <el-row :gutter="20">
+          <el-col :span="24">
+            <el-form-item label="所属站点" required>
+              <el-cascader v-model="formAreaValue" :options="areaTreeOptions"
+                :props="{ value: 'value', label: 'label' }" placeholder="请选择站点" style="width: 100%" clearable
+                @change="handleFormAreaChange" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="姓名" required><el-input v-model="form.name" placeholder="请输入" /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="电话" required><el-input v-model="form.phone" placeholder="请输入" /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="性别">
+              <el-select v-model="form.gender" placeholder="请选择">
+                <el-option v-for="dict in sys_user_sex" :key="dict.value" :label="dict.label"
+                  :value="parseInt(dict.value)" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
 
         <!-- 居住信息 -->
-        <div class="section">
-          <div class="section-title">居住信息</div>
-          <el-row :gutter="24">
-            <el-col :span="12">
-              <el-form-item label="所在地区">
-                <RegionCascader v-model="regionCascaderValue" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="12">
-              <el-form-item label="详细住址" required>
-                <el-input v-model="form.address" placeholder="请输入街道/门牌号" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="房屋类型">
-                <el-select v-model="form.houseType" placeholder="请选择">
-                  <el-option v-for="dict in sys_house_type" :key="dict.value" :label="dict.label" :value="dict.value" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="入门方式" required>
-                <el-select v-model="form.entryMethod" placeholder="请选择">
-                  <el-option v-for="dict in sys_entry_method" :key="dict.value" :label="dict.label"
-                    :value="dict.value" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="8" v-if="form.entryMethod === 'password'">
-              <el-form-item label="开门密码" required>
-                <el-input v-model="form.entryPassword" placeholder="请输入密码" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="8" v-if="form.entryMethod === 'key'">
-              <el-form-item label="钥匙位置" required>
-                <el-input v-model="form.keyLocation" placeholder="如:地毯下" />
-              </el-form-item>
-            </el-col>
-          </el-row>
+        <div class="section-heading">
+          <span class="section-line"></span>
+          <span class="section-text">居住信息</span>
+          <span class="section-line"></span>
         </div>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="所在地区">
+              <RegionCascader v-model="regionCascaderValue" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="详细住址" required>
+              <el-input v-model="form.address" placeholder="请输入街道/门牌号" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="房屋类型">
+              <el-select v-model="form.houseType" placeholder="请选择">
+                <el-option v-for="dict in sys_house_type" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="入门方式">
+              <el-select v-model="form.entryMethod" placeholder="请选择">
+                <el-option v-for="dict in sys_entry_method" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8" v-if="form.entryMethod === 'password'">
+            <el-form-item label="开门密码" required>
+              <el-input v-model="form.entryPassword" placeholder="请输入密码" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8" v-if="form.entryMethod === 'key'">
+            <el-form-item label="钥匙位置" required>
+              <el-input v-model="form.keyLocation" placeholder="如:地毯下" />
+            </el-form-item>
+          </el-col>
+        </el-row>
 
         <!-- 其他 -->
-        <div class="section">
-          <div class="section-title">其他</div>
-          <el-row :gutter="24">
-            <el-col :span="12">
-              <el-form-item label="用户标签">
-                <el-select v-model="selectedTagIds" multiple placeholder="选择标签" collapse-tags collapse-tags-tooltip>
-                  <el-option v-for="tag in allUserTags" :key="tag.id" :label="tag.name" :value="tag.id">
-                    <el-tag :type="tag.colorType || 'info'" effect="light" size="small">{{ tag.name }}</el-tag>
-                  </el-option>
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="12">
-              <el-form-item label="备注说明">
-                <el-input type="textarea" v-model="form.remark" :rows="2" placeholder="备注信息" />
-              </el-form-item>
-            </el-col>
-          </el-row>
+        <div class="section-heading">
+          <span class="section-line"></span>
+          <span class="section-text">其他信息</span>
+          <span class="section-line"></span>
         </div>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="用户标签">
+              <el-select v-model="selectedTagIds" multiple placeholder="选择标签" collapse-tags collapse-tags-tooltip>
+                <el-option v-for="tag in allUserTags" :key="tag.id" :label="tag.name" :value="tag.id">
+                  <el-tag :type="tag.colorType || 'info'" effect="light" size="small">{{ tag.name }}</el-tag>
+                </el-option>
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="备注说明">
+              <el-input type="textarea" v-model="form.remark" :rows="2" placeholder="备注信息" />
+            </el-form-item>
+          </el-col>
+        </el-row>
       </el-form>
     </div>
     <template #footer>
@@ -139,7 +144,6 @@ const { proxy } = getCurrentInstance()
 const { sys_user_sex, sys_house_type, sys_entry_method } = toRefs(
   proxy?.useDict('sys_user_sex', 'sys_house_type', 'sys_entry_method')
 )
-
 const userStore = useUserStore()
 const { loadRegionData } = useRegionData()
 
@@ -162,74 +166,41 @@ const form = reactive({
 })
 
 const areaTreeOptions = computed(() => {
-  const buildTree = (data, parentId) => {
-    return data
-      .filter(item => String(item.parentId) === String(parentId))
-      .map(item => {
-        const children = buildTree(data, item.id)
-        const node = { value: item.id, label: item.name }
-        if (children.length > 0) {
-          node.children = children
-        }
-        return node
-      })
-  }
+  const buildTree = (data, parentId) =>
+    data.filter(item => String(item.parentId) === String(parentId)).map(item => {
+      const children = buildTree(data, item.id)
+      const node = { value: item.id, label: item.name }
+      if (children.length > 0) node.children = children
+      return node
+    })
   return buildTree(allNodes.value, 0)
 })
 
 const dialogTitle = computed(() => props.editData ? '编辑用户' : '新增用户')
 
-const loadTags = () => {
-  listAllTag({ category: 'customer', status: 0 }).then((res) => {
-    allUserTags.value = res.data || []
-  }).catch(() => { })
-}
-
-const loadAreaStation = () => {
-  listAreaStation().then((res) => {
-    allNodes.value = res.data || []
-  })
-}
+const loadTags = () => { listAllTag({ category: 'customer', status: 0 }).then(res => { allUserTags.value = res.data || [] }).catch(() => { }) }
+const loadAreaStation = () => { listAreaStation().then(res => { allNodes.value = res.data || [] }) }
 
 const handleFormAreaChange = (value) => {
   if (value && value.length > 0) {
     const lastId = value[value.length - 1]
     const node = allNodes.value.find(n => String(n.id) === String(lastId))
     if (node) {
-      if (String(node.type) === '2') {
-        form.stationId = lastId
-        form.areaId = node.parentId
-      } else {
-        form.areaId = lastId
-        form.stationId = undefined
-      }
+      if (String(node.type) === '2') { form.stationId = lastId; form.areaId = node.parentId }
+      else { form.areaId = lastId; form.stationId = undefined }
     }
-  } else {
-    form.areaId = undefined
-    form.stationId = undefined
-  }
+  } else { form.areaId = undefined; form.stationId = undefined }
 }
 
 const handleUploadFile = async (file) => {
-  const fd = new FormData()
-  fd.append('file', file.raw)
+  const fd = new FormData(); fd.append('file', file.raw)
   try {
     const headers = globalHeaders()
-    const res = await fetch(uploadUrl, {
-      method: 'POST',
-      headers: { 'Authorization': headers.Authorization, 'clientid': headers.clientid },
-      body: fd
-    })
+    const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': headers.Authorization, 'clientid': headers.clientid }, body: fd })
     const result = await res.json()
-    if (result.code === 200) {
-      form.avatar = result.data.ossId
-      avatarDisplayUrl.value = result.data.url
-    } else {
-      ElMessage.error(result.msg || '头像上传失败')
-    }
-  } catch (e) {
-    ElMessage.error('头像上传失败')
-  }
+    if (result.code === 200) { form.avatar = result.data.ossId; avatarDisplayUrl.value = result.data.url }
+    else ElMessage.error(result.msg || '头像上传失败')
+  } catch (e) { ElMessage.error('头像上传失败') }
 }
 
 const saveUser = () => {
@@ -237,30 +208,16 @@ const saveUser = () => {
   if (!form.phone) return ElMessage.warning('请输入电话')
   if (!form.stationId) return ElMessage.warning('请选择所属站点')
   if (!form.address) return ElMessage.warning('请输入详细住址')
-  if (!form.entryMethod) return ElMessage.warning('请选择入门方式')
   if (form.entryMethod === 'password' && !form.entryPassword) return ElMessage.warning('请输入开门密码')
   if (form.entryMethod === 'key' && !form.keyLocation) return ElMessage.warning('请输入钥匙存放位置')
   submitLoading.value = true
   form.tagIds = selectedTagIds.value
-  if (regionCascaderValue.value && regionCascaderValue.value.length > 0) {
-    form.regionCode = regionCascaderValue.value.join('/')
-  } else {
-    form.regionCode = ''
-  }
-  if (props.orderMode) {
-    form.tenantId = props.tenantId
-    form.stationId = props.stationId
-  } else {
-    form.tenantId = userStore.tenantId
-  }
+  form.regionCode = (regionCascaderValue.value && regionCascaderValue.value.length > 0) ? regionCascaderValue.value.join('/') : ''
+  if (props.orderMode) { form.tenantId = props.tenantId; form.stationId = props.stationId }
+  else { form.tenantId = userStore.tenantId }
   const api = form.id ? updateCustomer(form) : addCustomer(form)
-  api.then(() => {
-    ElMessage.success('保存成功')
-    emit('update:visible', false)
-    emit('success')
-  }).finally(() => {
-    submitLoading.value = false
-  })
+  api.then((res) => { ElMessage.success('保存成功'); emit('update:visible', false); emit('success', res.data) })
+    .finally(() => { submitLoading.value = false })
 }
 
 const initForm = () => {
@@ -290,7 +247,6 @@ const loadEditData = (data) => {
   avatarDisplayUrl.value = data.avatarUrl || ''
   regionCascaderValue.value = data.regionCode ? data.regionCode.split('/') : []
   selectedTagIds.value = data.tags ? data.tags.map(t => t.id) : []
-
   const targetId = data.stationId || data.areaId
   if (targetId) {
     const findPath = (nodes, targetId, path = []) => {
@@ -298,56 +254,38 @@ const loadEditData = (data) => {
         const currentPath = [...path, node.id]
         if (String(node.id) === String(targetId)) return currentPath
         const children = allNodes.value.filter(n => String(n.parentId) === String(node.id))
-        if (children.length > 0) {
-          const result = findPath(children, targetId, currentPath)
-          if (result) return result
-        }
+        if (children.length > 0) { const result = findPath(children, targetId, currentPath); if (result) return result }
       }
       return null
     }
-    const roots = allNodes.value.filter(n => String(n.parentId) === '0')
-    formAreaValue.value = findPath(roots, targetId) || []
-  } else {
-    formAreaValue.value = []
-  }
+    formAreaValue.value = findPath(allNodes.value.filter(n => String(n.parentId) === '0'), targetId) || []
+  } else { formAreaValue.value = [] }
 }
 
-watch(() => props.visible, (val) => {
-  if (val) {
-    if (props.editData) {
-      loadEditData(props.editData)
-    } else {
-      initForm()
-    }
-  }
-})
+watch(() => props.visible, (val) => { if (val) { props.editData ? loadEditData(props.editData) : initForm() } })
 
-onMounted(() => {
-  loadTags()
-  loadAreaStation()
-  loadRegionData()
-})
+onMounted(() => { loadTags(); loadAreaStation(); loadRegionData() })
 </script>
 
 <style scoped>
 .add-customer-dialog :deep(.el-dialog__body) {
-  padding: 0 24px 0;
+  padding: 0 36px 0;
   max-height: 62vh;
   overflow-y: auto;
 }
 
 .dialog-body {
-  padding-top: 8px;
+  padding-top: 12px;
 }
 
 .avatar-section {
   display: flex;
   justify-content: center;
-  margin-bottom: 20px;
+  margin-bottom: 24px;
 }
 
 .avatar-preview {
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+  box-shadow: 0 2px 12px rgba(0, 0, 0, .1);
   border: 3px solid #fff;
   outline: 1px solid #e8e8e8;
   cursor: pointer;
@@ -361,30 +299,37 @@ onMounted(() => {
 }
 
 .customer-form {
-  padding-bottom: 10px;
+  padding-bottom: 12px;
+}
+
+.section-heading {
+  display: flex;
+  align-items: center;
+  margin: 32px 0 20px;
+}
+
+.section-heading:first-of-type {
+  margin-top: 0;
 }
 
-.section {
-  margin-bottom: 8px;
-  padding: 16px 20px 4px;
-  background: #fafbfc;
-  border-radius: 8px;
-  border: 1px solid #f0f0f0;
+.section-line {
+  flex: 1;
+  height: 1px;
+  background: #e8e8e8;
 }
 
-.section-title {
+.section-text {
+  padding: 0 20px;
   font-size: 14px;
   font-weight: 600;
   color: #303133;
-  margin-bottom: 14px;
-  padding-left: 10px;
-  border-left: 3px solid #409eff;
+  white-space: nowrap;
 }
 
 .dialog-footer {
   display: flex;
   justify-content: flex-end;
   gap: 12px;
-  padding-top: 8px;
+  padding-top: 12px;
 }
 </style>

+ 303 - 236
src/components/AddPetDialog/index.vue

@@ -1,179 +1,198 @@
 <template>
   <el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)"
-    :title="isEdit ? '编辑宠物' : '新增宠物'" width="780px" destroy-on-close append-to-body class="add-pet-dialog">
+    :title="isEdit ? '编辑宠物' : '新增宠物'" width="880px" destroy-on-close append-to-body class="add-pet-dialog">
     <div class="dialog-body">
       <!-- 头像区 -->
       <div class="avatar-section">
         <el-upload class="avatar-uploader" action="#" :show-file-list="false" :auto-upload="false"
           :on-change="handleUploadFile">
-          <el-avatar v-if="avatarDisplayUrl" :src="avatarDisplayUrl" :size="88" class="avatar-preview" />
+          <el-avatar v-if="avatarDisplayUrl" :src="avatarDisplayUrl" :size="80" class="avatar-preview" />
           <div v-else class="avatar-placeholder">
-            <el-icon :size="32"><Plus /></el-icon>
+            <el-icon :size="28">
+              <Plus />
+            </el-icon>
             <span>上传头像</span>
           </div>
         </el-upload>
       </div>
 
-      <el-form :model="form" label-width="90px" label-position="top" class="pet-form">
+      <el-form :model="form" label-position="top" class="pet-form">
         <!-- 基本信息 -->
-        <div class="section">
-          <div class="section-title">基本信息</div>
-          <el-row :gutter="24">
-            <el-col :span="8">
-              <el-form-item label="宠物姓名" required><el-input v-model="form.name" placeholder="请输入宠物姓名" /></el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="所属主人" required>
-                <el-select v-model="form.userId" placeholder="选择主人" filterable :disabled="lockedOwner">
-                  <el-option v-for="user in userOptions" :key="user.id"
-                    :label="user.name + ' - ' + (user.phone || user.phoneNumber || '')" :value="user.id" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="性别">
-                <el-select v-model="form.gender" placeholder="请选择">
-                  <el-option v-for="dict in sys_pet_gender" :key="dict.value" :label="dict.label"
-                    :value="parseInt(dict.value)" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="品种" required>
-                <el-input v-model="form.breed" placeholder="请输入品种" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="体型" required>
-                <el-select v-model="form.size">
-                  <el-option v-for="dict in sys_pet_size" :key="dict.value" :label="dict.label" :value="dict.value" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="4">
-              <el-form-item label="体重(kg)" required>
-                <el-input-number v-model="form.weight" :min="0" :precision="1" controls-position="right" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="4">
-              <el-form-item label="年龄(岁)" required>
-                <el-input-number v-model="form.age" :min="0" controls-position="right" />
-              </el-form-item>
-            </el-col>
-          </el-row>
-          <el-row :gutter="24">
-            <el-col :span="12">
-              <el-form-item label="性格关键词">
-                <el-input v-model="form.personality" placeholder="如:活泼、粘人" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="12">
-              <el-form-item label="宠物标签">
-                <el-select v-model="form.tagIds" multiple placeholder="选择标签" collapse-tags collapse-tags-tooltip>
-                  <el-option v-for="tag in allPetTags" :key="tag.id" :label="tag.name" :value="tag.id">
-                    <el-tag :type="tag.colorType || 'info'" effect="light" size="small">{{ tag.name }}</el-tag>
-                  </el-option>
-                </el-select>
-              </el-form-item>
-            </el-col>
-          </el-row>
-          <el-form-item label="萌宠性格">
-            <el-input v-model="form.cutePersonality" type="textarea" :rows="2" placeholder="详细描述萌宠的性格特点" />
-          </el-form-item>
+        <div class="section-heading">
+          <span class="section-line"></span>
+          <span class="section-text">基本信息</span>
+          <span class="section-line"></span>
         </div>
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="宠物姓名" required><el-input v-model="form.name" placeholder="请输入" /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="所属主人" required>
+              <el-select v-model="form.userId" placeholder="选择主人" filterable :disabled="lockedOwner">
+                <el-option v-for="user in userOptions" :key="user.id"
+                  :label="user.name + ' - ' + (user.phone || user.phoneNumber || '')" :value="user.id" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="性别">
+              <el-select v-model="form.gender" placeholder="请选择">
+                <el-option v-for="dict in sys_pet_gender" :key="dict.value" :label="dict.label"
+                  :value="parseInt(dict.value)" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="品种" required><el-input v-model="form.breed" placeholder="请输入" /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="体型" required>
+              <el-select v-model="form.size">
+                <el-option v-for="dict in sys_pet_size" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="4">
+            <el-form-item label="体重(kg)" required>
+              <el-input-number v-model="form.weight" :min="0" :precision="1" controls-position="right" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="4">
+            <el-form-item label="年龄" required>
+              <el-input-number v-model="form.age" :min="0" controls-position="right" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="性格关键词">
+              <el-input v-model="form.personality" placeholder="如:活泼、粘人" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="宠物标签">
+              <el-select v-model="form.tagIds" multiple placeholder="选择标签" collapse-tags collapse-tags-tooltip>
+                <el-option v-for="tag in allPetTags" :key="tag.id" :label="tag.name" :value="tag.id">
+                  <el-tag :type="tag.colorType || 'info'" effect="light" size="small">{{ tag.name }}</el-tag>
+                </el-option>
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="萌宠性格">
+              <el-input v-model="form.cutePersonality" type="textarea" :rows="2" placeholder="详细描述萌宠的性格特点" />
+            </el-form-item>
+          </el-col>
+        </el-row>
 
         <!-- 家庭信息 -->
-        <div class="section">
-          <div class="section-title">家庭信息</div>
-          <el-row :gutter="24">
-            <el-col :span="8">
-              <el-form-item label="新来家庭时间">
-                <el-date-picker v-model="form.arrivalTime" type="date" placeholder="选择日期" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="家庭房屋类型" required>
-                <el-select v-model="form.houseType" placeholder="请选择">
-                  <el-option v-for="dict in sys_house_type" :key="dict.value" :label="dict.label" :value="dict.value" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item label="入门方式" required>
-                <el-select v-model="form.entryMethod" placeholder="请选择">
-                  <el-option v-for="dict in sys_entry_method" :key="dict.value" :label="dict.label" :value="dict.value" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="8" v-if="form.entryMethod === 'password'">
-              <el-form-item label="门锁密码" required>
-                <el-input v-model="form.entryPassword" placeholder="请输入门锁密码" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="8" v-if="form.entryMethod === 'key'">
-              <el-form-item label="钥匙位置" required>
-                <el-input v-model="form.keyLocation" placeholder="如:地毯下" />
-              </el-form-item>
-            </el-col>
-          </el-row>
+        <div class="section-heading">
+          <span class="section-line"></span>
+          <span class="section-text">家庭信息</span>
+          <span class="section-line"></span>
         </div>
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="新来家庭时间">
+              <el-date-picker v-model="form.arrivalTime" type="date" placeholder="选择日期" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="家庭房屋类型" required>
+              <el-select v-model="form.houseType" placeholder="请选择">
+                <el-option v-for="dict in sys_house_type" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="入门方式">
+              <el-select v-model="form.entryMethod" placeholder="请选择">
+                <el-option v-for="dict in sys_entry_method" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8" v-if="form.entryMethod === 'password'">
+            <el-form-item label="门锁密码" required>
+              <el-input v-model="form.entryPassword" placeholder="请输入门锁密码" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8" v-if="form.entryMethod === 'key'">
+            <el-form-item label="钥匙位置" required>
+              <el-input v-model="form.keyLocation" placeholder="如:地毯下" />
+            </el-form-item>
+          </el-col>
+        </el-row>
 
         <!-- 健康状况 -->
-        <div class="section">
-          <div class="section-title">健康状况</div>
-          <el-row :gutter="24">
-            <el-col :span="6">
-              <el-form-item label="健康状态" required>
-                <el-select v-model="form.healthStatus" placeholder="请选择">
-                  <el-option value="健康" label="健康" />
-                  <el-option value="亚健康" label="亚健康" />
-                  <el-option value="疾病" label="疾病" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item label="攻击倾向" required>
-                <el-switch v-model="form.aggression" active-text="是" inactive-text="否"
-                  :active-value="1" :inactive-value="0" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item label="疫苗情况" required>
-                <el-select v-model="form.vaccineStatus" placeholder="请选择">
-                  <el-option value="无" label="无" />
-                  <el-option value="已打1次" label="已打1次" />
-                  <el-option value="已打2次" label="已打2次" />
-                  <el-option value="已打3次" label="已打3次" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item label="疫苗凭证">
-                <el-upload class="cert-uploader" action="#" :show-file-list="false" :auto-upload="false"
-                  :on-change="handleUploadVaccineCert">
-                  <img v-if="vaccineCertDisplayUrl" :src="vaccineCertDisplayUrl"
-                    class="cert-preview" />
-                  <div v-else class="cert-placeholder">
-                    <el-icon><Plus /></el-icon>
-                    <span>上传</span>
-                  </div>
-                </el-upload>
-              </el-form-item>
-            </el-col>
-          </el-row>
-          <el-row :gutter="24">
-            <el-col :span="12">
-              <el-form-item label="既往病史" required>
-                <el-input v-model="form.medicalHistory" type="textarea" :rows="2" placeholder="如有病史请记录" />
-              </el-form-item>
-            </el-col>
-            <el-col :span="12">
-              <el-form-item label="过敏史" required>
-                <el-input v-model="form.allergies" type="textarea" :rows="2" placeholder="如有过敏源请记录" />
-              </el-form-item>
-            </el-col>
-          </el-row>
+        <div class="section-heading">
+          <span class="section-line"></span>
+          <span class="section-text">健康状况</span>
+          <span class="section-line"></span>
         </div>
+        <el-row :gutter="20">
+          <el-col :span="showVaccineCert ? 6 : 8">
+            <el-form-item label="健康状态" required>
+              <el-select v-model="form.healthStatus" placeholder="请选择">
+                <el-option value="健康" label="健康" />
+                <el-option value="亚健康" label="亚健康" />
+                <el-option value="疾病" label="疾病" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="showVaccineCert ? 6 : 8">
+            <el-form-item label="攻击倾向" required>
+              <el-switch v-model="form.aggression" active-text="是" inactive-text="否" :active-value="1"
+                :inactive-value="0" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="showVaccineCert ? 6 : 8">
+            <el-form-item label="疫苗情况" required>
+              <el-select v-model="form.vaccineStatus" placeholder="请选择">
+                <el-option value="无" label="无" />
+                <el-option value="已打1次" label="已打1次" />
+                <el-option value="已打2次" label="已打2次" />
+                <el-option value="已打3次" label="已打3次" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col v-if="showVaccineCert" :span="6">
+            <el-form-item label="疫苗凭证">
+              <div class="cert-row">
+                <div class="cert-box" :class="{ 'has-image': vaccineCertDisplayUrl }">
+                  <el-image v-if="vaccineCertDisplayUrl" :src="vaccineCertDisplayUrl" class="cert-thumb"
+                    :preview-src-list="[vaccineCertDisplayUrl]" fit="cover" />
+                  <el-icon v-else :size="18" class="cert-plus-icon">
+                    <Plus />
+                  </el-icon>
+                  <div v-if="!vaccineCertDisplayUrl" class="cert-upload-mask" @click="triggerVaccineCertUpload" />
+                </div>
+                <el-upload ref="vaccineCertUploadRef" style="display:none" action="#" :show-file-list="false"
+                  :auto-upload="false" :on-change="handleUploadVaccineCert" />
+                <div v-if="vaccineCertDisplayUrl" class="cert-actions">
+                  <span class="cert-action-icon cert-action-edit" @click="triggerVaccineCertUpload">
+                    <el-icon :size="14">
+                      <Edit />
+                    </el-icon>
+                  </span>
+                  <span class="cert-action-icon cert-action-del" @click="removeVaccineCert">
+                    <el-icon :size="14">
+                      <Delete />
+                    </el-icon>
+                  </span>
+                </div>
+              </div>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="既往病史" required>
+              <el-input v-model="form.medicalHistory" type="textarea" :rows="2" placeholder="如有病史请记录" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="过敏史" required>
+              <el-input v-model="form.allergies" type="textarea" :rows="2" placeholder="如有过敏源请记录" />
+            </el-form-item>
+          </el-col>
+        </el-row>
       </el-form>
     </div>
     <template #footer>
@@ -186,7 +205,7 @@
 </template>
 
 <script setup>
-import { ref, reactive, watch, onMounted, getCurrentInstance, toRefs } from 'vue'
+import { ref, reactive, watch, onMounted, getCurrentInstance, toRefs, computed } from 'vue'
 import { ElMessage } from 'element-plus'
 import { globalHeaders } from '@/utils/request'
 import { addPet, updatePet } from '@/api/archieves/pet'
@@ -212,7 +231,6 @@ const submitLoading = ref(false)
 const allPetTags = ref([])
 const avatarDisplayUrl = ref('')
 const vaccineCertDisplayUrl = ref('')
-
 const isEdit = ref(false)
 
 const baseUrl = import.meta.env.VITE_APP_BASE_API
@@ -235,7 +253,6 @@ watch(() => props.visible, (val) => {
     submitLoading.value = false
     avatarDisplayUrl.value = ''
     vaccineCertDisplayUrl.value = ''
-
     if (props.petData) {
       isEdit.value = true
       const d = props.petData
@@ -260,9 +277,7 @@ watch(() => props.visible, (val) => {
       isEdit.value = false
       Object.assign(form, defaultForm())
       form.userId = props.userId || undefined
-      if (props.userId) {
-        fetchAndFillOwnerInfo(props.userId)
-      }
+      if (props.userId) fetchAndFillOwnerInfo(props.userId)
     }
   }
 })
@@ -281,49 +296,46 @@ const fetchAndFillOwnerInfo = (userId) => {
       if (!form.entryPassword) form.entryPassword = data.entryPassword || ''
       if (!form.keyLocation) form.keyLocation = data.keyLocation || ''
     }
-  }).catch(() => {})
+  }).catch(() => { })
 }
 
-const loadTags = () => {
-  listAllTag({ category: 'pet', status: 0 }).then((res) => {
-    allPetTags.value = res.data || []
-  })
-}
+const loadTags = () => { listAllTag({ category: 'pet', status: 0 }).then(res => { allPetTags.value = res.data || [] }) }
 
 const handleUploadFile = async (file) => {
-  const fd = new FormData()
-  fd.append('file', file.raw)
+  const fd = new FormData(); fd.append('file', file.raw)
   try {
     const headers = globalHeaders()
     const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': headers.Authorization, 'clientid': headers.clientid }, body: fd })
     const result = await res.json()
-    if (result.code === 200) {
-      form.avatar = result.data.ossId
-      avatarDisplayUrl.value = result.data.url
-    } else {
-      ElMessage.error(result.msg || '头像上传失败')
-    }
-  } catch (e) {
-    ElMessage.error('头像上传失败')
-  }
+    if (result.code === 200) { form.avatar = result.data.ossId; avatarDisplayUrl.value = result.data.url }
+    else ElMessage.error(result.msg || '头像上传失败')
+  } catch (e) { ElMessage.error('头像上传失败') }
 }
 
+const vaccineCertUploadRef = ref(null)
+
+const showVaccineCert = computed(() => form.vaccineStatus && form.vaccineStatus !== '无')
+
 const handleUploadVaccineCert = async (file) => {
-  const fd = new FormData()
-  fd.append('file', file.raw)
+  const fd = new FormData(); fd.append('file', file.raw)
   try {
     const headers = globalHeaders()
     const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': headers.Authorization, 'clientid': headers.clientid }, body: fd })
     const result = await res.json()
-    if (result.code === 200) {
-      form.vaccineCert = result.data.ossId
-      vaccineCertDisplayUrl.value = result.data.url
-    } else {
-      ElMessage.error(result.msg || '疫苗凭证上传失败')
-    }
-  } catch (e) {
-    ElMessage.error('疫苗凭证上传失败')
-  }
+    if (result.code === 200) { form.vaccineCert = result.data.ossId; vaccineCertDisplayUrl.value = result.data.url }
+    else ElMessage.error(result.msg || '疫苗凭证上传失败')
+  } catch (e) { ElMessage.error('疫苗凭证上传失败') }
+}
+
+const triggerVaccineCertUpload = () => {
+  const el = vaccineCertUploadRef.value?.$el || vaccineCertUploadRef.value
+  const input = el?.querySelector?.('input[type="file"]')
+  if (input) input.click()
+}
+
+const removeVaccineCert = () => {
+  form.vaccineCert = undefined
+  vaccineCertDisplayUrl.value = ''
 }
 
 const saveData = () => {
@@ -334,7 +346,6 @@ const saveData = () => {
   if (form.weight === undefined || form.weight === null) return ElMessage.warning('请输入体重(kg)')
   if (form.age === undefined || form.age === null) return ElMessage.warning('请输入年龄(岁)')
   if (!form.houseType) return ElMessage.warning('请选择家庭房屋类型')
-  if (!form.entryMethod) return ElMessage.warning('请选择入门方式')
   if (form.entryMethod === 'password' && !form.entryPassword) return ElMessage.warning('请输入门锁密码')
   if (form.entryMethod === 'key' && !form.keyLocation) return ElMessage.warning('请输入钥匙存放位置')
   if (!form.healthStatus) return ElMessage.warning('请选择健康状态')
@@ -342,7 +353,6 @@ const saveData = () => {
   if (!form.vaccineStatus) return ElMessage.warning('请选择疫苗情况')
   if (!form.medicalHistory) return ElMessage.warning('请输入既往病史')
   if (!form.allergies) return ElMessage.warning('请输入过敏史')
-
   submitLoading.value = true
   const data = { ...form, aggression: Number(form.aggression) || 0 }
   const api = isEdit.value ? updatePet(data) : addPet(data)
@@ -350,31 +360,27 @@ const saveData = () => {
     ElMessage.success(isEdit.value ? '宠物档案更新成功' : '宠物档案保存成功')
     emit('success', res.data || data)
     emit('update:visible', false)
-  }).finally(() => {
-    submitLoading.value = false
-  })
+  }).finally(() => { submitLoading.value = false })
 }
 
-onMounted(() => {
-  loadTags()
-})
+onMounted(() => { loadTags() })
 </script>
 
 <style scoped>
 .add-pet-dialog :deep(.el-dialog__body) {
-  padding: 0 24px 0;
+  padding: 0 36px 0;
   max-height: 62vh;
   overflow-y: auto;
 }
 
 .dialog-body {
-  padding-top: 8px;
+  padding-top: 12px;
 }
 
 .avatar-section {
   display: flex;
   justify-content: center;
-  margin-bottom: 20px;
+  margin-bottom: 24px;
 }
 
 .avatar-uploader {
@@ -382,14 +388,14 @@ onMounted(() => {
 }
 
 .avatar-preview {
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+  box-shadow: 0 2px 12px rgba(0, 0, 0, .1);
   border: 3px solid #fff;
   outline: 1px solid #e8e8e8;
 }
 
 .avatar-placeholder {
-  width: 88px;
-  height: 88px;
+  width: 80px;
+  height: 80px;
   border-radius: 50%;
   border: 2px dashed #dcdfe6;
   display: flex;
@@ -397,9 +403,9 @@ onMounted(() => {
   align-items: center;
   justify-content: center;
   color: #a8abb2;
-  gap: 2px;
   font-size: 12px;
-  transition: all 0.3s;
+  gap: 2px;
+  transition: all .3s;
 }
 
 .avatar-placeholder:hover {
@@ -408,62 +414,123 @@ onMounted(() => {
 }
 
 .pet-form {
-  padding-bottom: 10px;
+  padding-bottom: 12px;
+}
+
+.section-heading {
+  display: flex;
+  align-items: center;
+  margin: 32px 0 20px;
 }
 
-.section {
-  margin-bottom: 8px;
-  padding: 16px 20px 4px;
-  background: #fafbfc;
-  border-radius: 8px;
-  border: 1px solid #f0f0f0;
+.section-heading:first-of-type {
+  margin-top: 0;
 }
 
-.section-title {
+.section-line {
+  flex: 1;
+  height: 1px;
+  background: #e8e8e8;
+}
+
+.section-text {
+  padding: 0 20px;
   font-size: 14px;
   font-weight: 600;
   color: #303133;
-  margin-bottom: 14px;
-  padding-left: 10px;
-  border-left: 3px solid #409eff;
+  white-space: nowrap;
 }
 
-.cert-uploader {
-  cursor: pointer;
+.cert-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
 }
 
-.cert-preview {
-  width: 72px;
-  height: 72px;
-  object-fit: cover;
+.cert-box {
+  width: 32px;
+  height: 32px;
   border-radius: 6px;
-  border: 1px solid #e8e8e8;
+  border: 1.5px dashed #d0d5dd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+  overflow: hidden;
+  transition: border-color .25s, box-shadow .25s;
+  cursor: pointer;
+  flex-shrink: 0;
 }
 
-.cert-placeholder {
-  width: 72px;
-  height: 72px;
-  border-radius: 6px;
-  border: 2px dashed #dcdfe6;
+.cert-box:hover {
+  border-color: #409eff;
+  box-shadow: 0 0 0 3px rgba(64, 158, 255, .08);
+}
+
+.cert-plus-icon {
+  color: #a0a5b0;
+  transition: color .25s;
+}
+
+.cert-box:hover .cert-plus-icon {
+  color: #409eff;
+}
+
+.cert-upload-mask {
+  position: absolute;
+  inset: 0;
+  z-index: 1;
+}
+
+.cert-box.has-image {
+  border-style: solid;
+  border-color: #e4e7ed;
+  cursor: default;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
+}
+
+.cert-box.has-image:hover {
+  border-color: #e4e7ed;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
+}
+
+.cert-thumb {
+  width: 100%;
+  height: 100%;
+}
+
+.cert-actions {
   display: flex;
-  flex-direction: column;
+  gap: 4px;
+}
+
+.cert-action-icon {
+  width: 32px;
+  height: 32px;
+  border-radius: 6px;
+  display: inline-flex;
   align-items: center;
   justify-content: center;
-  color: #a8abb2;
-  font-size: 12px;
-  gap: 2px;
-  transition: all 0.3s;
+  cursor: pointer;
+  transition: all .2s;
+  color: #606266;
+  background: #f5f6f8;
 }
 
-.cert-placeholder:hover {
-  border-color: #409eff;
+.cert-action-icon:hover {
+  background: #ecf5ff;
   color: #409eff;
 }
 
+.cert-action-del:hover {
+  background: #fef0f0;
+  color: #f56c6c;
+}
+
 .dialog-footer {
   display: flex;
   justify-content: flex-end;
   gap: 12px;
-  padding-top: 8px;
+  padding-top: 12px;
 }
 </style>

+ 148 - 0
src/components/ServiceTypeSelector/index.vue

@@ -0,0 +1,148 @@
+<template>
+  <div class="service-type-selector">
+    <el-radio-group :model-value="modelValue" size="default" :fill="fill" @update:model-value="handleGroupChange">
+      <el-radio-button v-for="item in visibleItems" :key="item.id" :label="item.id">{{ item.name }}</el-radio-button>
+    </el-radio-group>
+    <el-popover v-if="hiddenItems.length > 0" placement="bottom" trigger="click" :width="200"
+      popper-class="service-type-popover">
+      <template #reference>
+        <el-button class="more-btn" size="default">
+          <el-icon>
+            <ArrowDown />
+          </el-icon>
+        </el-button>
+      </template>
+      <div class="popover-list">
+        <div v-for="item in allItems" :key="item.id" class="popover-item" :class="{ active: modelValue === item.id }"
+          @click="handlePopoverSelect(item)">
+          <span class="popover-item-dot" v-if="modelValue === item.id">●</span>
+          {{ item.name }}
+        </div>
+      </div>
+    </el-popover>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+interface ServiceOption {
+  id: string | number
+  name: string
+}
+
+interface Props {
+  modelValue: string | number
+  options: ServiceOption[]
+  allLabel?: string
+  allValue?: string | number
+  fill?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  allLabel: '全部类型',
+  allValue: '',
+  fill: undefined
+})
+
+const emit = defineEmits<{
+  'update:modelValue': [value: string | number]
+  change: [value: string | number]
+}>()
+
+const maxVisible = 4
+
+const allItems = computed<ServiceOption[]>(() => {
+  return [
+    { id: props.allValue, name: props.allLabel },
+    ...props.options
+  ]
+})
+
+const visibleItems = computed<ServiceOption[]>(() => {
+  const total = allItems.value
+  if (total.length <= maxVisible) return total
+
+  const result: ServiceOption[] = []
+  const selected = total.find(i => i.id === props.modelValue)
+
+  // "全部" 始终可见
+  result.push(total[0])
+
+  // 当前选中项始终可见
+  if (selected && selected.id !== props.allValue && !result.find(i => i.id === selected.id)) {
+    result.push(selected)
+  }
+
+  // 用列表前部的选项填充剩余位置
+  for (const item of total) {
+    if (result.length >= maxVisible) break
+    if (!result.find(i => i.id === item.id)) {
+      result.push(item)
+    }
+  }
+
+  return result
+})
+
+const hiddenItems = computed<ServiceOption[]>(() => {
+  return allItems.value.filter(i => !visibleItems.value.find(v => v.id === i.id))
+})
+
+const emitChange = (value: string | number) => {
+  emit('update:modelValue', value)
+  emit('change', value)
+}
+
+const handleGroupChange = (value: string | number) => {
+  emitChange(value)
+}
+
+const handlePopoverSelect = (item: ServiceOption) => {
+  emitChange(item.id)
+}
+</script>
+
+<style scoped>
+.service-type-selector {
+  display: inline-flex;
+  align-items: center;
+  gap: 0;
+}
+
+.more-btn {
+  margin-left: -1px;
+  border-radius: 0 4px 4px 0;
+  padding: 0 10px;
+  height: 32px;
+}
+
+.popover-list {
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+.popover-item {
+  padding: 8px 12px;
+  cursor: pointer;
+  font-size: 14px;
+  color: #606266;
+  transition: background-color 0.2s, color 0.2s;
+}
+
+.popover-item:hover {
+  background-color: #ecf5ff;
+  color: #2d8cf0;
+}
+
+.popover-item.active {
+  color: #2d8cf0;
+  font-weight: 600;
+  background-color: #ecf5ff;
+}
+
+.popover-item-dot {
+  margin-right: 6px;
+  font-size: 12px;
+}
+</style>

+ 1 - 1
src/views/index.vue

@@ -201,7 +201,7 @@ const getServices = async () => {
 
 const getServiceName = (id: number) => {
   const s = serviceList.value.find(item => String(item.id) === String(id));
-  return s ? s.name : String(id);
+  return s ? s.name : '未知服务';
 };
 
 // 构造服务标签伪随机色彩

+ 23 - 13
src/views/order/management/components/DispatchDialog.vue

@@ -32,8 +32,10 @@
                     </div>
                     <!-- 新增右侧按钮组 -->
                     <div class="card-right" style="display: flex; align-items: center; gap: 10px; padding-left: 20px;">
-                        <el-button type="primary" size="small" plain round icon="User" @click="openCustomerDetail" :loading="orderInfoLoading">用户档案</el-button>
-                        <el-button type="success" size="small" plain round @click="openPetDetail" :loading="orderInfoLoading" style="margin-left: 0;">宠物档案</el-button>
+                        <el-button type="primary" size="small" plain round icon="User" @click="openCustomerDetail"
+                            :loading="orderInfoLoading">用户档案</el-button>
+                        <el-button type="success" size="small" plain round @click="openPetDetail"
+                            :loading="orderInfoLoading" style="margin-left: 0;">宠物档案</el-button>
                     </div>
                 </div>
             </div>
@@ -55,7 +57,8 @@
                                 <span class="r-name">{{ currentRider.name || '--' }}</span>
                                 <span class="r-phone">{{ currentRider.phone || '--' }}</span>
                                 <dict-tag :options="genderOptions" :value="currentRider.gender" />
-                                <el-tag v-if="currentRider.status" size="small" :type="getStatusType(currentRider.status)" effect="plain">
+                                <el-tag v-if="currentRider.status" size="small"
+                                    :type="getStatusType(currentRider.status)" effect="plain">
                                     {{ getStatusText(currentRider.status) }}
                                 </el-tag>
                             </div>
@@ -63,8 +66,10 @@
 
                         <div class="row-2 categories-row"
                             style="margin-top: 6px; display:flex; gap:4px; flex-wrap:wrap;">
-                            <el-tag v-for="typeId in (currentRider.serviceTypes ? String(currentRider.serviceTypes).split(',') : [])" :key="typeId" size="small"
-                                type="primary" effect="plain">{{ getServiceTypeText(typeId) }}</el-tag>
+                            <el-tag
+                                v-for="typeId in (currentRider.serviceTypes ? String(currentRider.serviceTypes).split(',') : [])"
+                                :key="typeId" size="small" type="primary" effect="plain">{{ getServiceTypeText(typeId)
+                                }}</el-tag>
                         </div>
                         <div class="row-3 time-row" style="margin-top: 4px;">
                             <span class="last-time">下一单: {{ currentRider.nextOrderTime || '-' }}</span>
@@ -92,21 +97,26 @@
                                     <el-avatar :src="rider.avatar" :size="40" />
                                 </div>
                                 <div class="card-main">
-                                    <div class="row-1" style="justify-content: space-between; align-items: flex-start; display: flex;">
+                                    <div class="row-1"
+                                        style="justify-content: space-between; align-items: flex-start; display: flex;">
                                         <div style="display:flex; align-items:baseline; gap:8px;">
                                             <span class="r-name">{{ rider.name || '--' }}</span>
                                             <span class="r-phone">{{ rider.phone || '--' }}</span>
                                             <dict-tag :options="genderOptions" :value="rider.gender" />
                                         </div>
-                                        <el-tag v-if="rider.status" size="small" :type="getStatusType(rider.status)" effect="plain">
+                                        <el-tag v-if="rider.status" size="small" :type="getStatusType(rider.status)"
+                                            effect="plain">
                                             {{ getStatusText(rider.status) }}
                                         </el-tag>
                                     </div>
 
                                     <div class="row-2 categories-row"
                                         style="margin-top: 6px; display:flex; gap:4px; flex-wrap:wrap;">
-                                        <el-tag v-for="typeId in (rider.serviceTypes ? String(rider.serviceTypes).split(',') : [])" :key="typeId" size="small"
-                                            type="primary" effect="plain">{{ getServiceTypeText(typeId) }}</el-tag>
+                                        <el-tag
+                                            v-for="typeId in (rider.serviceTypes ? String(rider.serviceTypes).split(',') : [])"
+                                            :key="typeId" size="small" type="primary" effect="plain">{{
+                                            getServiceTypeText(typeId)
+                                            }}</el-tag>
                                     </div>
                                     <div class="row-3 time-row" style="margin-top: 4px">
                                         <span class="last-time">下一单: {{ rider.nextOrderTime || '-' }}</span>
@@ -223,7 +233,7 @@ const loadServiceOptions = async () => {
 
 const getServiceTypeText = (id) => {
     const s = serviceOptions.value.find(item => String(item.id) === String(id))
-    return s ? s.name : String(id)
+    return s ? s.name : '未知服务'
 }
 
 const loadRiders = async () => {
@@ -280,11 +290,11 @@ watch(() => props.visible, (val) => {
         petId.value = null
         orderInfoLoading.value = true
         getSubOrderInfo(props.order.id).then((res) => {
-            if(res.data) {
+            if (res.data) {
                 // 如果 usrCustomer / usrPet 是对象则取其 id,如果是 ID 直接取
                 customerId.value = res.data.usrCustomer?.id || res.data.usrCustomer
                 petId.value = res.data.usrPet?.id || res.data.usrPet
-                
+
                 // 接到详情后,把真实的金额放进去(后端金额单位为分)
                 const commRaw = res.data.orderCommission ?? res.data.fulfillmentCommission;
                 if (commRaw !== undefined && commRaw !== null) {
@@ -338,7 +348,7 @@ const genderOptions = computed(() => {
 
 const getTagText = (tagId) => {
     const t = tagMap.value?.[tagId]
-    return t?.name || String(tagId)
+    return t?.name || '未知标签'
 }
 
 const getTagType = (tagId) => {

+ 4 - 7
src/views/order/management/index.vue

@@ -5,11 +5,7 @@
         <div class="card-header">
           <span class="title">订单列表</span>
           <div class="right-panel">
-            <el-radio-group v-model="filters.service" size="default" @change="handleSearch">
-              <el-radio-button value="">全部类型</el-radio-button>
-              <el-radio-button v-for="item in serviceOptions" :key="item.id" :value="item.id">{{ item.name
-              }}</el-radio-button>
-            </el-radio-group>
+            <ServiceTypeSelector v-model="filters.service" :options="serviceOptions" @change="handleSearch" />
             <el-input v-model="filters.content" placeholder="订单号/商户/宠主/手机号" class="search-input" prefix-icon="Search"
               clearable @clear="handleSearch" @keyup.enter="handleSearch" />
             <el-cascader v-model="filterCascaderValue" :options="areaTreeOptions"
@@ -214,6 +210,7 @@ import DispatchDialog from './components/DispatchDialog.vue';
 import CareSummaryDrawer from './components/CareSummaryDrawer.vue';
 
 import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue';
+import ServiceTypeSelector from '@/components/ServiceTypeSelector/index.vue';
 import { listAllService } from '@/api/service/list/index';
 import { listSubOrder, dispatchSubOrder, getSubOrderInfo, cancelSubOrder, remarkSubOrder, confirmSubOrder, nursingSummarySubOrder, exportSubOrder } from '@/api/order/subOrder/index';
 import { listAreaStation } from '@/api/system/areaStation';
@@ -260,7 +257,7 @@ const areaTreeOptions = computed(() => {
   };
   return buildTree(areaStationList.value, 0);
 });
-const filterCascaderValue = ref < any[] > ([]);
+const filterCascaderValue = ref<any[]>([]);
 const storeMap = ref({});
 
 let timer = null;
@@ -448,7 +445,7 @@ const getFulfillerStatusText = (status) => {
     busy: '接单中',
     disabled: '禁用'
   };
-  return statusMap[status] || status;
+  return statusMap[status] || '未知状态';
 };
 
 const getFulfillerStatusType = (status) => {

+ 9 - 19
src/views/order/purchase/index.vue

@@ -457,12 +457,7 @@ const openAddUser = () => {
   userDialogVisible.value = true;
 };
 const handleUserSuccess = (newUser) => {
-  // 重新获取列表
-  userQuery.pageNum = 1;
-  fetchUsers();
-
   if (newUser && newUser.id) {
-    // 后端如果直接返回了用户信息或主键,尝试将其加入列表中并选中
     const exists = userOptions.value.find((u) => u.id === newUser.id);
     if (!exists) {
       userOptions.value.unshift(newUser);
@@ -472,7 +467,6 @@ const handleUserSuccess = (newUser) => {
     form.petId = '';
     ElMessage.success('用户添加成功并已自动选中');
   } else {
-    // 未返回具体信息情况,清空当前选中项让用户重选
     form.userId = '';
     currentPets.value = [];
     form.petId = '';
@@ -487,19 +481,15 @@ const openAddPet = () => {
   petDialogVisible.value = true;
 };
 const handlePetSuccess = (newPet) => {
-  if (form.userId) {
-    listPetByUser(form.userId).then((res) => {
-      currentPets.value = res.data || res.rows || [];
-      if (newPet && newPet.id) {
-        form.petId = newPet.id;
-        ElMessage.success('宠物添加成功并已自动选中');
-      } else {
-        form.petId = '';
-        ElMessage.success('宠物添加成功');
-      }
-    });
+  if (newPet && newPet.id) {
+    const exists = currentPets.value.find(p => p.id === newPet.id)
+    if (!exists) {
+      currentPets.value.unshift(newPet)
+    }
+    form.petId = newPet.id
+    ElMessage.success('宠物添加成功并已自动选中')
   } else {
-    ElMessage.success('宠物添加成功');
+    ElMessage.success('宠物添加成功')
   }
 };
 
@@ -530,7 +520,7 @@ const selectedServiceName = computed(() => {
 
 const selectedPkgName = computed(() => {
   const pkgId = activeData.value?.pkgId;
-  return allPackages.find((p) => p.id === pkgId)?.name || '';
+  return allPackages.find((p) => p.id === pkgId)?.name || '未知套餐';
 });
 
 const canSubmit = computed(() => {

+ 41 - 35
src/views/system/account/index.vue

@@ -5,19 +5,24 @@
         <div class="card-header">
           <span style="font-weight: bold;">账号管理</span>
           <div class="header-actions" style="display: flex; gap: 10px;">
-            <el-input v-model="queryParams.userName" placeholder="搜索用户名" style="width: 150px" clearable @keyup.enter="getList" />
-            <el-input v-model="queryParams.phonenumber" placeholder="搜索手机号" style="width: 150px" clearable @keyup.enter="getList" />
+            <el-input v-model="queryParams.userName" placeholder="搜索用户名" style="width: 150px" clearable
+              @keyup.enter="getList" />
+            <el-input v-model="queryParams.phonenumber" placeholder="搜索手机号" style="width: 150px" clearable
+              @keyup.enter="getList" />
             <el-button type="primary" icon="Search" @click="getList">搜索</el-button>
             <el-button icon="Refresh" @click="resetQuery">重置</el-button>
-            <el-button type="warning" icon="Plus" @click="handleAdd" v-hasPermi="['system:user:add']" style="background-color: #e6a23c; border-color: #e6a23c; color: #fff;">新增账号</el-button>
+            <el-button type="warning" icon="Plus" @click="handleAdd" v-hasPermi="['system:user:add']"
+              style="background-color: #e6a23c; border-color: #e6a23c; color: #fff;">新增账号</el-button>
           </div>
         </div>
       </template>
 
-      <el-table :data="tableData" style="width: 100%" v-loading="loading" :header-cell-style="{ color: '#909399', fontWeight: 'normal', backgroundColor: '#fff', borderBottom: '1px solid #ebeef5' }">
+      <el-table :data="tableData" style="width: 100%" v-loading="loading"
+        :header-cell-style="{ color: '#909399', fontWeight: 'normal', backgroundColor: '#fff', borderBottom: '1px solid #ebeef5' }">
         <el-table-column prop="avatar" label="头像" width="80" align="center">
           <template #default="scope">
-            <el-avatar :size="40" :src="scope.row.avatarUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" />
+            <el-avatar :size="40"
+              :src="scope.row.avatarUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" />
           </template>
         </el-table-column>
         <el-table-column prop="userName" label="用户名" width="120" />
@@ -26,13 +31,15 @@
         <el-table-column prop="roles" label="角色" min-width="150">
           <template #default="scope">
             <template v-if="scope.row.roles && scope.row.roles.length > 0">
-              <el-tag v-for="role in scope.row.roles" :key="role.roleId" style="margin-right: 5px; margin-bottom: 5px;" size="small">
-                {{ roleOptions.find(item => item.id === role.roleId)?.name || role.roleName }}
+              <el-tag v-for="role in scope.row.roles" :key="role.roleId" style="margin-right: 5px; margin-bottom: 5px;"
+                size="small">
+                {{roleOptions.find(item => item.id === role.roleId)?.name || role.roleName || '未知角色'}}
               </el-tag>
             </template>
             <template v-else-if="scope.row.roleIds && scope.row.roleIds.length > 0">
-              <el-tag v-for="roleId in scope.row.roleIds" :key="roleId" style="margin-right: 5px; margin-bottom: 5px;" size="small">
-                {{ roleOptions.find(item => item.id == roleId)?.name || '未知角色' }}
+              <el-tag v-for="roleId in scope.row.roleIds" :key="roleId" style="margin-right: 5px; margin-bottom: 5px;"
+                size="small">
+                {{roleOptions.find(item => item.id == roleId)?.name || '未知角色'}}
               </el-tag>
             </template>
             <span v-else style="color: #909399;">-</span>
@@ -41,38 +48,37 @@
         <el-table-column prop="storeIds" label="管理门店" min-width="200">
           <template #default="scope">
             <span v-if="scope.row.storeIds && scope.row.storeIds.length > 0">
-              {{ scope.row.storeIds.map(id => storeOptions.find(item => item.id === id)?.name || '未知门店(' + id + ')').join(',') }}
+              {{scope.row.storeIds.map(id => storeOptions.find(item => item.id === id)?.name || '未知门店').join(',')}}
             </span>
             <span v-else style="color: #909399;">全部门店</span>
           </template>
         </el-table-column>
         <el-table-column prop="status" label="状态" width="100">
           <template #default="scope">
-            <el-switch v-model="scope.row.status" active-value="0" inactive-value="1" @change="handleStatusChange(scope.row)" style="--el-switch-on-color: #f3d19e; --el-switch-off-color: #dcdfe6" />
+            <el-switch v-model="scope.row.status" active-value="0" inactive-value="1"
+              @change="handleStatusChange(scope.row)"
+              style="--el-switch-on-color: #f3d19e; --el-switch-off-color: #dcdfe6" />
           </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template #default="scope">
             <div style="display: flex; gap: 15px;" v-if="scope.row.userId !== 1">
-              <el-button link style="color: #e6a23c; padding: 0;" @click="handleEdit(scope.row)" v-hasPermi="['system:user:edit']">编辑</el-button>
-              <el-button link style="color: #67c23a; padding: 0;" @click="handleResetPwd(scope.row)" v-hasPermi="['system:user:resetPwd']">重置密码</el-button>
-              <el-button link style="color: #f56c6c; padding: 0;" @click="handleDelete(scope.row)" v-hasPermi="['system:user:remove']">删除</el-button>
+              <el-button link style="color: #e6a23c; padding: 0;" @click="handleEdit(scope.row)"
+                v-hasPermi="['system:user:edit']">编辑</el-button>
+              <el-button link style="color: #67c23a; padding: 0;" @click="handleResetPwd(scope.row)"
+                v-hasPermi="['system:user:resetPwd']">重置密码</el-button>
+              <el-button link style="color: #f56c6c; padding: 0;" @click="handleDelete(scope.row)"
+                v-hasPermi="['system:user:remove']">删除</el-button>
             </div>
           </template>
         </el-table-column>
       </el-table>
 
       <div class="pagination-container">
-        <el-pagination
-          v-if="total > 0"
-          v-model:current-page="queryParams.pageNum"
-          v-model:page-size="queryParams.pageSize"
-          :page-sizes="[10, 20, 50, 100]"
-          layout="total, sizes, prev, pager, next, jumper"
-          :total="total"
-          @size-change="handleSizeChange"
-          @current-change="handleCurrentChange"
-        />
+        <el-pagination v-if="total > 0" v-model:current-page="queryParams.pageNum"
+          v-model:page-size="queryParams.pageSize" :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper" :total="total" @size-change="handleSizeChange"
+          @current-change="handleCurrentChange" />
       </div>
     </el-card>
 
@@ -98,7 +104,8 @@
           </el-col>
           <el-col :span="24">
             <el-form-item label="手机号" prop="phonenumber">
-              <el-input v-model="form.phonenumber" placeholder="联系电话 (将作为登录账号)" maxlength="11" @input="form.userName = form.phonenumber" />
+              <el-input v-model="form.phonenumber" placeholder="联系电话 (将作为登录账号)" maxlength="11"
+                @input="form.userName = form.phonenumber" />
             </el-form-item>
           </el-col>
           <el-col :span="24" v-if="!isEdit">
@@ -109,12 +116,7 @@
           <el-col :span="24">
             <el-form-item label="分配角色" prop="roleIds">
               <el-select v-model="form.roleIds" multiple placeholder="请选择角色" style="width: 100%">
-                <el-option
-                  v-for="item in roleOptions"
-                  :key="item.id"
-                  :label="item.name"
-                  :value="item.id"
-                />
+                <el-option v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
               </el-select>
             </el-form-item>
           </el-col>
@@ -269,7 +271,7 @@ const handleEdit = async (row: any) => {
   form.value.roleIds = data.roleIds || [];
   form.value.storeIds = data.storeIds || (data.user && data.user.storeIds) || [];
   form.value.password = '';
-  
+
   // 处理头像回显
   if (data.user.avatar && data.user.avatarUrl) {
     form.value.avatar = [{ ossId: data.user.avatar, url: data.user.avatarUrl }];
@@ -278,7 +280,7 @@ const handleEdit = async (row: any) => {
   } else {
     form.value.avatar = '';
   }
-  
+
   dialogVisible.value = true;
 };
 
@@ -298,7 +300,7 @@ const handleResetPwd = async (row: any) => {
     await ElMessageBox.confirm(`确认重置 ${row.userName} 的密码为 ${initPassword.value} 吗?`, '提示', { type: 'warning' });
     await userApi.resetUserPwd(row.userId, initPassword.value);
     ElMessage.success('重置成功');
-  } catch {}
+  } catch { }
 };
 
 const handleDelete = async (row: any) => {
@@ -307,7 +309,7 @@ const handleDelete = async (row: any) => {
     await userApi.delUser(row.userId);
     ElMessage.success('删除成功');
     getList();
-  } catch {}
+  } catch { }
 };
 
 const saveAccount = () => {
@@ -350,22 +352,26 @@ onMounted(() => {
 .page-container {
   padding: 8px;
 }
+
 .card-header {
   display: flex;
   justify-content: space-between;
   align-items: center;
 }
+
 .pagination-container {
   margin-top: 20px;
   display: flex;
   justify-content: flex-end;
 }
+
 :deep(.circular-upload .el-upload--picture-card),
 :deep(.circular-upload .el-upload-list--picture-card .el-upload-list__item) {
   width: 100px;
   height: 100px;
   border-radius: 50%;
 }
+
 :deep(.circular-upload .el-upload-list__item-actions) {
   border-radius: 50%;
 }

+ 1 - 1
vite.config.ts

@@ -25,7 +25,7 @@ export default defineConfig(({ mode, command }) => {
       proxy: {
         [env.VITE_APP_BASE_API]: {
           target: 'http://127.0.0.1:8080',
-          // target: 'http://www.hoomeng.pet/api',
+          // target: 'https://www.hoomeng.pet/api',
           changeOrigin: true,
           ws: true,
           rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')