Pārlūkot izejas kodu

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

Huanyi 1 dienu atpakaļ
vecāks
revīzija
c4faa5b5b5

+ 2 - 1
.gitignore

@@ -1,4 +1,5 @@
 node_modules/
 dist/
 
-.idea/
+.idea/*
+!.idea/runConfigurations/

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

@@ -0,0 +1,423 @@
+<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="12" v-if="showBrand">
+              <el-form-item label="所属品牌" required>
+                <PageSelect v-model="form.tenantId"
+                  :options="brandList.map(item => ({ value: item.tenantId, label: item.name }))" :total="brandTotal"
+                  :pageSize="10" placeholder="请选择所属品牌" @page-change="handleBrandPageChange"
+                  @visible-change="handleBrandVisibleChange" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="showBrand ? 12 : 24">
+              <el-form-item label="所属站点" required>
+                <el-cascader v-model="formAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }"
+                  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="saveData" size="large">保存</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, computed, watch, onMounted, getCurrentInstance, toRefs } from 'vue'
+import { ElMessage } from 'element-plus'
+import { globalHeaders } from '@/utils/request'
+import { addCustomer, updateCustomer, addCustomerOnOrder } from '@/api/archieves/customer'
+import { listAllTag } from '@/api/archieves/tag'
+import { listAreaStation } from '@/api/system/areaStation'
+import { listOnStore as listBrandOnStore } from '@/api/system/tenant'
+import RegionCascader from '@/components/RegionCascader/index.vue'
+import PageSelect from '@/components/PageSelect/index.vue'
+import { useUserStore } from '@/store/modules/user'
+
+const userStore = useUserStore()
+
+const props = defineProps({
+  visible: { type: Boolean, default: false },
+  customerData: { type: Object, default: null },
+  showBrand: { type: Boolean, default: false },
+  stationId: { type: [String, Number], default: undefined },
+  tenantId: { type: [String, Number], default: undefined },
+  orderMode: { type: Boolean, default: false }
+})
+
+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 isEdit = ref(false)
+
+const dialogTitle = computed(() => {
+  if (props.orderMode) return '新增用户'
+  return isEdit.value ? '编辑用户' : '新增用户'
+})
+
+const submitLoading = ref(false)
+const allNodes = ref([])
+const allUserTags = ref([])
+const formAreaValue = ref([])
+const regionCascaderValue = ref([])
+const selectedTagIds = ref([])
+const avatarDisplayUrl = ref('')
+
+const brandList = ref([])
+const brandTotal = ref(0)
+
+const baseUrl = import.meta.env.VITE_APP_BASE_API
+const uploadUrl = baseUrl + '/resource/oss/upload'
+
+function emptyForm() {
+  return {
+    id: undefined, name: '', phone: '', avatar: undefined, gender: undefined,
+    birthday: '', idCard: '', areaId: undefined, stationId: undefined,
+    regionCode: '', region: [], address: '',
+    houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
+    source: '', emergencyContact: '', emergencyPhone: '',
+    memberLevel: 0, status: 0, remark: '', tagIds: [], tenantId: undefined
+  }
+}
+
+const form = reactive(emptyForm())
+
+watch(() => props.visible, (val) => {
+  if (val) {
+    resetForm()
+    loadTags()
+  }
+})
+
+const resetForm = () => {
+  submitLoading.value = false
+  selectedTagIds.value = []
+  avatarDisplayUrl.value = ''
+  formAreaValue.value = []
+  regionCascaderValue.value = []
+  Object.assign(form, emptyForm())
+
+  if (props.customerData) {
+    isEdit.value = true
+    const data = props.customerData
+    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, source: data.source,
+      emergencyContact: data.emergencyContact, emergencyPhone: data.emergencyPhone,
+      memberLevel: data.memberLevel, status: data.status, remark: data.remark, tagIds: [],
+      tenantId: data.tenantId
+    })
+    avatarDisplayUrl.value = data.avatarUrl || ''
+    if (data.stationId || data.areaId) {
+      formAreaValue.value = getStationPath(data.stationId || data.areaId)
+    }
+    regionCascaderValue.value = data.regionCode ? data.regionCode.split('/') : []
+    selectedTagIds.value = data.tags ? data.tags.map(t => t.id) : []
+  } else {
+    isEdit.value = false
+    form.tenantId = props.tenantId || undefined
+    if (props.stationId && allNodes.value.length > 0) {
+      const path = getStationPath(props.stationId)
+      if (path.length > 0) {
+        formAreaValue.value = path
+        handleFormAreaChange(path)
+      }
+    }
+  }
+}
+
+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 = { id: item.id, name: item.name }
+        if (children.length > 0) node.children = children
+        return node
+      })
+  }
+  return buildTree(allNodes.value, 0)
+})
+
+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 handleFormAreaChange = (value) => {
+  if (value && value.length > 0) {
+    const lastId = value[value.length - 1]
+    const node = allNodes.value.find(item => item.id === lastId)
+    form.stationId = lastId
+    form.areaId = node ? node.parentId : undefined
+  } else {
+    form.stationId = undefined
+    form.areaId = undefined
+  }
+}
+
+const loadTags = () => {
+  listAllTag({ category: 'customer', status: 0 }).then((res) => {
+    allUserTags.value = res.data || []
+  }).catch(() => { })
+}
+
+const loadAreaStation = () => {
+  listAreaStation().then((res) => {
+    allNodes.value = res.data || []
+  })
+}
+
+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 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 saveData = () => {
+  if (props.showBrand && !form.tenantId) return ElMessage.warning('请选择所属品牌')
+  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 = ''
+  }
+
+  let api
+  if (props.orderMode) {
+    api = addCustomerOnOrder(form)
+  } else {
+    api = isEdit.value ? updateCustomer(form) : addCustomer(form)
+  }
+  api.then((res) => {
+    ElMessage.success(isEdit.value ? '更新成功' : '保存成功')
+    emit('success', res.data)
+    emit('update:visible', false)
+  }).finally(() => {
+    submitLoading.value = false
+  })
+}
+
+onMounted(() => {
+  loadAreaStation()
+})
+</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>

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

@@ -0,0 +1,473 @@
+<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"
     }
   }
 }

+ 3 - 0
src/types/components.d.ts

@@ -8,6 +8,9 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    AddCustomerDialog: typeof import('./../components/AddCustomerDialog/index.vue')['default']
+    AddPetDialog: typeof import('./../components/AddPetDialog/index.vue')['default']
+    AddUserDialog: typeof import('./../components/AddUserDialog/index.vue')['default']
     ApprovalButton: typeof import('./../components/Process/approvalButton.vue')['default']
     ApprovalRecord: typeof import('./../components/Process/approvalRecord.vue')['default']
     Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']

+ 107 - 637
src/views/archieves/customer/index.vue

@@ -5,22 +5,18 @@
         <div class="card-header">
           <span class="title">用户管理</span>
           <div class="header-actions">
-            <el-cascader
-              v-model="searchAreaValue"
-              :options="areaTreeOptions"
-              :props="{ value: 'id', label: 'name' }"
-              placeholder="所属站点"
-              style="width: 240px; margin-right: 10px"
-              clearable
-              @change="onSearchAreaChange"
-            />
-            <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>
+            <el-cascader v-model="searchAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }"
+              placeholder="所属站点" style="width: 240px; margin-right: 10px" clearable @change="onSearchAreaChange" />
+            <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;">
@@ -36,17 +32,20 @@
         </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><el-tag size="small" effect="plain" :type="scope.row.tenantName ? '' : 'warning'">{{ scope.row.tenantName || '-' }}</el-tag></div>
+            <div><el-tag size="small" effect="plain" :type="scope.row.tenantName ? '' : 'warning'">{{
+              scope.row.tenantName || '-' }}</el-tag></div>
             <div style="font-size: 12px; color: #999; margin-top: 4px;">{{ scope.row.createTime }}</div>
           </template>
         </el-table-column>
@@ -62,30 +61,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>
@@ -93,145 +89,33 @@
         </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="currentUser.id"
-      editable
-      :service-list="serviceList"
-      :area-station-list="allNodes"
-      @add-pet="openAddPet"
-      @pet-detail="handlePetDetail"
-      @pet-edit="handlePetEdit"
-      @pet-remark="handlePetRemark"
-      @pet-delete="handlePetDelete"
-    />
+    <CustomerDetailDrawer ref="customerDetailRef" v-model:visible="drawerVisible" :customer-id="currentUser.id" editable
+      :service-list="serviceList" :area-station-list="allNodes" @add-pet="openAddPet" @pet-detail="handlePetDetail"
+      @pet-edit="handlePetEdit" @pet-remark="handlePetRemark" @pet-delete="handlePetDelete" />
 
     <!-- Pet Detail Drawer -->
-    <PetDetailDrawer
-      v-model:visible="petDrawerVisible"
-      :pet-id="currentPet.id"
-      :service-list="serviceList"
-      @remark-saved="getList"
-    />
+    <PetDetailDrawer v-model:visible="petDrawerVisible" :pet-id="currentPet.id" :service-list="serviceList"
+      @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="所属品牌" required>
-              <PageSelect v-model="form.tenantId"
-                :options="brandList.map(item => ({ value: item.tenantId, label: item.name }))"
-                :total="brandTotal" :pageSize="10" placeholder="请选择所属品牌"
-                @page-change="handleBrandPageChange"
-                @visible-change="handleBrandVisibleChange" />
-            </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">
-            <el-form-item label="所属站点" required>
-              <el-cascader v-model="formAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }" placeholder="请选择站点"
-                style="width: 100%" clearable @change="handleFormAreaChange" />
-            </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="customerDialogVisible" :customer-data="customerEditData" :show-brand="true"
+      @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>
 
@@ -239,179 +123,44 @@
     <el-dialog v-model="petRemarkDialogVisible" title="添加宠物备注" width="400px" append-to-body>
       <el-input v-model="remarkForm.content" type="textarea" :rows="3" placeholder="请输入宠物备注内容..." />
       <template #footer>
-            <span class="dialog-footer">
-                <el-button @click="petRemarkDialogVisible = false">取消</el-button>
-                <el-button type="primary" @click="savePetRemark">保存</el-button>
-            </span>
+        <span class="dialog-footer">
+          <el-button @click="petRemarkDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="savePetRemark">保存</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="[{ id: currentUser.id, name: currentUser.name, phone: currentUser.phone }]" :locked-owner="true"
+      :pet-data="petEditData" @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 { listAllTag } from '@/api/archieves/tag'
-import { listPetByUser, addPet, updatePet, delPet, updatePetRemark } from '@/api/archieves/pet'
+import { listCustomer, getCustomer, delCustomer, changeCustomerStatus, updateCustomerRemark } from '@/api/archieves/customer'
+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 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 { listAllService } from '@/api/service/list/index'
-import RegionCascader from '@/components/RegionCascader/index.vue'
 import { useRegionData } from '@/hooks/useRegionData'
-import PageSelect from '@/components/PageSelect/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 } = toRefs(
+  proxy?.useDict('sys_user_sex', 'sys_customer_status')
 )
 
 const loading = ref(false)
-const submitLoading = ref(false)
 const total = ref(0)
 
 const allNodes = ref([])
-// 废弃变量已清理,统一使用级联选择器展示全量树结构
 
 const loadAreaStation = () => {
   listAreaStation().then((res) => {
@@ -448,33 +197,22 @@ const searchForm = reactive({
   stationId: undefined
 })
 
-const dialogVisible = ref(false)
+const customerDialogVisible = ref(false)
 const drawerVisible = ref(false)
 const petDrawerVisible = ref(false)
 const remarkDialogVisible = ref(false)
 const petRemarkDialogVisible = ref(false)
 const petDialogVisible = ref(false)
-const isEdit = ref(false)
 const detailActiveTab = ref('info')
-const petDialogActiveTab = ref('basic')
 
-const selectedTagIds = ref([])
+const customerEditData = ref(null)
+const petEditData = ref(null)
 const currentUser = ref({})
 const currentPet = ref({})
 const currentPets = ref([])
 const tableData = ref([])
 
-const allUserTags = ref([])
-const allPetTags = ref([])
 const changeLogs = ref([])
-const userAvatarDisplayUrl = ref('')
-const petAvatarDisplayUrl = ref('')
-const petVaccineCertDisplayUrl = ref('')
-
-const brandList = ref([])
-const brandTotal = ref(0)
-const formAreaValue = ref([])
-const regionCascaderValue = ref([])
 
 const customerDetailRef = ref(null)
 const serviceList = ref([])
@@ -485,63 +223,6 @@ const getServiceList = () => {
   })
 }
 
-const form = reactive({
-  id: undefined,
-  name: '',
-  phone: '',
-  avatar: undefined,
-  gender: undefined,
-  birthday: '',
-  idCard: '',
-  areaId: undefined,
-  stationId: undefined,
-  regionCode: '',
-  region: [],
-  address: '',
-  houseType: '',
-  entryMethod: '',
-  entryPassword: '',
-  keyLocation: '',
-  source: '',
-  emergencyContact: '',
-  emergencyPhone: '',
-  memberLevel: 0,
-  status: 0,
-  remark: '',
-  tagIds: [],
-  tenantId: undefined
-})
-
-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 remarkForm = reactive({ content: '' })
 
 const areaTreeOptions = computed(() => {
@@ -550,8 +231,8 @@ const areaTreeOptions = computed(() => {
       .filter(item => String(item.parentId) === String(parentId))
       .map(item => {
         const children = buildTree(data, item.id)
-        const node = { 
-          id: item.id, 
+        const node = {
+          id: item.id,
           name: item.name,
           // 如果不是站点且没有下级了,则不可选(防止误选区域叶子点)
           disabled: Number(item.type) !== 2 && (!children || children.length === 0)
@@ -563,36 +244,6 @@ const areaTreeOptions = computed(() => {
   return buildTree(allNodes.value, 0)
 })
 
-const handleFormAreaChange = (value) => {
-  if (value && value.length > 0) {
-    const lastId = value[value.length - 1];
-    const node = allNodes.value.find(item => item.id === lastId);
-    form.stationId = lastId;
-    form.areaId = node ? node.parentId : undefined;
-  } else {
-    form.stationId = undefined;
-    form.areaId = 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
@@ -611,75 +262,42 @@ const handleSearch = () => {
   getList()
 }
 
-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)
-  })
-}
-
 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: '', source: '',
-    emergencyContact: '', emergencyPhone: '', memberLevel: 0, status: 0, remark: '', tagIds: [],
-    tenantId: undefined
-  })
-  userAvatarDisplayUrl.value = ''
-  formAreaValue.value = []
-  regionCascaderValue.value = []
-  dialogVisible.value = true
+  customerEditData.value = null
+  customerDialogVisible.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, source: data.source,
-      emergencyContact: data.emergencyContact, emergencyPhone: data.emergencyPhone,
-      memberLevel: data.memberLevel, status: data.status, remark: data.remark, tagIds: [],
-      tenantId: data.tenantId
-    })
-    userAvatarDisplayUrl.value = data.avatarUrl || ''
-    // Restore area cascader value path
-    if (data.stationId || data.areaId) {
-      const targetId = data.stationId || data.areaId;
-      const path = [];
-      let currentId = targetId;
-      while (currentId && String(currentId) !== '0') {
-        path.unshift(currentId);
-        const currentArea = allNodes.value.find((item) => String(item.id) === String(currentId));
-        if (currentArea) {
-          currentId = currentArea.parentId;
-        } else {
-          break;
-        }
-      }
-      formAreaValue.value = path;
-    } 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 = res.data
+    customerDialogVisible.value = true
   })
 }
 
+const onCustomerSaved = () => {
+  customerDialogVisible.value = false
+  getList()
+  if (drawerVisible.value) {
+    loadDetailLogs(currentUser.value.id, 'customer')
+  }
+}
+
+const openAddPet = () => {
+  petEditData.value = null
+  petDialogVisible.value = true
+}
+
+const handlePetEdit = (row) => {
+  petEditData.value = row
+  petDialogVisible.value = true
+}
+
+const onPetSaved = () => {
+  petDialogVisible.value = false
+  customerDetailRef.value?.refresh()
+  getList()
+}
+
 const handleDetail = (row) => {
   getCustomer(row.id).then((res) => {
     const data = res.data
@@ -755,32 +373,6 @@ const savePetRemark = () => {
   })
 }
 
-const saveUser = () => {
-  if (!form.tenantId) return ElMessage.warning('请选择所属品牌')
-  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 = ''
-  }
-  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(() => {
@@ -809,121 +401,11 @@ 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: currentUser.value.id, 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 = ''
-  petDialogVisible.value = true
-}
-
 const handlePetDetail = (row) => {
   currentPet.value = row
   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 || ''
-  petDialogVisible.value = true
-}
-
 const handlePetRemark = (row) => {
   currentPet.value = row
   remarkForm.content = ''
@@ -940,50 +422,28 @@ const handlePetDelete = (row) => {
   })
 }
 
-const savePet = () => {
-  if (!petForm.name) return ElMessage.warning('请输入宠物姓名');
-  if (!petForm.userId) 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;
-    customerDetailRef.value?.refresh();
-    getList();
-  }).finally(() => {
-    submitLoading.value = false;
-  });
-};
-
 onMounted(() => {
   getList()
-  loadTags()
   loadAreaStation()
   loadRegionData()
-  getBrandList()
   getServiceList()
 })
 </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;
@@ -992,22 +452,27 @@ 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;
@@ -1025,10 +490,12 @@ onMounted(() => {
   border-bottom: 1px dashed #eee;
   color: #303133;
 }
+
 .upload-avatar:hover {
   cursor: pointer;
   opacity: 0.8;
 }
+
 .pagination-container {
   margin-top: 20px;
   display: flex;
@@ -1042,6 +509,7 @@ onMounted(() => {
   overflow: hidden;
   display: inline-block;
 }
+
 .avatar-uploader-icon {
   font-size: 28px;
   color: #8c939d;
@@ -1055,10 +523,12 @@ onMounted(() => {
   align-items: center;
   transition: .2s;
 }
+
 .avatar-uploader-icon:hover {
   border-color: #409EFF;
   color: #409EFF;
 }
+
 .avatar {
   width: 100px;
   height: 100px;

+ 50 - 312
src/views/archieves/pet/index.vue

@@ -5,7 +5,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>
@@ -36,14 +37,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">{{
-              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="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">
@@ -53,185 +56,50 @@
         </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="宠物档案详情" 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-text="是" inactive-text="否" :active-value="1" :inactive-value="0" />
-            </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>
-        <span class="dialog-footer">
-          <el-button @click="dialogVisible = false">取消</el-button>
-          <el-button type="primary" :loading="submitLoading" @click="saveData">保存</el-button>
-        </span>
-      </template>
-    </el-dialog>
+    <AddPetDialog v-model:visible="dialogVisible" :user-options="userList" :pet-data="petEditData"
+      @success="onPetSaved" />
 
     <!-- Pet Profile Drawer -->
-    <PetDetailDrawer
-      v-model:visible="drawerVisible"
-      :pet-id="currentPet.id"
-      :service-list="serviceList"
-      editable
-      @remark-saved="getList"
-    />
+    <PetDetailDrawer v-model:visible="drawerVisible" :pet-id="currentPet.id" :service-list="serviceList" editable
+      @remark-saved="getList" />
 
   </div>
 </template>
 
 <script setup>
 import { ref, reactive, onMounted, getCurrentInstance, toRefs } from 'vue';
-import { globalHeaders } from '@/utils/request';
 import { ElMessage, ElMessageBox } from 'element-plus';
-import { listPet, getPet, addPet, updatePet, delPet } from '@/api/archieves/pet'
+import { listPet, getPet, delPet } from '@/api/archieves/pet'
 import { listAllTag } from '@/api/archieves/tag'
 import { listAllCustomer } from '@/api/archieves/customer'
 import { listAllService } from '@/api/service/list/index'
 import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
+import AddPetDialog from '@/components/AddPetDialog/index.vue'
 
 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([]);
 
@@ -245,14 +113,11 @@ const searchKey = ref('');
 
 const dialogVisible = ref(false);
 const drawerVisible = ref(false);
-const remarkDialogVisible = ref(false);
-const isEdit = ref(false);
-const activeTab = ref('basic');
-const detailActiveTab = ref('info');
 const currentPet = ref({});
 
 const allPetTags = ref([]);
 const userList = ref([]);
+const petEditData = ref(null);
 const serviceList = ref([]);
 
 const getServiceList = () => {
@@ -261,40 +126,6 @@ const getServiceList = () => {
   })
 }
 
-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;
   queryParams.keyword = searchKey.value;
@@ -324,46 +155,24 @@ 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) => {
   currentPet.value = row
-  detailActiveTab.value = 'info'
   drawerVisible.value = true
 }
 
@@ -373,7 +182,6 @@ const handleRemark = (row) => {
   // 由于备注功能已集成在详情抽屉中,直接打开抽屉即可,后期可以根据需要调整是否直接弹出备注对话框
 }
 
-
 const handleDelete = (row) => {
   ElMessageBox.confirm('确认删除该宠物档案吗?', '提示', { type: 'warning' }).then(() => {
     delPet(row.id).then(() => {
@@ -383,86 +191,6 @@ 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;
-    getList();
-  }).finally(() => {
-    submitLoading.value = false;
-  });
-};
-
 onMounted(() => {
   getList();
   loadTags();
@@ -475,14 +203,17 @@ onMounted(() => {
 .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;
@@ -495,9 +226,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;
@@ -505,18 +238,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;
@@ -525,6 +262,7 @@ onMounted(() => {
   padding-left: 10px;
   line-height: 1.2;
 }
+
 .pagination-container {
   margin-top: 20px;
   display: flex;

+ 24 - 1
src/views/order/orderList/components/OrderDetailDrawer.vue

@@ -243,10 +243,13 @@
                     <el-tab-pane label="服务进度" name="service">
                         <div class="tab-pane-content">
                             <!-- 导出流程图按钮:与订单日志导出Excel位置一致 -->
-                            <div style="display: flex; justify-content: flex-end; margin-bottom: 15px;">
+                            <div style="display: flex; justify-content: flex-end; margin-bottom: 15px; gap: 10px;">
                                 <el-button type="primary" size="small" icon="Picture"
                                     v-hasPermi="['order:orderList:queryExportExcel']"
                                     @click="handleExportProgressImage">导出流程图</el-button>
+                                <el-button type="success" size="small" icon="Link"
+                                    v-hasPermi="['order:orderList:queyGenerateFulfillPath']"
+                                    @click="handleGenerateFulfillPath">生成履约路径</el-button>
                             </div>
 
                             <div v-if="serviceProgressSteps.length === 0" class="empty-progress"
@@ -1016,6 +1019,26 @@ const downloadProcessImage = () => {
     link.download = `服务流程图_${orderNo}_${new Date().getTime()}.png`
     link.click()
 }
+
+/**
+ * 生成履约路径链接并复制到剪贴板
+ */
+const handleGenerateFulfillPath = () => {
+    const id = order.value?.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,15 +1,20 @@
 <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>
         <el-col :span="12">
           <el-form-item label="姓名" required><el-input v-model="form.name" placeholder="请输入姓名" /></el-form-item>
         </el-col>
@@ -19,18 +24,21 @@
         <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">
           <el-form-item label="所属站点" required>
-            <el-cascader v-model="formAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }" placeholder="请选择站点"
-              style="width: 100%" clearable @change="handleFormAreaChange" />
+            <el-cascader v-model="formAreaValue" :options="areaTreeOptions" :props="{ value: 'id', label: 'name' }"
+              placeholder="请选择站点" style="width: 100%" clearable @change="handleFormAreaChange" />
           </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" />
@@ -49,7 +57,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>
@@ -64,7 +73,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%">
@@ -82,7 +93,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>
@@ -103,7 +115,8 @@ const userStore = useUserStore()
 
 const props = defineProps({
   visible: { type: Boolean, default: false },
-  tenantId: { type: [String, Number], default: undefined }
+  tenantId: { type: [String, Number], default: undefined },
+  stationId: { type: [String, Number], default: undefined }
 })
 
 const emit = defineEmits(['update:visible', 'success'])
@@ -172,6 +185,14 @@ const resetForm = () => {
     emergencyContact: '', emergencyPhone: '', memberLevel: 0, status: 0, remark: '', tagIds: [],
     tenantId: props.tenantId || undefined
   })
