Преглед изворни кода

完善需求所需自动回填的字段;提取两个公共组件

Huanyi пре 14 часа
родитељ
комит
7897ee692b

+ 390 - 0
src/components/AddCustomerDialog/index.vue

@@ -0,0 +1,390 @@
+<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">
+    <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"
+            :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">
+        <!-- 基本资料 -->
+        <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>
+
+        <!-- 居住信息 -->
+        <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>
+
+        <!-- 其他 -->
+        <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>
+      </el-form>
+    </div>
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="$emit('update:visible', false)" size="large">取消</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="saveUser" size="large">保存</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, computed, watch, getCurrentInstance, toRefs, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { globalHeaders } from '@/utils/request'
+import { addCustomer, updateCustomer, getCustomer } from '@/api/archieves/customer'
+import { listAllTag } from '@/api/archieves/tag'
+import { listAreaStation } from '@/api/system/areaStation'
+import RegionCascader from '@/components/RegionCascader/index.vue'
+import { useRegionData } from '@/hooks/useRegionData'
+import { useUserStore } from '@/store/modules/user'
+
+const props = defineProps({
+  visible: { type: Boolean, default: false },
+  editData: { type: Object, default: null },
+  orderMode: { type: Boolean, default: false },
+  tenantId: { type: [String, Number], default: undefined },
+  stationId: { type: [String, Number], default: undefined }
+})
+
+const emit = defineEmits(['update:visible', 'success'])
+
+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()
+
+const submitLoading = ref(false)
+const avatarDisplayUrl = ref('')
+const selectedTagIds = ref([])
+const allUserTags = ref([])
+const allNodes = ref([])
+const formAreaValue = ref([])
+const regionCascaderValue = ref([])
+
+const baseUrl = import.meta.env.VITE_APP_BASE_API
+const uploadUrl = baseUrl + '/resource/oss/upload'
+
+const form = reactive({
+  id: undefined, name: '', phone: '', avatar: undefined, gender: undefined,
+  areaId: undefined, stationId: undefined, regionCode: '', address: '',
+  houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
+  tenantId: undefined, remark: '', tagIds: []
+})
+
+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
+      })
+  }
+  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 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
+      }
+    }
+  } else {
+    form.areaId = undefined
+    form.stationId = undefined
+  }
+}
+
+const handleUploadFile = async (file) => {
+  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('头像上传失败')
+  }
+}
+
+const saveUser = () => {
+  if (!form.name) return ElMessage.warning('请输入姓名')
+  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
+  }
+  const api = form.id ? updateCustomer(form) : addCustomer(form)
+  api.then(() => {
+    ElMessage.success('保存成功')
+    emit('update:visible', false)
+    emit('success')
+  }).finally(() => {
+    submitLoading.value = false
+  })
+}
+
+const initForm = () => {
+  selectedTagIds.value = []
+  Object.assign(form, {
+    id: undefined, name: '', phone: '', avatar: undefined, gender: undefined,
+    areaId: undefined, stationId: undefined, regionCode: '', address: '',
+    houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
+    tenantId: props.orderMode ? props.tenantId : userStore.tenantId,
+    remark: '', tagIds: []
+  })
+  avatarDisplayUrl.value = ''
+  formAreaValue.value = []
+  regionCascaderValue.value = []
+}
+
+const loadEditData = (data) => {
+  Object.assign(form, {
+    id: data.id, name: data.name, phone: data.phone, avatar: data.avatar,
+    gender: data.gender, areaId: data.areaId, stationId: data.stationId,
+    regionCode: data.regionCode || '', address: data.address,
+    houseType: data.houseType, entryMethod: data.entryMethod,
+    entryPassword: data.entryPassword, keyLocation: data.keyLocation,
+    tenantId: props.orderMode ? props.tenantId : data.tenantId || userStore.tenantId,
+    remark: data.remark || '', tagIds: []
+  })
+  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 = []) => {
+      for (const node of nodes) {
+        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
+        }
+      }
+      return null
+    }
+    const roots = allNodes.value.filter(n => String(n.parentId) === '0')
+    formAreaValue.value = findPath(roots, targetId) || []
+  } else {
+    formAreaValue.value = []
+  }
+}
+
+watch(() => props.visible, (val) => {
+  if (val) {
+    if (props.editData) {
+      loadEditData(props.editData)
+    } else {
+      initForm()
+    }
+  }
+})
+
+onMounted(() => {
+  loadTags()
+  loadAreaStation()
+  loadRegionData()
+})
+</script>
+
+<style scoped>
+.add-customer-dialog :deep(.el-dialog__body) {
+  padding: 0 24px 0;
+  max-height: 62vh;
+  overflow-y: auto;
+}
+
+.dialog-body {
+  padding-top: 8px;
+}
+
+.avatar-section {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 20px;
+}
+
+.avatar-preview {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+  border: 3px solid #fff;
+  outline: 1px solid #e8e8e8;
+  cursor: pointer;
+}
+
+.avatar-tip {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #409eff;
+  text-align: center;
+}
+
+.customer-form {
+  padding-bottom: 10px;
+}
+
+.section {
+  margin-bottom: 8px;
+  padding: 16px 20px 4px;
+  background: #fafbfc;
+  border-radius: 8px;
+  border: 1px solid #f0f0f0;
+}
+
+.section-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 14px;
+  padding-left: 10px;
+  border-left: 3px solid #409eff;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  padding-top: 8px;
+}
+</style>

+ 469 - 0
src/components/AddPetDialog/index.vue

@@ -0,0 +1,469 @@
+<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">
+    <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" />
+          <div v-else class="avatar-placeholder">
+            <el-icon :size="32"><Plus /></el-icon>
+            <span>上传头像</span>
+          </div>
+        </el-upload>
+      </div>
+
+      <el-form :model="form" label-width="90px" 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>
+
+        <!-- 家庭信息 -->
+        <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>
+
+        <!-- 健康状况 -->
+        <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>
+      </el-form>
+    </div>
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="$emit('update:visible', false)" size="large">取消</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="saveData" size="large">保存</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, watch, onMounted, getCurrentInstance, toRefs } from 'vue'
+import { ElMessage } from 'element-plus'
+import { globalHeaders } from '@/utils/request'
+import { addPet, updatePet } from '@/api/archieves/pet'
+import { getCustomer } from '@/api/archieves/customer'
+import { listAllTag } from '@/api/archieves/tag'
+
+const props = defineProps({
+  visible: { type: Boolean, default: false },
+  userId: { type: [String, Number], default: undefined },
+  userOptions: { type: Array, default: () => [] },
+  lockedOwner: { type: Boolean, default: false },
+  petData: { type: Object, default: null }
+})
+
+const emit = defineEmits(['update:visible', 'success'])
+
+const { proxy } = getCurrentInstance()
+const { sys_pet_gender, sys_pet_size, sys_house_type, sys_entry_method } = toRefs(
+  proxy?.useDict('sys_pet_gender', 'sys_pet_size', 'sys_house_type', 'sys_entry_method')
+)
+
+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
+const uploadUrl = baseUrl + '/resource/oss/upload'
+
+const defaultForm = () => ({
+  id: undefined, userId: undefined, avatar: undefined,
+  name: '', type: 0, gender: undefined, breed: '', birthday: '',
+  age: 1, weight: 5, size: 'small', isSterilized: 0,
+  arrivalTime: '', houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
+  personality: '', cutePersonality: '',
+  healthStatus: '健康', aggression: 0, vaccineStatus: '无', vaccineCert: undefined,
+  medicalHistory: '', allergies: '', remark: '', tagIds: []
+})
+
+const form = reactive(defaultForm())
+
+watch(() => props.visible, (val) => {
+  if (val) {
+    submitLoading.value = false
+    avatarDisplayUrl.value = ''
+    vaccineCertDisplayUrl.value = ''
+
+    if (props.petData) {
+      isEdit.value = true
+      const d = props.petData
+      Object.assign(form, {
+        id: d.id, userId: d.userId, avatar: d.avatar,
+        name: d.name, type: d.type, gender: d.gender,
+        breed: d.breed, birthday: d.birthday || '',
+        age: d.age, weight: d.weight, size: d.size,
+        isSterilized: d.isSterilized, arrivalTime: d.arrivalTime || '',
+        houseType: d.houseType || '', entryMethod: d.entryMethod || '',
+        entryPassword: d.entryPassword || '', keyLocation: d.keyLocation || '',
+        personality: d.personality || '', cutePersonality: d.cutePersonality || '',
+        healthStatus: d.healthStatus || '健康', aggression: d.aggression ?? 0,
+        vaccineStatus: d.vaccineStatus || '无', vaccineCert: d.vaccineCert,
+        medicalHistory: d.medicalHistory || '', allergies: d.allergies || '',
+        remark: d.remark || '',
+        tagIds: d.tags ? d.tags.map(t => t.id) : (d.tagIds || [])
+      })
+      avatarDisplayUrl.value = d.avatarUrl || ''
+      vaccineCertDisplayUrl.value = d.vaccineCertUrl || ''
+    } else {
+      isEdit.value = false
+      Object.assign(form, defaultForm())
+      form.userId = props.userId || undefined
+      if (props.userId) {
+        fetchAndFillOwnerInfo(props.userId)
+      }
+    }
+  }
+})
+
+watch(() => form.userId, (newUserId) => {
+  if (!newUserId || isEdit.value) return
+  fetchAndFillOwnerInfo(newUserId)
+})
+
+const fetchAndFillOwnerInfo = (userId) => {
+  getCustomer(userId).then((res) => {
+    const data = res.data
+    if (data) {
+      if (!form.houseType) form.houseType = data.houseType || ''
+      if (!form.entryMethod) form.entryMethod = data.entryMethod || ''
+      if (!form.entryPassword) form.entryPassword = data.entryPassword || ''
+      if (!form.keyLocation) form.keyLocation = data.keyLocation || ''
+    }
+  }).catch(() => {})
+}
+
+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)
+  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('头像上传失败')
+  }
+}
+
+const handleUploadVaccineCert = async (file) => {
+  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('疫苗凭证上传失败')
+  }
+}
+
+const saveData = () => {
+  if (!form.name) return ElMessage.warning('请输入宠物姓名')
+  if (!form.userId) return ElMessage.warning('请选择所属主人')
+  if (!form.breed) return ElMessage.warning('请输入品种')
+  if (!form.size) return ElMessage.warning('请选择体型')
+  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('请选择健康状态')
+  if (form.aggression === undefined || form.aggression === null) return ElMessage.warning('请选择是否有攻击倾向')
+  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)
+  api.then((res) => {
+    ElMessage.success(isEdit.value ? '宠物档案更新成功' : '宠物档案保存成功')
+    emit('success', res.data || data)
+    emit('update:visible', false)
+  }).finally(() => {
+    submitLoading.value = false
+  })
+}
+
+onMounted(() => {
+  loadTags()
+})
+</script>
+
+<style scoped>
+.add-pet-dialog :deep(.el-dialog__body) {
+  padding: 0 24px 0;
+  max-height: 62vh;
+  overflow-y: auto;
+}
+
+.dialog-body {
+  padding-top: 8px;
+}
+
+.avatar-section {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 20px;
+}
+
+.avatar-uploader {
+  cursor: pointer;
+}
+
+.avatar-preview {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+  border: 3px solid #fff;
+  outline: 1px solid #e8e8e8;
+}
+
+.avatar-placeholder {
+  width: 88px;
+  height: 88px;
+  border-radius: 50%;
+  border: 2px dashed #dcdfe6;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #a8abb2;
+  gap: 2px;
+  font-size: 12px;
+  transition: all 0.3s;
+}
+
+.avatar-placeholder:hover {
+  border-color: #409eff;
+  color: #409eff;
+}
+
+.pet-form {
+  padding-bottom: 10px;
+}
+
+.section {
+  margin-bottom: 8px;
+  padding: 16px 20px 4px;
+  background: #fafbfc;
+  border-radius: 8px;
+  border: 1px solid #f0f0f0;
+}
+
+.section-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 14px;
+  padding-left: 10px;
+  border-left: 3px solid #409eff;
+}
+
+.cert-uploader {
+  cursor: pointer;
+}
+
+.cert-preview {
+  width: 72px;
+  height: 72px;
+  object-fit: cover;
+  border-radius: 6px;
+  border: 1px solid #e8e8e8;
+}
+
+.cert-placeholder {
+  width: 72px;
+  height: 72px;
+  border-radius: 6px;
+  border: 2px dashed #dcdfe6;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #a8abb2;
+  font-size: 12px;
+  gap: 2px;
+  transition: all 0.3s;
+}
+
+.cert-placeholder:hover {
+  border-color: #409eff;
+  color: #409eff;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  padding-top: 8px;
+}
+</style>

+ 8 - 0
src/json/fulfiller.json

@@ -27,6 +27,10 @@
     "violation_punish": {
       "label": "违规惩罚",
       "tagType": "danger"
+    },
+    "level_points": {
+      "label": "等级积分处理",
+      "tagType": "info"
     }
   },
   "BalanceBizType": {
@@ -87,6 +91,10 @@
     "order_finish": {
       "label": "订单完成",
       "tagType": "info"
+    },
+    "level_points": {
+      "label": "等级积分处理",
+      "tagType": "info"
     }
   }
 }

+ 94 - 699
src/views/archieves/customer/index.vue

@@ -7,15 +7,18 @@
           <span class="title">用户管理</span>
           <div class="header-actions">
             <el-cascader v-model="searchRegionValue" :options="areaCascaderOptions"
-                         :props="{ value: 'id', label: 'name' }"
-                         placeholder="所属站点" style="width: 350px; margin-right: 10px" clearable @change="handleSearchRegionChange" />
-            <el-input v-model="searchForm.keyword" placeholder="搜索姓名/手机号" style="width: 200px; margin-right: 10px;" clearable @keyup.enter="handleSearch" @clear="handleSearch" />
-            <el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['archieves:customer:add']">新增用户</el-button>
+              :props="{ value: 'id', label: 'name' }" placeholder="所属站点" style="width: 350px; margin-right: 10px"
+              clearable @change="handleSearchRegionChange" />
+            <el-input v-model="searchForm.keyword" placeholder="搜索姓名/手机号" style="width: 200px; margin-right: 10px;"
+              clearable @keyup.enter="handleSearch" @clear="handleSearch" />
+            <el-button type="primary" icon="Plus" @click="handleAdd"
+              v-hasPermi="['archieves:customer:add']">新增用户</el-button>
           </div>
         </div>
       </template>
 
-      <el-table :data="tableData" v-loading="loading" style="width: 100%" :header-cell-style="{ background: '#f5f7fa' }">
+      <el-table :data="tableData" v-loading="loading" style="width: 100%"
+        :header-cell-style="{ background: '#f5f7fa' }">
         <el-table-column label="用户基本信息" width="250">
           <template #default="scope">
             <div style="display: flex; align-items: center;">
@@ -31,23 +34,16 @@
         </el-table-column>
         <el-table-column label="住址" show-overflow-tooltip min-width="150">
           <template #default="scope">
-            {{ [scope.row.regionCode ? scope.row.regionCode.split('/').map(c => codeToName(c)).filter(Boolean).join(' ') : '', scope.row.address].filter(Boolean).join(' ') || '-' }}
+            {{[scope.row.regionCode ? scope.row.regionCode.split('/').map(c => codeToName(c)).filter(Boolean).join(' ')
+              : '', scope.row.address].filter(Boolean).join(' ') || '-'}}
           </template>
         </el-table-column>
         <el-table-column label="用户标签" width="200">
           <template #default="scope">
-            <el-tag v-for="tag in scope.row.tags" :key="tag.id" :type="tag.colorType || 'info'" effect="light" size="small" style="margin-right: 5px;">{{ tag.name }}</el-tag>
+            <el-tag v-for="tag in scope.row.tags" :key="tag.id" :type="tag.colorType || 'info'" effect="light"
+              size="small" style="margin-right: 5px;">{{ tag.name }}</el-tag>
           </template>
         </el-table-column>
-<!--        <el-table-column label="录入信息" width="200">-->
-<!--          <template #default="scope">-->
-<!--            <div v-if="scope.row.tenantName" style="margin-bottom: 4px;">-->
-<!--              <el-tag size="small" effect="light">{{ scope.row.tenantName }}</el-tag>-->
-<!--            </div>-->
-<!--&lt;!&ndash;            <div><el-tag size="small" effect="plain" :type="scope.row.source && scope.row.source.includes('平台') ? '' : 'warning'">{{ scope.row.source || '-' }}</el-tag></div>&ndash;&gt;-->
-<!--            <div style="font-size: 12px; color: #999; margin-top: 4px;">{{ scope.row.createTime }}</div>-->
-<!--          </template>-->
-<!--        </el-table-column>-->
         <el-table-column label="订单数量" width="120" align="center" sortable prop="orderCount">
           <template #default="scope">
             <div>{{ scope.row.orderCount }}单</div>
@@ -60,30 +56,27 @@
         </el-table-column>
         <el-table-column label="状态" width="100" align="center">
           <template #default="scope">
-            <el-switch
-              v-model="scope.row.status"
-              :active-value="0"
-              :inactive-value="1"
-              inline-prompt
-              active-text="正常"
-              inactive-text="停用"
-              @change="handleStatusChange(scope.row)"
-            />
+            <el-switch v-model="scope.row.status" :active-value="0" :inactive-value="1" inline-prompt active-text="正常"
+              inactive-text="停用" @change="handleStatusChange(scope.row)" />
           </template>
         </el-table-column>
         <el-table-column prop="remark" label="备注" show-overflow-tooltip min-width="150" />
         <el-table-column label="操作" width="200" align="center">
           <template #default="scope">
-            <el-button link type="primary" size="small" @click="handleDetail(scope.row)" v-hasPermi="['archieves:customer:query']">详情</el-button>
-            <el-button link type="primary" size="small" @click="handleEdit(scope.row)" v-hasPermi="['archieves:customer:edit']">编辑</el-button>
-            <el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, scope.row)" style="margin-left: 10px; vertical-align: middle">
+            <el-button link type="primary" size="small" @click="handleDetail(scope.row)"
+              v-hasPermi="['archieves:customer:query']">详情</el-button>
+            <el-button link type="primary" size="small" @click="handleEdit(scope.row)"
+              v-hasPermi="['archieves:customer:edit']">编辑</el-button>
+            <el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, scope.row)"
+              style="margin-left: 10px; vertical-align: middle">
               <el-button link type="primary" size="small">
                 更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
               </el-button>
               <template #dropdown>
                 <el-dropdown-menu>
                   <el-dropdown-item command="remark" v-hasPermi="['archieves:customer:remark']">添加备注</el-dropdown-item>
-                  <el-dropdown-item command="delete" style="color: #F56C6C" v-hasPermi="['archieves:customer:remove']">删除用户</el-dropdown-item>
+                  <el-dropdown-item command="delete" style="color: #F56C6C"
+                    v-hasPermi="['archieves:customer:remove']">删除用户</el-dropdown-item>
                 </el-dropdown-menu>
               </template>
             </el-dropdown>
@@ -91,302 +84,63 @@
         </el-table-column>
       </el-table>
       <div class="pagination-container">
-        <el-pagination
-          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="getList"
-          @current-change="getList"
-        />
+        <el-pagination 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="getList" @current-change="getList" />
       </div>
     </el-card>
 
     <!-- User Detail Drawer -->
-    <CustomerDetailDrawer
-      ref="customerDetailRef"
-      v-model:visible="drawerVisible"
-      :customer-id="currentCustomerId"
-      editable
-      @add-pet="openAddPet"
-      @pet-detail="handlePetDetail"
-      @pet-edit="handlePetEdit"
-      @pet-remark="handlePetRemark"
-      @pet-delete="handlePetDelete"
-    />
+    <CustomerDetailDrawer ref="customerDetailRef" v-model:visible="drawerVisible" :customer-id="currentCustomerId"
+      editable @add-pet="openAddPet" @pet-detail="handlePetDetail" @pet-edit="handlePetEdit"
+      @pet-remark="handlePetRemark" @pet-delete="handlePetDelete" />
 
     <!-- Pet Profile Drawer -->
     <pet-detail-drawer v-model:visible="petDrawerVisible" :pet-id="selectedPetId" editable @remark-saved="getList" />
 
     <!-- Add/Edit User Dialog -->