+  // 根据门店站点自动选中所属站点
+  if (props.stationId && allNodes.value.length > 0) {
+    const path = getStationPath(props.stationId)
+    if (path.length > 0) {
+      formAreaValue.value = path
+      handleFormAreaChange(path)
+    }
+  }
 }
 
 const areaTreeOptions = computed(() => {
@@ -188,6 +209,21 @@ const areaTreeOptions = computed(() => {
   return buildTree(allNodes.value, 0)
 })
 
+// 根据站点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
+}
+
 // formStationList 已废弃
 
 
@@ -260,7 +296,7 @@ const saveUser = () => {
   } else {
     form.regionCode = ''
   }
-  
+
   addCustomerOnOrder(form).then(res => {
     emit('success', res.data)
     emit('update:visible', false)
@@ -276,7 +312,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>

+ 100 - 22
src/views/order/purchase/components/TransportForm.vue

@@ -15,7 +15,9 @@
           <div class="seg-badge start">接</div>
           <div class="seg-content">
             <el-row :gutter="10" align="middle" class="address-row" style="margin-bottom: 10px;">
-              <el-col :span="2"><div class="addr-label required">起点</div></el-col>
+              <el-col :span="2">
+                <div class="addr-label required">起点</div>
+              </el-col>
               <el-col :span="6">
                 <RegionCascader v-model="transportData.pickStartRegion" placeholder="省/市/区" />
               </el-col>
@@ -24,7 +26,9 @@
               </el-col>
             </el-row>
             <el-row :gutter="10" align="middle" class="address-row" style="margin-bottom: 20px;">
-              <el-col :span="2"><div class="addr-label required">终点</div></el-col>
+              <el-col :span="2">
+                <div class="addr-label required">终点</div>
+              </el-col>
               <el-col :span="6">
                 <RegionCascader v-model="transportData.pickEndRegion" placeholder="省/市/区" />
               </el-col>
@@ -38,7 +42,8 @@
             </el-row>
             <el-row :gutter="10" class="required-field">
               <el-col :span="24">
-                <el-date-picker v-model="transportData.pickTime" type="datetime" placeholder="选择接宠时间" style="width: 100%" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DD HH:mm" />
+                <el-date-picker v-model="transportData.pickTime" type="datetime" placeholder="选择接宠时间"
+                  style="width: 100%" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DD HH:mm" />
               </el-col>
             </el-row>
           </div>
@@ -53,7 +58,9 @@
           <div class="seg-badge end">送</div>
           <div class="seg-content">
             <el-row :gutter="10" align="middle" class="address-row" style="margin-bottom: 10px;">
-              <el-col :span="2"><div class="addr-label">起点</div></el-col>
+              <el-col :span="2">
+                <div class="addr-label">起点</div>
+              </el-col>
               <el-col :span="6">
                 <RegionCascader v-model="transportData.dropStartRegion" placeholder="省/市/区" />
               </el-col>
@@ -62,7 +69,9 @@
               </el-col>
             </el-row>
             <el-row :gutter="10" align="middle" class="address-row">
-              <el-col :span="2"><div class="addr-label">终点</div></el-col>
+              <el-col :span="2">
+                <div class="addr-label">终点</div>
+              </el-col>
               <el-col :span="6">
                 <RegionCascader v-model="transportData.dropEndRegion" placeholder="省/市/区" />
               </el-col>
@@ -76,7 +85,8 @@
             </el-row>
             <el-row :gutter="10" class="required-field">
               <el-col :span="24">
-                <el-date-picker v-model="transportData.dropTime" type="datetime" placeholder="预计送回时间" style="width: 100%" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DD HH:mm" />
+                <el-date-picker v-model="transportData.dropTime" type="datetime" placeholder="预计送回时间"
+                  style="width: 100%" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DD HH:mm" />
               </el-col>
             </el-row>
           </div>
@@ -102,20 +112,76 @@ const emit = defineEmits(['change'])
 </script>
 
 <style scoped>
-.business-form { padding-top: 5px; }
-.route-box { display: flex; flex-direction: column; gap: 20px; }
-.segment-card { background: #f9f9f9; padding: 20px; border-radius: 8px; border: 1px solid #EBEEF5; }
-.route-segment { display: flex; gap: 15px; }
+.business-form {
+  padding-top: 5px;
+}
+
+.route-box {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.segment-card {
+  background: #f9f9f9;
+  padding: 20px;
+  border-radius: 8px;
+  border: 1px solid #EBEEF5;
+}
+
+.route-segment {
+  display: flex;
+  gap: 15px;
+}
+
 .seg-badge {
-  width: 32px; height: 32px; background: #409eff; color: white; border-radius: 8px;
-  text-align: center; line-height: 32px; font-weight: bold; flex-shrink: 0;
+  width: 32px;
+  height: 32px;
+  background: #409eff;
+  color: white;
+  border-radius: 8px;
+  text-align: center;
+  line-height: 32px;
+  font-weight: bold;
+  flex-shrink: 0;
+}
+
+.seg-badge.end {
+  background: #67c23a;
+}
+
+.seg-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
 }
-.seg-badge.end { background: #67c23a; }
-.seg-content { flex: 1; display: flex; flex-direction: column; gap: 10px; }
 
-.route-connector { display: flex; align-items: center; justify-content: center; margin: 15px 0; gap: 10px; color: #909399; font-size: 12px; }
-.route-connector .line { height: 1px; width: 80px; background: #dcdfe6; }
-.route-connector .store-node { background: white; padding: 4px 12px; border-radius: 20px; border: 1px solid #dcdfe6; display: flex; align-items: center; gap: 5px; }
+.route-connector {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin: 15px 0;
+  gap: 10px;
+  color: #909399;
+  font-size: 12px;
+}
+
+.route-connector .line {
+  height: 1px;
+  width: 80px;
+  background: #dcdfe6;
+}
+
+.route-connector .store-node {
+  background: white;
+  padding: 4px 12px;
+  border-radius: 20px;
+  border: 1px solid #dcdfe6;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
 
 .addr-label {
   text-align: right;
@@ -123,10 +189,22 @@ const emit = defineEmits(['change'])
   font-size: 14px;
   padding-right: 5px;
 }
-.addr-label.required::before { content: '*'; color: #f56c6c; margin-right: 4px; }
-.required-field { margin-bottom: 10px; }
-.required-field :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #f56c6c inset; }
-.required-field :deep(.el-input__wrapper):hover { box-shadow: 0 0 0 1px #f56c6c inset !important; }
 
-.remark-section { background: #fdfdfd; border: 1px dashed #dcdfe6; padding: 15px; border-radius: 6px; margin-top: 20px; }
+.addr-label.required::before {
+  content: '*';
+  color: #f56c6c;
+  margin-right: 4px;
+}
+
+.required-field {
+  margin-bottom: 10px;
+}
+
+.remark-section {
+  background: #fdfdfd;
+  border: 1px dashed #dcdfe6;
+  padding: 15px;
+  border-radius: 6px;
+  margin-top: 20px;
+}
 </style>

+ 311 - 4
src/views/order/purchase/index.vue

@@ -1,5 +1,10 @@
 <template>
   <div class="page-container">
+    <div class="page-header">
+      <el-button type="warning" size="default" icon="Upload" @click="openImportDialog" style="margin-left: auto;">
+        导入微信订单
+      </el-button>
+    </div>
     <div class="create-layout">
 
       <!-- 左侧:下单填写区 -->
@@ -196,11 +201,47 @@
 
     </div>
 
+    <!-- Import WeChat Order Dialog -->
+    <el-dialog v-model="importDialogVisible" title="导入微信订单" width="600px" :close-on-click-modal="false"
+      destroy-on-close>
+      <el-alert type="info" :closable="false" show-icon style="margin-bottom: 15px;">
+        <template #title>
+          请将微信订单消息复制到下方文本框<br /><br />
+          <b>【接送单模板】</b><br />
+          门店:zj测试门店<br />
+          宠物用户:13888888889<br />
+          宠物:张晓蔷的狗<br />
+          选择服务:宠物接送<br />
+          接送类型:接送<br />
+          时间:接[2026年5月28日18点],送[2026年5月28日18点]<br />
+          备注:测试微信订单<br />
+          团购套餐:测试套餐<br /><br />
+          <b>【服务单模板】</b><br />
+          门店:zj测试门店<br />
+          宠物用户:13888888889<br />
+          宠物:张晓蔷的狗<br />
+          选择服务:上门洗护<br />
+          时间:2026年5月28日18点、2026年5月28日19点<br />
+          备注:测试微信订单<br />
+          团购套餐:测试套餐
+        </template>
+      </el-alert>
+      <el-input v-model="importText" type="textarea" :rows="10" placeholder="请粘贴微信订单文本..."
+        style="margin-bottom: 15px;" />
+      <template #footer>
+        <el-button @click="importDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="importLoading" @click="handleImportWechatOrder">
+          解析并填充
+        </el-button>
+      </template>
+    </el-dialog>
+
     <!-- Dialogs -->
     <!-- Add User Dialog -->
-    <AddUserDialog v-model:visible="userDialogVisible" :tenant-id="currentTenantId" @success="handleUserSuccess" />
+    <AddCustomerDialog v-model:visible="userDialogVisible" :tenant-id="currentTenantId" :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>
@@ -211,8 +252,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'
@@ -465,6 +506,261 @@ const handleUserSuccess = (newUser) => {
 // Add Pet Logic
 const petDialogVisible = ref(false)
 const openAddPet = () => { petDialogVisible.value = true }
+
+// --- Import WeChat Order ---
+const importDialogVisible = ref(false)
+const importText = ref('')
+const importLoading = ref(false)
+
+const openImportDialog = () => {
+  importText.value = ''
+  importDialogVisible.value = true
+}
+
+const parseWechatOrderText = (text) => {
+  const result = {}
+  const lines = text.split('\n').map(l => l.trim()).filter(l => l)
+
+  for (const line of lines) {
+    const match = line.match(/^(.+?)[::]\s*(.*)/)
+    if (!match) continue
+    const key = match[1].trim()
+    const value = match[2].trim()
+    if (key.includes('门店')) result.storeName = value
+    else if (key.includes('宠物用户')) result.customerPhone = value
+    else if (key.includes('宠物')) result.petName = value
+    else if (key.includes('服务') || key.includes('选择服务')) result.serviceName = value
+    else if (key.includes('接送类型')) result.transportType = value
+    else if (key.includes('时间')) result.serviceTime = value
+    else if (key.includes('团购套餐')) result.groupBuyPackage = value
+    else if (key.includes('备注')) result.remark = value
+  }
+
+  return result
+}
+
+/**
+ * 解析时间字符串为 yyyy-MM-dd HH:mm:ss 格式
+ */
+const resolveTimeString = (timeStr) => {
+  if (!timeStr) return ''
+  const match = timeStr.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日\s*(\d{1,2})\s*点/)
+  if (match) {
+    return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')} ${match[4].padStart(2, '0')}:00`
+  }
+  return timeStr
+}
+
+/**
+ * 解析接送单时间行
+ * 单程: "2026年5月28日18点" → { pickTime }
+ * 双程: "接[2026年5月28日18点],送[2026年5月28日18点]" → { pickTime, dropTime }
+ */
+const parseTransportTime = (timeStr) => {
+  const result = { pickTime: '', dropTime: '' }
+  const roundMatch = timeStr.match(/接\s*[\[【]\s*(.*?)\s*[\]】]\s*[,,]\s*送\s*[\[【]\s*(.*?)\s*[\]】]/)
+  if (roundMatch) {
+    result.pickTime = resolveTimeString(roundMatch[1])
+    result.dropTime = resolveTimeString(roundMatch[2])
+  } else {
+    result.pickTime = resolveTimeString(timeStr)
+  }
+  return result
+}
+
+/**
+ * 解析服务单时间行
+ * "2026年5月28日18点、2026年5月28日19点" → ['2026-05-28 18:00:00', '2026-05-28 19:00:00']
+ */
+const parseServiceTimes = (timeStr) => {
+  if (!timeStr) return []
+  return timeStr.split(/[,,、]/).map(t => resolveTimeString(t.trim())).filter(Boolean)
+}
+
+const handleImportWechatOrder = async () => {
+  if (!importText.value.trim()) {
+    ElMessage.warning('请粘贴微信订单文本')
+    return
+  }
+
+  const parsed = parseWechatOrderText(importText.value)
+  if (!parsed.storeName) {
+    ElMessage.warning('未能解析到门店名称,请检查文本格式')
+    return
+  }
+  if (!parsed.customerPhone) {
+    ElMessage.warning('未能解析到宠物用户手机号,请检查文本格式')
+    return
+  }
+  if (!parsed.petName) {
+    ElMessage.warning('未能解析到宠物名,请检查文本格式')
+    return
+  }
+  if (!parsed.serviceName) {
+    ElMessage.warning('未能解析到服务名,请检查文本格式')
+    return
+  }
+
+  importLoading.value = true
+  try {
+    // ① 查询门店
+    const storeRes = await listStoreOnOrder({ name: parsed.storeName, pageNum: 1, pageSize: 10 })
+    const storeList = storeRes?.data?.rows || storeRes?.rows || []
+    if (storeList.length === 0) {
+      ElMessage.warning(`未找到门店:"${parsed.storeName}"`)
+      return
+    }
+    const store = storeList[0]
+
+    // 刷新门店列表到 stores 中
+    stores.value = storeList
+    storeTotal.value = storeRes?.data?.total || storeRes?.total || 1
+
+    // ② 设置门店 → 触发 handleStoreChange 清空下游
+    form.merchantId = store.id
+    form.userId = ''
+    form.petId = ''
+    form.serviceId = ''
+    form.type = ''
+    form.mode = undefined
+    currentPets.value = []
+
+    // ③ 查询宠物用户(需要 tenantId + stationId)
+    const customerRes = await listCustomerOnOrder({
+      content: parsed.customerPhone,
+      tenantId: store.tenantId,
+      stationId: store.site
+    })
+    const customerList = customerRes?.data?.rows || customerRes?.rows || []
+    if (customerList.length === 0) {
+      ElMessage.warning(`门店"${parsed.storeName}"下未找到手机号:"${parsed.customerPhone}"对应客户`)
+      return
+    }
+    const customer = customerList[0]
+
+    // 填充用户选项
+    userOptions.value = customerList.map(c => ({
+      id: c.id, name: c.name,
+      phoneNumber: c.phoneNumber || c.phone,
+      regionCode: c.regionCode, address: c.address
+    }))
+    userTotal.value = customerRes?.data?.total || customerRes?.total || customerList.length
+
+    form.userId = customer.id
+
+    // ④ 查询宠物
+    const petRes = await listPetByUser(customer.id)
+    const petList = petRes?.data || petRes?.rows || []
+    if (petList.length === 0) {
+      ElMessage.warning(`用户"${customer.name}"名下没有宠物`)
+      return
+    }
+    const matchedPet = petList.find(p => p.name === parsed.petName)
+    if (!matchedPet) {
+      ElMessage.warning(`未找到宠物"${parsed.petName}",用户"${customer.name}"名下有:${petList.map(p => p.name).join('、')}`)
+      return
+    }
+    currentPets.value = petList
+    form.petId = matchedPet.id
+
+    // ⑤ 匹配服务
+    const matchedService = allServices.value.find(s => s.name === parsed.serviceName)
+    if (!matchedService) {
+      const available = store.services
+        ? allServices.value.filter(s => store.services.includes(s.id)).map(s => s.name)
+        : allServices.value.map(s => s.name)
+      ElMessage.warning(`未找到服务"${parsed.serviceName}",该门店可选服务:${available.join('、')}`)
+      return
+    }
+
+    if (store.services && !store.services.includes(matchedService.id)) {
+      ElMessage.warning(`服务"${parsed.serviceName}"在该门店不可用`)
+      return
+    }
+
+    handleServiceChange(matchedService)
+
+    const isTransportOrder = form.type === 'transport'
+
+    // ⑥ 显式填充地址、联系人(不依赖 watcher,避免异步时序导致红框)
+    const storeRegion = store?.areaCode ? store.areaCode.split(',') : []
+    const storeAddr = store?.address || ''
+    const userRegion = customer?.regionCode ? customer.regionCode.split('/') : []
+    const userAddr = customer?.address || ''
+    const contactName = customer.name || ''
+    const contactPhone = customer.phoneNumber || customer.phone || ''
+
+    if (isTransportOrder) {
+      form.transport.pickStartRegion = userRegion
+      form.transport.pickStartDetail = userAddr
+      form.transport.pickEndRegion = storeRegion
+      form.transport.pickEndDetail = storeAddr
+      form.transport.pickContact = contactName
+      form.transport.pickPhone = contactPhone
+      form.transport.dropStartRegion = storeRegion
+      form.transport.dropStartDetail = storeAddr
+      form.transport.dropEndRegion = userRegion
+      form.transport.dropEndDetail = userAddr
+      form.transport.dropContact = contactName
+      form.transport.dropPhone = contactPhone
+    } else {
+      form[form.type].region = userRegion
+      form[form.type].addressDetail = userAddr
+    }
+
+    // ⑦ 接送类型(仅接送单)
+    if (isTransportOrder && parsed.transportType) {
+      const typeMap = { '接送': 'round', '接': 'pick', '送': 'drop' }
+      const subType = typeMap[parsed.transportType]
+      if (subType) {
+        form.transport.subType = subType
+      }
+    }
+
+    // ⑧ 设置时间
+    if (parsed.serviceTime) {
+      const data = form[form.type]
+      if (data) {
+        if (isTransportOrder) {
+          const times = parseTransportTime(parsed.serviceTime)
+          if (times.pickTime) {
+            data.pickTime = times.pickTime
+          }
+          if (times.dropTime) {
+            data.dropTime = times.dropTime
+          }
+        } else {
+          const times = parseServiceTimes(parsed.serviceTime)
+          if (times.length > 0) {
+            data.appointments = times.map(t => ({ startTime: t, endTime: t }))
+          }
+        }
+      }
+    }
+
+    // ⑨ 团购套餐
+    if (parsed.groupBuyPackage) {
+      form.groupBuyPackage = parsed.groupBuyPackage
+    }
+
+    // ⑩ 备注
+    if (parsed.remark) {
+      const data = form[form.type]
+      if (data) {
+        data.other = parsed.remark
+      }
+    }
+
+    importDialogVisible.value = false
+    ElMessage.success('微信订单解析成功,已填充至表单,请核对后下单')
+  } catch (err) {
+    console.error('导入微信订单失败:', err)
+    ElMessage.error(err?.msg || err?.message || '导入失败,请稍后重试')
+  } finally {
+    importLoading.value = false
+  }
+}
+// --- End Import WeChat Order ---
 const handlePetSuccess = (newPet) => {
   if (form.userId) {
     listPetByUser(form.userId).then(res => {
@@ -521,6 +817,11 @@ const currentTenantId = computed(() => {
   return store ? store.tenantId : undefined
 })
 
+const currentStationId = computed(() => {
+  const store = stores.value.find(s => s.id === form.merchantId)
+  return store ? store.site : undefined
+})
+
 // --- Methods ---
 const fetchUsers = () => {
   if (!form.merchantId) return
@@ -785,6 +1086,12 @@ onMounted(() => {
   min-height: 100vh;
 }
 
+.page-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
 .create-layout {
   display: flex;
   align-items: flex-start;

+ 3 - 2
vite.config.ts

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