-    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '新增用户'" width="700px" destroy-on-close>
-      <el-form :model="form" label-width="90px" class="user-form">
-        <el-row :gutter="20">
-          <el-col :span="24" style="text-align: center; margin-bottom: 25px;">
-            <el-upload action="#" :show-file-list="false" :auto-upload="false" :on-change="handleUserUploadFile">
-              <el-avatar :size="80" :src="userAvatarDisplayUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" class="upload-avatar" />
-              <div style="margin-top: 8px; font-size: 12px; color: #409EFF;">点击修改头像</div>
-            </el-upload>
-          </el-col>
-
-          <el-col :span="24"><div class="form-section-header">基本资料</div></el-col>
-<!--          <el-col :span="12">-->
-<!--            <el-form-item label="录入来源">-->
-<!--              <PageSelect v-model="form.tenantId"-->
-<!--                          :options="brandList.map(item => ({ value: item.id, label: item.name }))"-->
-<!--                          :total="brandTotal" :pageSize="10" placeholder="请选择所属品牌"-->
-<!--                          @page-change="handleBrandPageChange"-->
-<!--                          @visible-change="handleBrandVisibleChange" />-->
-<!--            </el-form-item>-->
-<!--          </el-col>-->
-          <el-col :span="24">
-            <el-form-item label="所属站点" required>
-              <el-cascader v-model="formAreaValue" :options="areaTreeOptions" placeholder="请选择站点"
-                           :props="{ value: 'value', label: 'label' }"
-                           style="width: 100%" clearable @change="handleFormAreaChange" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="姓名" required><el-input v-model="form.name" placeholder="请输入姓名" /></el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="电话" required><el-input v-model="form.phone" placeholder="请输入电话" /></el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <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-col :span="24"><div class="form-section-header">居住信息</div></el-col>
-          <el-col :span="24">
-            <el-form-item label="所在地区">
-              <RegionCascader v-model="regionCascaderValue" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="详细住址" required><el-input v-model="form.address" placeholder="请输入街道/门牌号" /></el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="房屋类型">
-              <el-radio-group v-model="form.houseType">
-                <el-radio v-for="dict in sys_house_type" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
-              </el-radio-group>
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="入门方式" required>
-              <el-radio-group v-model="form.entryMethod">
-                <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
-              </el-radio-group>
-            </el-form-item>
-          </el-col>
-          <el-col :span="12" 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="12" v-if="form.entryMethod === 'key'">
-            <el-form-item label="钥匙位置" required>
-              <el-input v-model="form.keyLocation" placeholder="如:地毯下" />
-            </el-form-item>
-          </el-col>
-
-          <el-col :span="24"><div class="form-section-header">其他</div></el-col>
-          <el-col :span="24">
-            <el-form-item label="用户标签">
-              <el-select v-model="selectedTagIds" multiple placeholder="选择标签" style="width: 100%">
-                <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="24">
-            <el-form-item label="备注说明"><el-input type="textarea" v-model="form.remark" rows="3" /></el-form-item>
-          </el-col>
-        </el-row>
-      </el-form>
-      <div style="text-align: center; margin-top: 20px;">
-        <el-button @click="dialogVisible = false" size="large" style="width: 120px;">取消</el-button>
-        <el-button type="primary" :loading="submitLoading" @click="saveUser" size="large" style="width: 120px;">保存</el-button>
-      </div>
-    </el-dialog>
+    <AddCustomerDialog v-model:visible="dialogVisible" :edit-data="customerEditData" @success="onCustomerSaved" />
 
     <!-- Remark Dialog -->
     <el-dialog v-model="remarkDialogVisible" title="添加备注" width="400px">
       <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入备注内容..." />
       <template #footer>
-            <span class="dialog-footer">
-                <el-button @click="remarkDialogVisible = false">取消</el-button>
-                <el-button type="primary" @click="saveRemark">保存</el-button>
-            </span>
+        <span class="dialog-footer">
+          <el-button @click="remarkDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="saveRemark">保存</el-button>
+        </span>
       </template>
     </el-dialog>
 
-    <!-- Full Add/Edit Pet Dialog -->
-    <el-dialog v-model="petDialogVisible" :title="petForm.id ? '编辑宠物' : '新增宠物'" width="800px">
-      <el-tabs v-model="petDialogActiveTab">
-        <el-tab-pane label="基本信息" name="basic">
-          <el-form :model="petForm" label-width="100px">
-            <el-row>
-              <el-col :span="24" style="display: flex; justify-content: center; margin-bottom: 20px;">
-                <el-upload
-                  class="avatar-uploader"
-                  action="#"
-                  :show-file-list="false"
-                  :auto-upload="false"
-                  :on-change="handlePetUploadFile"
-                >
-                  <el-avatar v-if="petAvatarDisplayUrl" :src="petAvatarDisplayUrl" :size="80" />
-                  <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
-                </el-upload>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="宠物姓名" required><el-input v-model="petForm.name" /></el-form-item>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="性别">
-                  <el-select v-model="petForm.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="12">
-                <el-form-item label="品种" required>
-                  <el-select v-model="petForm.breed" placeholder="请选择品种" style="width: 100%" filterable allow-create default-first-option>
-                    <el-option v-for="dict in sys_pet_breed" :key="dict.value" :label="dict.label" :value="dict.value" />
-                  </el-select>
-                </el-form-item>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="体型" required>
-                  <el-select v-model="petForm.size" style="width: 100%">
-                    <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="12">
-                <el-form-item label="体重(kg)" required><el-input-number v-model="petForm.weight" :min="0" :precision="1" style="width: 100%" /></el-form-item>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="年龄(岁)" required><el-input-number v-model="petForm.age" :min="0" style="width: 100%" /></el-form-item>
-              </el-col>
-              <el-col :span="24">
-                <el-form-item label="性格关键词"><el-input v-model="petForm.personality" placeholder="如:活泼、粘人" /></el-form-item>
-              </el-col>
-              <el-col :span="24">
-                <el-form-item label="萌宠性格"><el-input v-model="petForm.cutePersonality" type="textarea" placeholder="详细描述" /></el-form-item>
-              </el-col>
-              <el-col :span="24">
-                <el-form-item label="宠物标签">
-                  <el-select v-model="petForm.tagIds" multiple placeholder="选择标签" style="width: 100%">
-                    <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>
-        </el-tab-pane>
-        <el-tab-pane label="家庭信息" name="family">
-          <el-form :model="petForm" label-width="120px">
-            <el-form-item label="新来家庭时间">
-              <el-date-picker v-model="petForm.arrivalTime" type="date" placeholder="选择日期" style="width: 100%" />
-            </el-form-item>
-            <el-form-item label="家庭房屋类型" required>
-              <el-radio-group v-model="petForm.houseType">
-                <el-radio v-for="dict in sys_house_type" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="入门方式" required>
-              <el-radio-group v-model="petForm.entryMethod">
-                <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="密码" v-if="petForm.entryMethod === 'password'" required>
-              <el-input v-model="petForm.entryPassword" placeholder="请输入门锁密码" />
-            </el-form-item>
-            <el-form-item label="钥匙位置" v-if="petForm.entryMethod === 'key'" required>
-              <el-input v-model="petForm.keyLocation" placeholder="请输入钥匙存放位置" />
-            </el-form-item>
-          </el-form>
-        </el-tab-pane>
-        <el-tab-pane label="健康状况" name="health">
-          <el-form :model="petForm" label-width="120px">
-            <el-form-item label="健康状态" required>
-              <el-radio-group v-model="petForm.healthStatus">
-                <el-radio label="健康">健康</el-radio>
-                <el-radio label="亚健康">亚健康</el-radio>
-                <el-radio label="疾病">疾病</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="是否有攻击倾向" required>
-              <el-switch v-model="petForm.aggression" active-text="是" inactive-text="否" :active-value="1" :inactive-value="0" />
-            </el-form-item>
-            <el-form-item label="疫苗情况" required>
-              <el-radio-group v-model="petForm.vaccineStatus">
-                <el-radio label="无">无</el-radio>
-                <el-radio label="已打1次">已打1次</el-radio>
-                <el-radio label="已打2次">已打2次</el-radio>
-                <el-radio label="已打3次">已打3次</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="疫苗凭证">
-              <el-upload class="avatar-uploader" action="#" :show-file-list="false" :auto-upload="false" :on-change="handlePetUploadVaccineCert">
-                <img v-if="petVaccineCertDisplayUrl" :src="petVaccineCertDisplayUrl" class="avatar" style="width: 100px; height: 100px; object-fit: cover;" />
-                <el-icon v-else class="avatar-uploader-icon" style="width: 100px; height: 100px; line-height: 100px;"><Plus /></el-icon>
-              </el-upload>
-            </el-form-item>
-            <el-form-item label="既往病史" required>
-              <el-input v-model="petForm.medicalHistory" type="textarea" placeholder="如有病史请记录" />
-            </el-form-item>
-            <el-form-item label="过敏史" required>
-              <el-input v-model="petForm.allergies" type="textarea" placeholder="如有过敏源请记录" />
-            </el-form-item>
-          </el-form>
-        </el-tab-pane>
-      </el-tabs>
-      <template #footer>
-            <span class="dialog-footer">
-                <el-button @click="petDialogVisible = false">取消</el-button>
-                <el-button type="primary" :loading="submitLoading" @click="savePet">保存</el-button>
-            </span>
-      </template>
-    </el-dialog>
+    <AddPetDialog v-model:visible="petDialogVisible" :user-id="currentUser.id"
+      :user-options="currentUser.id ? [currentUser] : []" :pet-data="petEditData" :locked-owner="true"
+      @success="onPetSaved" />
   </div>
 </template>
 
 <script setup>
 import { ref, reactive, computed, onMounted, getCurrentInstance, toRefs } from 'vue'
-import { globalHeaders } from '@/utils/request'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { listCustomer, getCustomer, addCustomer, updateCustomer, delCustomer, changeCustomerStatus, updateCustomerRemark } from '@/api/archieves/customer'
+import { listCustomer, getCustomer, delCustomer, changeCustomerStatus, updateCustomerRemark } from '@/api/archieves/customer'
 import { listAllTag } from '@/api/archieves/tag'
-import { listPetByUser, addPet, updatePet, delPet, updatePetRemark } from '@/api/archieves/pet'
+import { listPetByUser, delPet, updatePetRemark } from '@/api/archieves/pet'
 import { listAllChangeLog } from '@/api/archieves/changeLog'
 import { listAreaStation } from '@/api/system/areaStation'
-import { listOnStore as listBrandOnStore } from '@/api/system/tenant'
-import RegionCascader from '@/components/RegionCascader/index.vue'
 import { useRegionData } from '@/hooks/useRegionData'
-import PageSelect from '@/components/PageSelect/index.vue'
 import CustomerDetailDrawer from '@/components/CustomerDetailDrawer/index.vue'
 import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
+import AddCustomerDialog from '@/components/AddCustomerDialog/index.vue'
+import AddPetDialog from '@/components/AddPetDialog/index.vue'
 import { useUserStore } from '@/store/modules/user'
 
 const userStore = useUserStore()
 const { codeToName, loadRegionData } = useRegionData()
 const { proxy } = getCurrentInstance()
-const { sys_user_sex, sys_customer_status, sys_house_type, sys_entry_method, sys_pet_gender, sys_pet_size, sys_pet_type, sys_pet_breed } = toRefs(
-  proxy?.useDict('sys_user_sex', 'sys_customer_status', 'sys_house_type', 'sys_entry_method', 'sys_pet_gender', 'sys_pet_size', 'sys_pet_type', 'sys_pet_breed')
+const { sys_user_sex, sys_customer_status, sys_house_type, sys_entry_method, sys_pet_gender, sys_pet_size, sys_pet_type } = toRefs(
+  proxy?.useDict('sys_user_sex', 'sys_customer_status', 'sys_house_type', 'sys_entry_method', 'sys_pet_gender', 'sys_pet_size', 'sys_pet_type')
 )
 
 const loading = ref(false)
-const submitLoading = ref(false)
 const total = ref(0)
 
 const allNodes = ref([])
@@ -443,8 +197,6 @@ const queryParams = reactive({
   status: undefined
 })
 
-
-
 const searchForm = reactive({
   keyword: '',
   areaId: undefined,
@@ -455,146 +207,21 @@ const dialogVisible = ref(false)
 const drawerVisible = ref(false)
 const remarkDialogVisible = ref(false)
 const petDialogVisible = ref(false)
-const isEdit = ref(false)
-const detailActiveTab = ref('info')
-const petDialogActiveTab = ref('basic')
 
-const selectedTagIds = ref([])
 const currentUser = ref({})
 const tableData = ref([])
 
 const allUserTags = ref([])
-const allPetTags = ref([])
 const currentCustomerId = ref(null)
 const customerDetailRef = ref(null)
-const userAvatarDisplayUrl = ref('')
-const petAvatarDisplayUrl = ref('')
-const petVaccineCertDisplayUrl = ref('')
 const petDrawerVisible = ref(false)
 const selectedPetId = ref(null)
 
-const brandList = ref([])
-const brandTotal = ref(0)
-const formAreaValue = ref([])
-const regionCascaderValue = ref([])
-
-
-// 移除 mockOrders
-
-const form = reactive({
-  id: undefined,
-  name: '',
-  phone: '',
-  avatar: undefined,
-  gender: undefined,
-  birthday: '',
-  idCard: '',
-  areaId: undefined,
-  stationId: undefined,
-  regionCode: '',
-  region: [],
-  address: '',
-  houseType: '',
-  entryMethod: '',
-  entryPassword: '',
-  keyLocation: '',
-  tenantId: undefined,
-  emergencyContact: '',
-  emergencyPhone: '',
-  memberLevel: 0,
-  status: 0,
-  remark: '',
-  tagIds: []
-})
-
-const petForm = reactive({
-  id: undefined,
-  userId: undefined,
-  avatar: undefined,
-  name: '',
-  type: 0,
-  gender: undefined,
-  breed: '',
-  birthday: '',
-  age: 1,
-  weight: 5,
-  size: 'small',
-  isSterilized: 0,
-  arrivalTime: '',
-  houseType: '',
-  entryMethod: '',
-  entryPassword: '',
-  keyLocation: '',
-  personality: '',
-  cutePersonality: '',
-  healthStatus: '',
-  aggression: 0,
-  vaccineStatus: '',
-  vaccineCert: undefined,
-  medicalHistory: '',
-  allergies: '',
-  remark: '',
-  tagIds: []
-})
+const customerEditData = ref(null)
+const petEditData = ref(null)
 
 const remarkForm = reactive({ content: '' })
 
-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
-        } else if (String(item.type) !== '2') {
-          node.disabled = true
-        }
-        return node
-      })
-  }
-  const areaData = allNodes.value
-  return buildTree(areaData, 0)
-})
-
-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
-      }
-    }
-  } else {
-    form.areaId = undefined
-    form.stationId = undefined
-  }
-}
-
-const getBrandList = async (pageNum = 1) => {
-  const res = await listBrandOnStore({ pageNum, pageSize: 10 })
-  if (res.code === 200) {
-    brandList.value = res.rows || []
-    brandTotal.value = res.total || 0
-  }
-}
-
-const handleBrandPageChange = (page) => {
-  getBrandList(Number(page))
-}
-
-const handleBrandVisibleChange = (visible) => {
-  if (visible) {
-    getBrandList(1)
-  }
-}
-
 const getList = () => {
   loading.value = true
   queryParams.keyword = searchForm.keyword
@@ -616,79 +243,30 @@ const handleSearch = () => {
 const loadTags = () => {
   listAllTag({ category: 'customer', status: 0 }).then((res) => {
     allUserTags.value = res.data || []
-  }).catch((err) => {
-    console.error('加载用户标签失败', err)
-  })
-  listAllTag({ category: 'pet', status: 0 }).then((res) => {
-    allPetTags.value = res.data || []
-  }).catch((err) => {
-    console.error('加载宠物标签失败', err)
-  })
+  }).catch(() => { })
 }
 
 const handleAdd = () => {
-  isEdit.value = false
-  selectedTagIds.value = []
-  Object.assign(form, {
-    id: undefined, name: '', phone: '', avatar: undefined, gender: undefined, birthday: '', idCard: '',
-    areaId: undefined, stationId: undefined, regionCode: '', region: [], address: '',
-    houseType: '', entryMethod: '', entryPassword: '', keyLocation: '', tenantId: userStore.tenantId,
-    emergencyContact: '', emergencyPhone: '', memberLevel: 0, status: 0, remark: '', tagIds: []
-  })
-  userAvatarDisplayUrl.value = ''
-  formAreaValue.value = []
-  regionCascaderValue.value = []
+  customerEditData.value = null
   dialogVisible.value = true
 }
 
 const handleEdit = (row) => {
-  isEdit.value = true
-  getCustomer(row.id).then((res) => {
-    const data = res.data
-    Object.assign(form, {
-      id: data.id, name: data.name, phone: data.phone, avatar: data.avatar, gender: data.gender,
-      birthday: data.birthday, idCard: data.idCard, areaId: data.areaId, stationId: data.stationId,
-      regionCode: data.regionCode, region: data.regionCode ? data.regionCode.split('/') : [],
-      address: data.address, houseType: data.houseType, entryMethod: data.entryMethod,
-      entryPassword: data.entryPassword, keyLocation: data.keyLocation, tenantId: data.tenantId,
-      emergencyContact: data.emergencyContact, emergencyPhone: data.emergencyPhone,
-      memberLevel: data.memberLevel, status: data.status, remark: data.remark, tagIds: []
-    })
-    userAvatarDisplayUrl.value = data.avatarUrl || ''
-    // Restore area cascader value path
-    const targetId = data.stationId || data.areaId
-    if (targetId) {
-      const findPath = (nodes, targetId, path = []) => {
-        for (const node of nodes) {
-          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
-          }
-        }
-        return null
-      }
-      const roots = allNodes.value.filter(n => String(n.parentId) === '0')
-      formAreaValue.value = findPath(roots, targetId) || []
-    } else {
-      formAreaValue.value = []
-    }
-    // Restore region cascader value
-    regionCascaderValue.value = data.regionCode ? data.regionCode.split('/') : []
-    selectedTagIds.value = data.tags ? data.tags.map(t => t.id) : []
-    dialogVisible.value = true
-  })
+  customerEditData.value = row
+  dialogVisible.value = true
+}
+
+const onCustomerSaved = () => {
+  dialogVisible.value = false
+  getList()
 }
 
 const handleDetail = (row) => {
+  currentUser.value = row
   currentCustomerId.value = row.id
   drawerVisible.value = true
 }
 
-// 移除不需要的方法
-
 const remarkTargetType = ref('customer')
 
 const handleRemark = (row) => {
@@ -727,31 +305,6 @@ const saveRemark = () => {
   }
 }
 
-const saveUser = () => {
-  if (!form.stationId) return ElMessage.warning('所属站点只能选择具体的站点')
-  if (!form.name) return ElMessage.warning('请输入姓名')
-  if (!form.phone) 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 = ''
-  }
-  const api = isEdit.value ? updateCustomer(form) : addCustomer(form)
-  api.then(() => {
-    ElMessage.success('保存成功')
-    dialogVisible.value = false
-    getList()
-  }).finally(() => {
-    submitLoading.value = false
-  })
-}
-
 const handleDelete = (row) => {
   ElMessageBox.confirm('确认删除该用户档案吗?', '提示', { type: 'warning' }).then(() => {
     delCustomer(row.id).then(() => {
@@ -780,118 +333,18 @@ const handleCommand = (command, row) => {
   }
 }
 
-const baseUrl = import.meta.env.VITE_APP_BASE_API
-const uploadUrl = baseUrl + '/resource/oss/upload'
-
-const handleUserUploadFile = async (file) => {
-  const formData = new FormData()
-  formData.append('file', file.raw)
-  try {
-    const headers = globalHeaders()
-    const res = await fetch(uploadUrl, {
-      method: 'POST',
-      headers: {
-        'Authorization': headers.Authorization,
-        'clientid': headers.clientid
-      },
-      body: formData
-    })
-    const result = await res.json()
-    if (result.code === 200) {
-      form.avatar = result.data.ossId
-      userAvatarDisplayUrl.value = result.data.url
-    } else {
-      ElMessage.error(result.msg || '头像上传失败')
-    }
-  } catch (e) {
-    ElMessage.error('头像上传失败')
-  }
-}
-
-const handlePetUploadFile = async (file) => {
-  const formData = new FormData()
-  formData.append('file', file.raw)
-  try {
-    const headers = globalHeaders()
-    const res = await fetch(uploadUrl, {
-      method: 'POST',
-      headers: {
-        'Authorization': headers.Authorization,
-        'clientid': headers.clientid
-      },
-      body: formData
-    })
-    const result = await res.json()
-    if (result.code === 200) {
-      petForm.avatar = result.data.ossId
-      petAvatarDisplayUrl.value = result.data.url
-    } else {
-      ElMessage.error(result.msg || '头像上传失败')
-    }
-  } catch (e) {
-    ElMessage.error('头像上传失败')
-  }
-}
-const handlePetUploadVaccineCert = async (file) => {
-  const formData = new FormData()
-  formData.append('file', file.raw)
-  try {
-    const headers = globalHeaders()
-    const res = await fetch(uploadUrl, {
-      method: 'POST',
-      headers: {
-        'Authorization': headers.Authorization,
-        'clientid': headers.clientid
-      },
-      body: formData
-    })
-    const result = await res.json()
-    if (result.code === 200) {
-      petForm.vaccineCert = result.data.ossId
-      petVaccineCertDisplayUrl.value = result.data.url
-    } else {
-      ElMessage.error(result.msg || '疫苗凭证上传失败')
-    }
-  } catch (e) {
-    ElMessage.error('疫苗凭证上传失败')
-  }
-}
-
 const openAddPet = () => {
-  petDialogActiveTab.value = 'basic'
-  Object.assign(petForm, {
-    id: undefined, userId: currentCustomerId.value, avatar: undefined, name: '', type: 0, gender: undefined,
-    breed: '', birthday: '', age: 1, weight: 5, size: 'small', isSterilized: 0,
-    arrivalTime: '', houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
-    personality: '', cutePersonality: '', healthStatus: '健康', aggression: 0,
-    vaccineStatus: '无', vaccineCert: undefined, medicalHistory: '', allergies: '', remark: '', tagIds: []
-  })
-  petAvatarDisplayUrl.value = ''
-  petVaccineCertDisplayUrl.value = ''
+  petEditData.value = null
   petDialogVisible.value = true
 }
 
 const handlePetDetail = (row) => {
-  selectedPetId.value = row.id;
-  petDrawerVisible.value = true;
+  selectedPetId.value = row.id
+  petDrawerVisible.value = true
 }
 
 const handlePetEdit = (row) => {
-  petDialogActiveTab.value = 'basic'
-  Object.assign(petForm, {
-    id: row.id, userId: row.userId, avatar: row.avatar, name: row.name, type: row.type,
-    gender: row.gender, breed: row.breed, birthday: row.birthday, age: row.age,
-    weight: row.weight, size: row.size, isSterilized: row.isSterilized,
-    arrivalTime: row.arrivalTime, houseType: row.houseType, entryMethod: row.entryMethod,
-    entryPassword: row.entryPassword, keyLocation: row.keyLocation,
-    personality: row.personality, cutePersonality: row.cutePersonality,
-    healthStatus: row.healthStatus, aggression: row.aggression,
-    vaccineStatus: row.vaccineStatus, vaccineCert: row.vaccineCert,
-    medicalHistory: row.medicalHistory, allergies: row.allergies, remark: row.remark,
-    tagIds: row.tags ? row.tags.map(t => t.id) : []
-  })
-  petAvatarDisplayUrl.value = row.avatarUrl || ''
-  petVaccineCertDisplayUrl.value = row.vaccineCertUrl || ''
+  petEditData.value = row
   petDialogVisible.value = true
 }
 
@@ -906,38 +359,20 @@ const handlePetDelete = (row) => {
   ElMessageBox.confirm(`确认删除宠物 [${row.name}] 吗?`, '提示', { type: 'warning' }).then(() => {
     delPet(row.id).then(() => {
       ElMessage.success('宠物删除成功')
-      customerDetailRef.value.refresh()
+      if (customerDetailRef.value) {
+        customerDetailRef.value.refresh()
+      }
       getList()
     })
   })
 }
 
-const savePet = () => {
-  if (!petForm.name) return ElMessage.warning('请输入宠物姓名');
-  if (!petForm.breed) return ElMessage.warning('请选择品种');
-  if (!petForm.size) return ElMessage.warning('请选择体型');
-  if (petForm.weight === undefined || petForm.weight === null) return ElMessage.warning('请输入体重(kg)');
-  if (petForm.age === undefined || petForm.age === null) return ElMessage.warning('请输入年龄(岁)');
-  if (!petForm.houseType) return ElMessage.warning('请选择家庭房屋类型');
-  if (!petForm.entryMethod) return ElMessage.warning('请选择入门方式');
-  if (petForm.entryMethod === 'password' && !petForm.entryPassword) return ElMessage.warning('请输入门锁密码');
-  if (petForm.entryMethod === 'key' && !petForm.keyLocation) return ElMessage.warning('请输入钥匙存放位置');
-  if (!petForm.healthStatus) return ElMessage.warning('请选择健康状态');
-  if (petForm.aggression === undefined || petForm.aggression === null) return ElMessage.warning('请选择是否有攻击倾向');
-  if (!petForm.vaccineStatus) return ElMessage.warning('请选择疫苗情况');
-  if (!petForm.medicalHistory) return ElMessage.warning('请输入既往病史');
-  if (!petForm.allergies) return ElMessage.warning('请输入过敏史');
-  submitLoading.value = true
-  const data = { ...petForm, aggression: Number(petForm.aggression) || 0 }
-  const api = data.id ? updatePet(data) : addPet(data)
-  api.then(() => {
-    ElMessage.success('宠物档案保存成功')
-    petDialogVisible.value = false
+const onPetSaved = () => {
+  petDialogVisible.value = false
+  if (customerDetailRef.value) {
     customerDetailRef.value.refresh()
-    getList()
-  }).finally(() => {
-    submitLoading.value = false
-  })
+  }
+  getList()
 }
 
 onMounted(() => {
@@ -945,14 +380,23 @@ onMounted(() => {
   loadTags()
   loadAreaStation()
   loadRegionData()
-  getBrandList()
 })
 </script>
 
 <style scoped>
-.page-container { padding: 20px; }
-.card-header { display: flex; justify-content: space-between; align-items: center; }
-.title { font-weight: bold; }
+.page-container {
+  padding: 20px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.title {
+  font-weight: bold;
+}
 
 .profile-header {
   display: flex;
@@ -961,90 +405,41 @@ onMounted(() => {
   padding-bottom: 20px;
   border-bottom: 1px solid #f0f0f0;
 }
+
 .profile-basic {
   margin-left: 20px;
 }
+
 .name-row {
   display: flex;
   align-items: center;
 }
+
 .name {
   font-size: 20px;
   font-weight: bold;
-  color: #303133;
-}
-.phone {
-  margin-left: 10px;
-  color: #666;
-}
-.section-title {
-  font-size: 16px;
-  font-weight: bold;
-  margin-bottom: 15px;
-  border-left: 4px solid #409EFF;
-  padding-left: 10px;
-  line-height: 1.2;
 }
 
-.form-section-header {
-  font-weight: bold;
-  margin-bottom: 15px;
-  margin-top: 10px;
-  padding-bottom: 5px;
-  border-bottom: 1px dashed #eee;
-  color: #303133;
-}
-.upload-avatar:hover {
-  cursor: pointer;
-  opacity: 0.8;
-}
 .pagination-container {
-  margin-top: 20px;
   display: flex;
   justify-content: flex-end;
+  margin-top: 20px;
 }
 
-.customer-tabs {
-  margin-bottom: 20px;
-  background-color: #fff;
-  padding: 10px 20px 0;
-  border-radius: 4px;
-}
-:deep(.el-tabs__header) {
-  margin-bottom: 0;
-}
-:deep(.el-tabs__nav-wrap::after) {
-  height: 0;
-}
-
-/* Add Upload Styles */
-.avatar-uploader .el-upload {
+.upload-avatar {
   cursor: pointer;
-  position: relative;
-  overflow: hidden;
-  display: inline-block;
 }
-.avatar-uploader-icon {
-  font-size: 28px;
-  color: #8c939d;
-  width: 100px;
-  height: 100px;
-  text-align: center;
-  border: 1px dashed #dcdfe6;
-  border-radius: 4px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  transition: .2s;
-}
-.avatar-uploader-icon:hover {
-  border-color: #409EFF;
+
+.form-section-header {
+  margin-bottom: 15px;
+  padding-bottom: 10px;
+  font-size: 14px;
+  font-weight: bold;
   color: #409EFF;
+  border-bottom: 1px solid #e4e7ed;
 }
-.avatar {
-  width: 100px;
-  height: 100px;
-  display: block;
-  border-radius: 4px;
+
+.user-form {
+  padding: 10px;
 }
 </style>

+ 52 - 316
src/views/archieves/pet/index.vue

@@ -6,7 +6,8 @@
         <div class="card-header">
           <span class="title">宠物档案</span>
           <div class="header-actions">
-            <el-input v-model="searchKey" placeholder="搜索宠物名/主人" style="width: 200px; margin-right: 10px" clearable @keyup.enter="handleSearch" @clear="handleSearch" />
+            <el-input v-model="searchKey" placeholder="搜索宠物名/主人" style="width: 200px; margin-right: 10px" clearable
+              @keyup.enter="handleSearch" @clear="handleSearch" />
             <el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['archieves:pet:add']">新增档案</el-button>
           </div>
         </div>
@@ -37,14 +38,16 @@
         </el-table-column>
         <el-table-column label="标签" min-width="150">
           <template #default="scope">
-            <el-tag v-for="tag in scope.row.tags" :key="tag.id" :type="tag.colorType || 'info'" effect="light" size="small" style="margin-right: 5px">{{
+            <el-tag v-for="tag in scope.row.tags" :key="tag.id" :type="tag.colorType || 'info'" effect="light"
+              size="small" style="margin-right: 5px">{{
                 tag.name
               }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="健康状态" width="100" align="center">
           <template #default="scope">
-            <el-tag :type="scope.row.healthStatus === '健康' ? 'success' : 'warning'" effect="dark" size="small">{{ scope.row.healthStatus }}</el-tag>
+            <el-tag :type="scope.row.healthStatus === '健康' ? 'success' : 'warning'" effect="dark" size="small">{{
+              scope.row.healthStatus }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="疫苗接种" width="120" align="center">
@@ -54,155 +57,26 @@
         </el-table-column>
         <el-table-column label="操作" width="200" align="center">
           <template #default="scope">
-            <el-button link type="primary" @click="handleDetail(scope.row)" v-hasPermi="['archieves:pet:query']">详情</el-button>
-            <el-button link type="primary" @click="handleEdit(scope.row)" v-hasPermi="['archieves:pet:edit']">编辑</el-button>
-            <el-button link type="primary" @click="handleRemark(scope.row)" v-hasPermi="['archieves:pet:remark']">备注</el-button>
-            <el-button link type="danger" @click="handleDelete(scope.row)" v-hasPermi="['archieves:pet:remove']">删除</el-button>
+            <el-button link type="primary" @click="handleDetail(scope.row)"
+              v-hasPermi="['archieves:pet:query']">详情</el-button>
+            <el-button link type="primary" @click="handleEdit(scope.row)"
+              v-hasPermi="['archieves:pet:edit']">编辑</el-button>
+            <el-button link type="primary" @click="handleRemark(scope.row)"
+              v-hasPermi="['archieves:pet:remark']">备注</el-button>
+            <el-button link type="danger" @click="handleDelete(scope.row)"
+              v-hasPermi="['archieves:pet:remove']">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
       <div class="pagination-container">
-        <el-pagination
-          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="getList"
-          @current-change="getList"
-        />
+        <el-pagination 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="getList" @current-change="getList" />
       </div>
     </el-card>
 
-    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑宠物档案' : '新增宠物档案'" width="800px">
-      <el-tabs v-model="activeTab">
-        <el-tab-pane label="基本信息" name="basic">
-          <el-form :model="form" label-width="100px">
-            <el-row>
-              <el-col :span="24" style="display: flex; justify-content: center; margin-bottom: 20px">
-                <el-upload class="avatar-uploader" action="#" :show-file-list="false" :auto-upload="false" :on-change="handleUploadFile">
-                  <el-avatar v-if="avatarDisplayUrl" :src="avatarDisplayUrl" :size="80" />
-                  <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
-                </el-upload>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="宠物姓名" required><el-input v-model="form.name" /></el-form-item>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="所属主人" required>
-                  <el-select v-model="form.userId" placeholder="选择主人" style="width: 100%" filterable>
-                    <el-option v-for="user in userList" :key="user.id" :label="user.name + ' - ' + user.phone" :value="user.id" />
-                  </el-select>
-                </el-form-item>
-              </el-col>
-              <el-col :span="12">
-                <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="12">
-                <el-form-item label="品种" required>
-                  <el-input v-model="form.breed" placeholder="请输入品种" style="width: 100%" />
-                </el-form-item>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="体型" required>
-                  <el-select v-model="form.size" style="width: 100%">
-                    <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="12">
-                <el-form-item label="体重(kg)" required><el-input-number v-model="form.weight" :min="0" :precision="1" style="width: 100%" /></el-form-item>
-              </el-col>
-              <el-col :span="12">
-                <el-form-item label="年龄(岁)" required><el-input-number v-model="form.age" :min="0" style="width: 100%" /></el-form-item>
-              </el-col>
-              <el-col :span="24">
-                <el-form-item label="性格关键词"><el-input v-model="form.personality" placeholder="如:活泼、粘人" /></el-form-item>
-              </el-col>
-              <el-col :span="24">
-                <el-form-item label="萌宠性格"><el-input v-model="form.cutePersonality" type="textarea" placeholder="详细描述" /></el-form-item>
-              </el-col>
-              <el-col :span="24">
-                <el-form-item label="宠物标签">
-                  <el-select v-model="form.tagIds" multiple placeholder="选择标签" style="width: 100%">
-                    <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>
-        </el-tab-pane>
-        <el-tab-pane label="家庭信息" name="family">
-          <el-form :model="form" label-width="120px">
-            <el-form-item label="新来家庭时间">
-              <el-date-picker v-model="form.arrivalTime" type="date" placeholder="选择日期" style="width: 100%" />
-            </el-form-item>
-            <el-form-item label="家庭房屋类型" required>
-              <el-radio-group v-model="form.houseType">
-                <el-radio v-for="dict in sys_house_type" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="入门方式" required>
-              <el-radio-group v-model="form.entryMethod">
-                <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="密码" v-if="form.entryMethod === 'password'" required>
-              <el-input v-model="form.entryPassword" placeholder="请输入门锁密码" />
-            </el-form-item>
-            <el-form-item label="钥匙位置" v-if="form.entryMethod === 'key'" required>
-              <el-input v-model="form.keyLocation" placeholder="请输入钥匙存放位置" />
-            </el-form-item>
-          </el-form>
-        </el-tab-pane>
-        <el-tab-pane label="健康状况" name="health">
-          <el-form :model="form" label-width="120px">
-            <el-form-item label="健康状态" required>
-              <el-radio-group v-model="form.healthStatus">
-                <el-radio label="健康">健康</el-radio>
-                <el-radio label="亚健康">亚健康</el-radio>
-                <el-radio label="疾病">疾病</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="是否有攻击倾向" required>
-              <el-switch v-model="form.aggression" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否" />
-            </el-form-item>
-            <el-form-item label="疫苗情况" required>
-              <el-radio-group v-model="form.vaccineStatus">
-                <el-radio label="无">无</el-radio>
-                <el-radio label="已打1次">已打1次</el-radio>
-                <el-radio label="已打2次">已打2次</el-radio>
-                <el-radio label="已打3次">已打3次</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item label="疫苗凭证">
-              <el-upload class="avatar-uploader" action="#" :show-file-list="false" :auto-upload="false" :on-change="handleUploadVaccineCert">
-                <img v-if="vaccineCertDisplayUrl" :src="vaccineCertDisplayUrl" class="avatar" style="width: 100px; height: 100px; object-fit: cover" />
-                <el-icon v-else class="avatar-uploader-icon" style="width: 100px; height: 100px; line-height: 100px"><Plus /></el-icon>
-              </el-upload>
-            </el-form-item>
-            <el-form-item label="既往病史" required>
-              <el-input v-model="form.medicalHistory" type="textarea" placeholder="如有病史请记录" />
-            </el-form-item>
-            <el-form-item label="过敏史" required>
-              <el-input v-model="form.allergies" type="textarea" placeholder="如有过敏源请记录" />
-            </el-form-item>
-          </el-form>
-        </el-tab-pane>
-      </el-tabs>
-      <template #footer>
-        <div class="dialog-footer">
-          <el-button @click="dialogVisible = false">取 消</el-button>
-          <el-button type="primary" :loading="submitLoading" @click="saveData">确 定</el-button>
-        </div>
-      </template>
-    </el-dialog>
+    <AddPetDialog v-model:visible="dialogVisible" :user-options="userList" :pet-data="petEditData"
+      @success="onPetSaved" />
 
     <!-- Pet Profile Drawer (已抽离为公共组件) -->
     <pet-detail-drawer v-model:visible="drawerVisible" :pet-id="currentPetId" editable @remark-saved="getList" />
@@ -223,19 +97,18 @@
 <script setup>
 import { ref, reactive, onMounted, getCurrentInstance, toRefs } from 'vue';
 import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue';
-import { globalHeaders } from '@/utils/request';
+import AddPetDialog from '@/components/AddPetDialog/index.vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
-import { listPet, getPet, addPet, updatePet, delPet, updatePetRemark } from '@/api/archieves/pet';
+import { listPet, getPet, delPet, updatePetRemark } from '@/api/archieves/pet';
 import { listAllTag } from '@/api/archieves/tag';
 import { listAllCustomer } from '@/api/archieves/customer';
 
 const { proxy } = getCurrentInstance();
-const { sys_pet_gender, sys_pet_type, sys_pet_size, sys_house_type, sys_entry_method } = toRefs(
-  proxy?.useDict('sys_pet_gender', 'sys_pet_type', 'sys_pet_size', 'sys_house_type', 'sys_entry_method')
+const { sys_pet_gender, sys_pet_type, sys_pet_size } = toRefs(
+  proxy?.useDict('sys_pet_gender', 'sys_pet_type', 'sys_pet_size')
 );
 
 const loading = ref(false);
-const submitLoading = ref(false);
 const total = ref(0);
 const tableData = ref([]);
 
@@ -252,48 +125,14 @@ const searchKey = ref('');
 const dialogVisible = ref(false);
 const drawerVisible = ref(false);
 const remarkDialogVisible = ref(false);
-const isEdit = ref(false);
-const activeTab = ref('basic');
 const currentPet = ref({});
 const currentPetId = ref(null);
 
 const allPetTags = ref([]);
 const userList = ref([]);
+const petEditData = ref(null);
 
 const remarkForm = reactive({ content: '' });
-const avatarDisplayUrl = ref('');
-const vaccineCertDisplayUrl = ref('');
-
-
-const form = reactive({
-  id: undefined,
-  userId: undefined,
-  avatar: undefined,
-  name: '',
-  type: 0,
-  gender: undefined,
-  breed: '',
-  birthday: '',
-  age: 1,
-  weight: 5,
-  size: 'small',
-  isSterilized: 0,
-  arrivalTime: '',
-  houseType: '',
-  entryMethod: '',
-  entryPassword: '',
-  keyLocation: '',
-  personality: '',
-  cutePersonality: '',
-  healthStatus: '健康',
-  aggression: 0,
-  vaccineStatus: '无',
-  vaccineCert: undefined,
-  medicalHistory: '',
-  allergies: '',
-  remark: '',
-  tagIds: []
-});
 
 const getList = () => {
   loading.value = true;
@@ -324,42 +163,21 @@ const loadUsers = () => {
 };
 
 const handleAdd = () => {
-  isEdit.value = false;
-  activeTab.value = 'basic';
-  Object.assign(form, {
-    id: undefined, userId: undefined, avatar: undefined, name: '', type: 0, gender: undefined,
-    breed: '', birthday: '', age: 1, weight: 5, size: 'small', isSterilized: 0,
-    arrivalTime: '', houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
-    personality: '', cutePersonality: '', healthStatus: '健康', aggression: 0,
-    vaccineStatus: '无', vaccineCert: undefined, medicalHistory: '', allergies: '', remark: '', tagIds: []
-  });
-  avatarDisplayUrl.value = '';
-  vaccineCertDisplayUrl.value = '';
-  dialogVisible.value = true;
-};
+  petEditData.value = null
+  dialogVisible.value = true
+}
 
 const handleEdit = (row) => {
-  isEdit.value = true;
-  activeTab.value = 'basic';
   getPet(row.id).then((res) => {
-    const data = res.data;
-    Object.assign(form, {
-      id: data.id, userId: data.userId, avatar: data.avatar, name: data.name, type: data.type,
-      gender: data.gender, breed: data.breed, birthday: data.birthday, age: data.age,
-      weight: data.weight, size: data.size, isSterilized: data.isSterilized,
-      arrivalTime: data.arrivalTime, houseType: data.houseType, entryMethod: data.entryMethod,
-      entryPassword: data.entryPassword, keyLocation: data.keyLocation,
-      personality: data.personality, cutePersonality: data.cutePersonality,
-      healthStatus: data.healthStatus, aggression: data.aggression,
-      vaccineStatus: data.vaccineStatus, vaccineCert: data.vaccineCert,
-      medicalHistory: data.medicalHistory, allergies: data.allergies, remark: data.remark,
-      tagIds: data.tags ? data.tags.map(t => t.id) : []
-    });
-    avatarDisplayUrl.value = data.avatarUrl || '';
-    vaccineCertDisplayUrl.value = data.vaccineCertUrl || '';
-    dialogVisible.value = true;
-  });
-};
+    petEditData.value = res.data
+    dialogVisible.value = true
+  })
+}
+
+const onPetSaved = () => {
+  dialogVisible.value = false
+  getList()
+}
 
 const handleDetail = (row) => {
   currentPetId.value = row.id;
@@ -372,26 +190,6 @@ const handleRemark = (row) => {
   remarkDialogVisible.value = true;
 };
 
-const saveRemark = () => {
-  if (!remarkForm.content) return ElMessage.warning('请输入内容');
-  const data = { petId: currentPet.value.id, content: remarkForm.content };
-  updatePetRemark(data).then(() => {
-    ElMessage.success('备注添加成功');
-    remarkDialogVisible.value = false;
-    getList();
-    // 刷新 drawer 中的变更日志
-    if (drawerVisible.value) {
-      listAllChangeLog(currentPet.value.id, 'pet').then((logRes) => {
-        // NOTE: 这里原代码中没有定义 changeLogs,可能是个疏忽
-        // 但既然我是来改备注接口的,就先保留原有的逻辑
-        if (typeof changeLogs !== 'undefined') {
-          changeLogs.value = logRes.data || [];
-        }
-      });
-    }
-  });
-};
-
 const handleDelete = (row) => {
   ElMessageBox.confirm('确认删除该宠物档案吗?', '提示', { type: 'warning' }).then(() => {
     delPet(row.id).then(() => {
@@ -401,83 +199,13 @@ const handleDelete = (row) => {
   });
 };
 
-const baseUrl = import.meta.env.VITE_APP_BASE_API;
-const uploadUrl = baseUrl + '/resource/oss/upload';
-
-const handleUploadFile = async (file) => {
-  const formData = new FormData();
-  formData.append('file', file.raw);
-  try {
-    const headers = globalHeaders();
-    const res = await fetch(uploadUrl, {
-      method: 'POST',
-      headers: {
-        'Authorization': headers.Authorization,
-        'clientid': headers.clientid
-      },
-      body: formData
-    });
-    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('头像上传失败');
-  }
-};
-
-const handleUploadVaccineCert = async (file) => {
-  const formData = new FormData();
-  formData.append('file', file.raw);
-  try {
-    const headers = globalHeaders();
-    const res = await fetch(uploadUrl, {
-      method: 'POST',
-      headers: {
-        'Authorization': headers.Authorization,
-        'clientid': headers.clientid
-      },
-      body: formData
-    });
-    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('疫苗凭证上传失败');
-  }
-};
-
-const saveData = () => {
-  if (!form.name) return ElMessage.warning('请输入宠物姓名');
-  if (!form.userId) return ElMessage.warning('请选择所属主人');
-  if (!form.breed) return ElMessage.warning('请输入品种');
-  if (!form.size) return ElMessage.warning('请选择体型');
-  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('请选择健康状态');
-  if (form.aggression === undefined || form.aggression === null) return ElMessage.warning('请选择是否有攻击倾向');
-  if (!form.vaccineStatus) return ElMessage.warning('请选择疫苗情况');
-  if (!form.medicalHistory) return ElMessage.warning('请输入既往病史');
-  if (!form.allergies) return ElMessage.warning('请输入过敏史');
-  submitLoading.value = true;
-  const api = isEdit.value ? updatePet(form) : addPet(form);
-  api.then(() => {
-    ElMessage.success('保存成功');
-    dialogVisible.value = false;
+const saveRemark = () => {
+  if (!remarkForm.content) return ElMessage.warning('请输入内容');
+  const data = { petId: currentPet.value.id, content: remarkForm.content };
+  updatePetRemark(data).then(() => {
+    ElMessage.success('备注添加成功');
+    remarkDialogVisible.value = false;
     getList();
-  }).finally(() => {
-    submitLoading.value = false;
   });
 };
 
@@ -489,19 +217,20 @@ onMounted(() => {
 </script>
 
 <style scoped>
-
-
 .page-container {
   padding: 20px;
 }
+
 .card-header {
   display: flex;
   justify-content: space-between;
   align-items: center;
 }
+
 .title {
   font-weight: bold;
 }
+
 .avatar-uploader-icon {
   font-size: 28px;
   color: #8c939d;
@@ -514,9 +243,11 @@ onMounted(() => {
   justify-content: center;
   align-items: center;
 }
+
 .avatar-uploader-icon:hover {
   border-color: var(--el-color-primary);
 }
+
 .profile-header {
   display: flex;
   align-items: center;
@@ -524,18 +255,22 @@ onMounted(() => {
   padding-bottom: 20px;
   border-bottom: 1px solid #f0f0f0;
 }
+
 .profile-basic {
   margin-left: 20px;
 }
+
 .name-row {
   display: flex;
   align-items: center;
 }
+
 .name {
   font-size: 20px;
   font-weight: bold;
   color: #303133;
 }
+
 .section-title {
   font-size: 16px;
   font-weight: bold;
@@ -544,6 +279,7 @@ onMounted(() => {
   padding-left: 10px;
   line-height: 1.2;
 }
+
 .pagination-container {
   margin-top: 20px;
   display: flex;

+ 34 - 9
src/views/order/management/components/OrderDetailDrawer.vue

@@ -90,7 +90,7 @@
                         <el-descriptions :column="2" size="small" class="pet-desc" border>
                             <el-descriptions-item label="品种">{{ order.petBreed || '-' }}</el-descriptions-item>
                             <el-descriptions-item label="疫苗状态"><span style="color:#67c23a">{{ order.petVaccine || '-'
-                                    }}</span></el-descriptions-item>
+                            }}</span></el-descriptions-item>
                             <el-descriptions-item label="性格特点">{{ order.petCharacter || '-' }}</el-descriptions-item>
                             <el-descriptions-item label="健康状况">{{ order.petHealth || '-' }}</el-descriptions-item>
                         </el-descriptions>
@@ -104,7 +104,7 @@
                         <div class="user-content">
                             <div class="u-row">
                                 <el-avatar :size="40" :src="order.userAvatar">{{ (order.userName || '').charAt(0)
-                                    }}</el-avatar>
+                                }}</el-avatar>
                                 <div class="u-info">
                                     <div class="nm">{{ order.userName }}</div>
                                     <div class="ph">{{ order.contactPhone }}</div>
@@ -136,18 +136,18 @@
                                         ({{ Number(order.platformId) === 1 ? '门店下单' : '平台代下单' }})</el-descriptions-item>
                                     <el-descriptions-item label="宠主信息">{{ order.userName || '-' }} / {{
                                         order.contactPhone || '-'
-                                    }}</el-descriptions-item>
+                                        }}</el-descriptions-item>
                                     <el-descriptions-item label="订单佣金" label-class-name="money-label">
                                         <span style="color:#f56c6c; font-weight:bold;">¥ {{ order.orderCommission ?
                                             (order.orderCommission / 100).toFixed(2) : '0.00' }}</span>
                                     </el-descriptions-item>
 
                                     <el-descriptions-item label="预约时间">{{ getServiceTimeRange(order.serviceTime) || '-'
-                                        }}</el-descriptions-item>
+                                    }}</el-descriptions-item>
                                     <el-descriptions-item label="团购套餐">{{ order.groupBuyPackage || '-'
-                                        }}</el-descriptions-item>
-                                    <el-descriptions-item label="创建时间">{{ order.createTime || '-'
                                     }}</el-descriptions-item>
+                                    <el-descriptions-item label="创建时间">{{ order.createTime || '-'
+                                        }}</el-descriptions-item>
 
                                     <el-descriptions-item label="订单备注" :span="3">
                                         {{ order.remark || '-' }}
@@ -166,7 +166,7 @@
                                     <div class="t-row">
                                         <span class="t-k">起点</span>
                                         <span class="t-v">{{ order.detail?.fromAddress || order.detail?.pickAddr || '-'
-                                        }}</span>
+                                            }}</span>
                                     </div>
                                     <div class="t-row">
                                         <span class="t-k">终点</span>
@@ -187,7 +187,7 @@
                                 <el-descriptions :column="2" border size="default" class="custom-desc">
                                     <el-descriptions-item label="服务地址" :span="2">{{ order.detail?.area || order.address
                                         || '-'
-                                    }}</el-descriptions-item>
+                                        }}</el-descriptions-item>
                                 </el-descriptions>
                             </div>
                         </div>
@@ -199,7 +199,7 @@
                             <div v-if="order.fulfillerName" class="fulfiller-card">
                                 <div class="f-left">
                                     <el-avatar :size="60" :src="order.fulfillerAvatar">{{ order.fulfillerName.charAt(0)
-                                        }}</el-avatar>
+                                    }}</el-avatar>
                                 </div>
                                 <div class="f-right">
                                     <div class="f-row1">
@@ -227,6 +227,11 @@
                     <!-- Tab 3: Service Progress -->
                     <el-tab-pane label="服务进度" name="service">
                         <div class="tab-pane-content">
+                            <div style="display: flex; justify-content: flex-end; margin-bottom: 15px; gap: 10px;">
+                                <el-button type="success" size="small" icon="Link"
+                                    v-hasPermi="['order:management:queyGenerateFulfillPath']"
+                                    @click="handleGenerateFulfillPath">生成履约路径</el-button>
+                            </div>
                             <div v-if="serviceProgressSteps.length === 0" class="empty-progress"
                                 style="padding:40px; text-align:center; color:#909399;">
                                 <el-result icon="info" title="待接单" sub-title="履约者接单后将在此记录服务进度"></el-result>
@@ -691,6 +696,26 @@ const handleExportLogs = () => {
         `OrderLogs_${props.order.orderNo}_${new Date().getTime()}.xlsx`
     );
 }
+
+/**
+ * 生成履约路径链接并复制到剪贴板
+ */
+const handleGenerateFulfillPath = () => {
+    const id = props.order?.id
+    if (!id) {
+        ElMessage.warning('订单信息不完整,无法生成链接')
+        return
+    }
+    const hostname = window.location.hostname
+    const isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)
+    const baseUrl = isIp ? `http://${hostname}` : 'http://hoomeng.pet'
+    const url = `${baseUrl}/fulfillPath?orderId=${id}`
+    navigator.clipboard.writeText(url).then(() => {
+        ElMessage.success('履约路径已复制到剪贴板')
+    }).catch(() => {
+        ElMessage({ message: url, type: 'success', duration: 10000, showClose: true })
+    })
+}
 </script>
 
 <style scoped>

+ 20 - 1
src/views/order/purchase/components/AddPetDialog.vue

@@ -84,7 +84,7 @@
           <el-form-item label="入门方式" required>
             <el-radio-group v-model="form.entryMethod">
               <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label
-                }}</el-radio>
+              }}</el-radio>
             </el-radio-group>
           </el-form-item>
           <el-form-item label="密码" v-if="form.entryMethod === 'password'" required>
@@ -149,6 +149,7 @@ import { ref, reactive, watch, onMounted, getCurrentInstance, toRefs } from 'vue
 import { ElMessage } from 'element-plus'
 import { globalHeaders } from '@/utils/request'
 import { addPetOnOrder } from '@/api/archieves/pet'
+import { getCustomer } from '@/api/archieves/customer'
 import { listAllTag } from '@/api/archieves/tag'
 
 const props = defineProps({
@@ -215,9 +216,27 @@ watch(() => props.visible, (val) => {
       personality: '', cutePersonality: '', healthStatus: '健康', aggression: 0,
       vaccineStatus: '无', vaccineCert: undefined, medicalHistory: '', allergies: '', remark: '', tagIds: []
     })
+    // 自动从主人信息填充家庭信息重叠字段
+    if (props.userId) {
+      fetchAndFillOwnerInfo(props.userId)
+    }
   }
 })
 
+const fetchAndFillOwnerInfo = (userId) => {
+  getCustomer(userId).then((res) => {
+    const data = res.data
+    if (data) {
+      form.houseType = data.houseType || form.houseType
+      form.entryMethod = data.entryMethod || form.entryMethod
+      form.entryPassword = data.entryPassword || form.entryPassword
+      form.keyLocation = data.keyLocation || form.keyLocation
+    }
+  }).catch(() => {
+    // 获取主人信息失败不影响新增宠物流程
+  })
+}
+
 const loadTags = () => {
   listAllTag({ category: 'pet', status: 0 }).then((res) => {
     allPetTags.value = res.data || []

+ 65 - 15
src/views/order/purchase/components/AddUserDialog.vue

@@ -1,21 +1,26 @@
 <template>
-  <el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" title="新增用户" width="700px" destroy-on-close append-to-body>
+  <el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" title="新增用户" width="700px"
+    destroy-on-close append-to-body>
     <el-form :model="form" label-width="90px" class="user-form">
       <el-row :gutter="20">
         <el-col :span="24" style="text-align: center; margin-bottom: 25px;">
           <el-upload action="#" :show-file-list="false" :auto-upload="false" :on-change="handleUserUploadFile">
-            <el-avatar :size="80" :src="userAvatarDisplayUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" class="upload-avatar" />
+            <el-avatar :size="80"
+              :src="userAvatarDisplayUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'"
+              class="upload-avatar" />
             <div style="margin-top: 8px; font-size: 12px; color: #409EFF;">点击修改头像</div>
           </el-upload>
         </el-col>
 
-        <el-col :span="24"><div class="form-section-header">基本资料</div></el-col>
+        <el-col :span="24">
+          <div class="form-section-header">基本资料</div>
+        </el-col>
         <!-- 录入来源不在表单中展示,自动使用当前登录用户的 tenantId,提交时静默传递 -->
         <el-col :span="24">
           <el-form-item label="所属站点" required>
             <el-cascader v-model="formAreaValue" :options="areaTreeOptions" placeholder="请选择站点"
-              :props="{ checkStrictly: true, value: 'value', label: 'label' }"
-              style="width: 100%" clearable @change="handleFormAreaChange" />
+              :props="{ checkStrictly: true, value: 'value', label: 'label' }" style="width: 100%" clearable
+              @change="handleFormAreaChange" />
           </el-form-item>
         </el-col>
         <el-col :span="12">
@@ -27,12 +32,15 @@
         <el-col :span="12">
           <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-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-col :span="24"><div class="form-section-header">居住信息</div></el-col>
+        <el-col :span="24">
+          <div class="form-section-header">居住信息</div>
+        </el-col>
         <el-col :span="24">
           <el-form-item label="所在地区">
             <RegionCascader v-model="regionCascaderValue" />
@@ -51,7 +59,8 @@
         <el-col :span="12">
           <el-form-item label="入门方式" required>
             <el-radio-group v-model="form.entryMethod">
-              <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
+              <el-radio v-for="dict in sys_entry_method" :key="dict.value" :value="dict.value">{{ dict.label
+              }}</el-radio>
             </el-radio-group>
           </el-form-item>
         </el-col>
@@ -66,7 +75,9 @@
           </el-form-item>
         </el-col>
 
-        <el-col :span="24"><div class="form-section-header">其他</div></el-col>
+        <el-col :span="24">
+          <div class="form-section-header">其他</div>
+        </el-col>
         <el-col :span="24">
           <el-form-item label="用户标签">
             <el-select v-model="selectedTagIds" multiple placeholder="选择标签" style="width: 100%">
@@ -84,7 +95,8 @@
     <template #footer>
       <div style="text-align: center; margin-top: 20px;">
         <el-button @click="$emit('update:visible', false)" size="large" style="width: 120px;">取消</el-button>
-        <el-button type="primary" :loading="submitLoading" @click="saveUser" size="large" style="width: 120px;">保存</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="saveUser" size="large"
+          style="width: 120px;">保存</el-button>
       </div>
     </template>
   </el-dialog>
@@ -102,7 +114,8 @@ import PageSelect from '@/components/PageSelect/index.vue'
 import { useUserStore } from '@/store/modules/user'
 
 const props = defineProps({
-  visible: { type: Boolean, default: false }
+  visible: { type: Boolean, default: false },
+  stationId: { type: [String, Number], default: undefined }
 })
 
 const emit = defineEmits(['update:visible', 'success'])
@@ -174,6 +187,29 @@ const resetForm = () => {
     tenantId: userStore.tenantId,
     emergencyContact: '', emergencyPhone: '', memberLevel: 0, status: 0, remark: '', tagIds: []
   })
+  // 根据门店站点自动选中所属站点
+  if (props.stationId && allNodes.value.length > 0) {
+    const path = getStationPath(props.stationId)
+    if (path.length > 0) {
+      formAreaValue.value = path
+      handleFormAreaChange(path)
+    }
+  }
+}
+
+// 根据站点ID获取级联路径
+const getStationPath = (id) => {
+  if (!id) return []
+  const path = []
+  let currentId = String(id)
+  let maxDepth = 10
+  while (currentId && maxDepth-- > 0) {
+    const node = allNodes.value.find(n => String(n.id) === currentId)
+    if (!node) break
+    path.unshift(node.id)
+    currentId = node.parentId != null ? String(node.parentId) : null
+  }
+  return path
 }
 
 const areaTreeOptions = computed(() => {
@@ -265,7 +301,7 @@ const saveUser = () => {
   } else {
     form.regionCode = ''
   }
-  
+
   addCustomerOnOrder(form).then(res => {
     emit('success', res.data)
     emit('update:visible', false)
@@ -281,7 +317,21 @@ onMounted(() => {
 </script>
 
 <style scoped>
-.form-section-header { font-weight: bold; margin-bottom: 20px; font-size: 15px; color: #303133; }
-.upload-avatar { cursor: pointer; border: 2px solid #e4e7ed; transition: border-color 0.2s; border-radius: 50%; }
-.upload-avatar:hover { border-color: #409EFF; }
+.form-section-header {
+  font-weight: bold;
+  margin-bottom: 20px;
+  font-size: 15px;
+  color: #303133;
+}
+
+.upload-avatar {
+  cursor: pointer;
+  border: 2px solid #e4e7ed;
+  transition: border-color 0.2s;
+  border-radius: 50%;
+}
+
+.upload-avatar:hover {
+  border-color: #409EFF;
+}
 </style>

+ 58 - 6
src/views/order/purchase/index.vue

@@ -192,9 +192,10 @@
 
     <!-- Dialogs -->
     <!-- Add User Dialog -->
-    <AddUserDialog v-model:visible="userDialogVisible" @success="handleUserSuccess" />
+    <AddCustomerDialog v-model:visible="userDialogVisible" :station-id="currentStationId" :order-mode="true"
+      @success="handleUserSuccess" />
     <AddPetDialog v-model:visible="petDialogVisible" :user-id="form.userId" :user-options="userOptions"
-      @success="handlePetSuccess" />
+      :locked-owner="true" @success="handlePetSuccess" />
   </div>
 </template>
 
@@ -204,8 +205,8 @@ import { ElMessage } from 'element-plus';
 import TransportForm from './components/TransportForm.vue';
 import FeedingForm from './components/FeedingForm.vue';
 import WashingForm from './components/WashingForm.vue';
-import AddUserDialog from './components/AddUserDialog.vue';
-import AddPetDialog from './components/AddPetDialog.vue';
+import AddCustomerDialog from '@/components/AddCustomerDialog/index.vue';
+import AddPetDialog from '@/components/AddPetDialog/index.vue';
 import PageSelect from '@/components/PageSelect/index.vue';
 import { listStoreOnOrder } from '@/api/system/store';
 import { listAllService } from '@/api/service/list/index';
@@ -515,6 +516,10 @@ const userSelectOptions = computed(() => {
   return userOptions.value.map((u) => ({ label: `${u.name} - ${u.phoneNumber || u.phone}`, value: u.id }));
 });
 const selectedMerchantName = computed(() => stores.value.find((m) => m.id === form.merchantId)?.name);
+const currentStationId = computed(() => {
+  const store = stores.value.find((s) => s.id === form.merchantId)
+  return store ? store.site : undefined
+})
 const selectedUserName = computed(() => userOptions.value.find((u) => u.id === form.userId)?.name);
 const selectedPet = computed(() => currentPets.value.find((p) => p.id === form.petId));
 const selectedPetName = computed(() => selectedPet.value?.name);
@@ -645,9 +650,56 @@ const resetForm = () => {
   currentPets.value = [];
 };
 
+const validateForm = () => {
+  if (!form.merchantId) return '请选择服务门店';
+  if (!form.userId) return '请选择宠主用户';
+  if (!form.petId) return '请选择宠物';
+  if (!form.serviceId) return '请选择服务项目';
+  if (!form.orderCommission || form.orderCommission <= 0) return '请填写订单佣金且不能为0';
+
+  if (form.type === 'transport') {
+    const td = form.transport;
+    if (['round', 'pick'].includes(td.subType)) {
+      if (!td.pickStartRegion || td.pickStartRegion.length === 0) return '请选择接宠起点区域';
+      if (!td.pickStartDetail) return '请填写接宠起点详细地址';
+      if (!td.pickEndRegion || td.pickEndRegion.length === 0) return '请选择接宠终点区域';
+      if (!td.pickEndDetail) return '请填写接宠终点详细地址';
+      if (!td.pickContact) return '请填写接宠联系人';
+      if (!td.pickPhone) return '请填写接宠电话';
+      if (!td.pickTime) return '请选择接宠时间';
+    }
+    if (['round', 'drop'].includes(td.subType)) {
+      if (!td.dropStartRegion || td.dropStartRegion.length === 0) return '请选择送宠起点区域';
+      if (!td.dropStartDetail) return '请填写送宠起点详细地址';
+      if (!td.dropEndRegion || td.dropEndRegion.length === 0) return '请选择送宠终点区域';
+      if (!td.dropEndDetail) return '请填写送宠终点详细地址';
+      if (!td.dropContact) return '请填写送宠联系人';
+      if (!td.dropPhone) return '请填写送宠电话';
+      if (!td.dropTime) return '请选择预计送回时间';
+    }
+  } else if (form.type === 'feeding' || form.type === 'washing') {
+    const data = form[form.type];
+    if (!data.region || data.region.length === 0) return '请选择服务区域';
+    if (!data.addressDetail) return '请填写服务详细地址';
+    if (!data.appointments || data.appointments.length === 0) return '请至少添加一个预约时间';
+
+    for (let i = 0; i < data.appointments.length; i++) {
+      if (!data.appointments[i].startTime) {
+        return `请选择第 ${i + 1} 个预约的开始时间`;
+      }
+      if (!data.appointments[i].endTime) {
+        return `请选择第 ${i + 1} 个预约的结束时间`;
+      }
+    }
+  }
+
+  return null;
+};
+
 const handleSubmit = async () => {
-  if (!form.orderCommission || form.orderCommission <= 0) {
-    ElMessage.warning('请填写订单佣金且不能为0');
+  const errorMsg = validateForm();
+  if (errorMsg) {
+    ElMessage.warning(errorMsg);
     return;
   }
   try {