Huanyi преди 1 ден
родител
ревизия
98025138ce

+ 1 - 0
src/api/archieves/customer/types.ts

@@ -64,6 +64,7 @@ export interface CustomerOnOrderVO {
 export interface CustomerOnOrderQuery extends PageQuery {
   content?: string;
   tenantId?: string | number;
+  stationId?: number;
 }
 
 /**

+ 2 - 0
src/api/system/store/types.ts

@@ -257,6 +257,8 @@ export interface StoreOrderVO {
   id: number;
   name: string;
   services: number[];
+  site: number;
+  tenantId: string;
 }
 
 export interface StoreDispatchVO {

+ 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>

+ 9 - 3
src/components/PageSelect/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="page-select">
-    <el-select v-bind="{ ...$attrs, value: undefined }" v-model="localValue" :filterable="true" :remote="false"
-      @visible-change="handleVisibleChange" @change="handleInput">
+    <el-select v-bind="{ ...$attrs, value: undefined }" v-model="localValue" :filterable="true" :remote="true"
+      :remote-method="onFilterMethod" @visible-change="handleVisibleChange" @change="handleInput">
       <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
       <!-- 分页组件 -->
       <div v-if="total > 0" class="brand-pagination">
@@ -48,7 +48,7 @@ const props = defineProps({
   }
 });
 
-const emit = defineEmits(['update:modelValue', 'page-change', 'visible-change']);
+const emit = defineEmits(['update:modelValue', 'page-change', 'visible-change', 'filter-method']);
 
 const localValue = ref(props.modelValue);
 const currentPage = ref(1);
@@ -107,6 +107,12 @@ const handlePageChange = (page: number) => {
   currentPage.value = page;
   emit('page-change', page);
 };
+
+// 处理筛选输入变化,重置到第1页
+const onFilterMethod = (query: string) => {
+  currentPage.value = 1;
+  emit('filter-method', query);
+};
 </script>
 
 <style scoped>

+ 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"
     }
   }
 }

+ 1 - 1
src/permission.ts

@@ -11,7 +11,7 @@ import { usePermissionStore } from '@/store/modules/permission';
 import { ElMessage } from 'element-plus/es';
 
 NProgress.configure({ showSpinner: false });
-const whiteList = ['/login', '/register', '/social-callback', '/register*', '/register/*'];
+const whiteList = ['/login', '/register', '/social-callback', '/register*', '/register/*', '/fulfillPath*'];
 
 const isWhiteList = (path: string) => {
   return whiteList.some((pattern) => isPathMatch(pattern, path));

+ 5 - 0
src/router/index.ts

@@ -62,6 +62,11 @@ export const constantRoutes: RouteRecordRaw[] = [
     component: () => import('@/views/error/401.vue'),
     hidden: true
   },
+  {
+    path: '/fulfillPath',
+    component: () => import('@/views/fulfillPath/index.vue'),
+    hidden: true
+  },
   {
     path: '',
     component: Layout,

+ 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;

+ 383 - 0
src/views/fulfillPath/index.vue

@@ -0,0 +1,383 @@
+<template>
+    <div class="fulfill-path-page">
+        <div class="page-header">
+            <div class="header-content">
+                <div class="header-title">服务进度</div>
+            </div>
+            <div class="order-info" v-if="orderCode">
+                <span class="order-no-label">订单号</span>
+                <span class="order-no-value">{{ orderCode }}</span>
+            </div>
+        </div>
+
+        <div class="page-body" v-loading="loading">
+            <div v-if="!loading && progressSteps.length === 0" class="empty-state">
+                <div class="empty-icon">
+                    <el-icon :size="48" color="#c0c4cc">
+                        <Clock />
+                    </el-icon>
+                </div>
+                <div class="empty-title">暂无服务进度</div>
+                <div class="empty-desc">履约者接单后将在此记录服务进度</div>
+            </div>
+
+            <div class="timeline-wrapper" v-else>
+                <div class="timeline">
+                    <div v-for="(step, index) in progressSteps" :key="index" class="timeline-item"
+                        :class="{ 'is-last': index === progressSteps.length - 1 }">
+                        <div class="timeline-dot-wrapper">
+                            <div class="timeline-dot" :style="{ borderColor: step.color }">
+                                <el-icon v-if="step.done" :size="14" :color="step.color">
+                                    <CircleCheck />
+                                </el-icon>
+                                <span v-else class="dot-inner" :style="{ background: step.color }"></span>
+                            </div>
+                            <div v-if="index < progressSteps.length - 1" class="timeline-line"></div>
+                        </div>
+
+                        <div class="timeline-content">
+                            <div class="step-time">{{ step.time }}</div>
+                            <div class="step-card">
+                                <h4 class="step-title">{{ step.title }}</h4>
+                                <p class="step-desc" v-if="step.desc">{{ step.desc }}</p>
+                                <div class="step-media" v-if="step.media && step.media.length">
+                                    <div v-for="(item, i) in step.media" :key="i" class="media-item">
+                                        <el-image v-if="item.type === 'image'" :src="item.url"
+                                            :preview-src-list="step.media.filter(m => m.type === 'image').map(m => m.url)"
+                                            fit="cover" class="media-img" :preview-teleported="true" />
+                                        <div v-else-if="item.type === 'video'" class="media-video"
+                                            @click="openVideo(item.url)">
+                                            <video :src="item.url" preload="metadata" class="media-img"></video>
+                                            <div class="video-play-icon">
+                                                <el-icon :size="28" color="#fff">
+                                                    <VideoPlay />
+                                                </el-icon>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <el-dialog v-model="videoVisible" title="视频播放" :close-on-click-modal="false" append-to-body
+            @closed="videoUrl = ''">
+            <div class="video-preview-box">
+                <video v-if="videoUrl" :src="videoUrl" controls autoplay style="width: 100%; max-height: 60vh;"></video>
+            </div>
+        </el-dialog>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { Clock, CircleCheck, VideoPlay } from '@element-plus/icons-vue';
+import { listSubOrderLog } from '@/api/order/subOrderLog/index';
+import { getSubOrderInfo } from '@/api/order/subOrder/index';
+
+const route = useRoute();
+
+const loading = ref(false);
+const orderCode = ref('');
+const videoVisible = ref(false);
+const videoUrl = ref('');
+
+interface MediaItem {
+    type: 'image' | 'video';
+    url: string;
+}
+
+interface ProgressStep {
+    title: string;
+    time: string;
+    desc: string;
+    color: string;
+    done: boolean;
+    media: MediaItem[];
+}
+
+const progressSteps = ref<ProgressStep[]>([]);
+
+const videoExts = ['.mp4', '.mov', '.avi', '.wmv', '.webm', '.ogg'];
+
+const isVideo = (url: string): boolean => {
+    if (!url) return false;
+    return videoExts.some(ext => String(url).toLowerCase().trim().endsWith(ext));
+};
+
+const openVideo = (url: string) => {
+    videoUrl.value = url;
+    videoVisible.value = true;
+};
+
+const fetchProgress = async (id: string) => {
+    loading.value = true;
+    try {
+        const [logRes, orderRes] = await Promise.all([
+            listSubOrderLog({ orderId: id }),
+            getSubOrderInfo(id)
+        ]);
+
+        const list = logRes?.data?.data || logRes?.data || [];
+        const arr = Array.isArray(list) ? list : [];
+        const fLogs = arr.filter((i: any) => Number(i?.logType) === 1);
+
+        progressSteps.value = fLogs.map((i: any) => {
+            const urls = i?.photoUrls || [];
+            const media: MediaItem[] = Array.isArray(urls)
+                ? urls.map((u: string) => ({ type: isVideo(u) ? 'video' : 'image', url: u }))
+                : [];
+            const isCompleted = i.step === 4 || i.step === 99;
+            return {
+                title: i?.title || '--',
+                time: i?.createTime || i?.time || '',
+                desc: i?.content || '',
+                color: isCompleted ? '#67c23a' : '#ff9900',
+                done: isCompleted,
+                media
+            };
+        });
+
+        if (orderRes?.data) {
+            orderCode.value = orderRes.data.code || '';
+        }
+    } catch {
+        progressSteps.value = [];
+    } finally {
+        loading.value = false;
+    }
+};
+
+watch(() => route.query.orderId, (val) => {
+    if (val) {
+        fetchProgress(String(val));
+    }
+}, { immediate: true });
+
+onMounted(() => {
+    const id = route.query.orderId;
+    if (id) {
+        fetchProgress(String(id));
+    }
+});
+</script>
+
+<style scoped>
+.fulfill-path-page {
+    min-height: 100vh;
+    background: #f5f7fa;
+    font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
+}
+
+.page-header {
+    position: sticky;
+    top: 0;
+    z-index: 100;
+    background: #fff;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+}
+
+.header-content {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 12px 16px;
+}
+
+.header-title {
+    font-size: 17px;
+    font-weight: 600;
+    color: #303133;
+}
+
+.order-info {
+    padding: 10px 16px 14px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.order-no-label {
+    font-size: 13px;
+    color: #909399;
+}
+
+.order-no-value {
+    font-size: 15px;
+    font-weight: 500;
+    color: #303133;
+    word-break: break-all;
+}
+
+.page-body {
+    padding: 16px;
+    min-height: calc(100vh - 120px);
+}
+
+.empty-state {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 60px 20px;
+    text-align: center;
+}
+
+.empty-icon {
+    margin-bottom: 16px;
+}
+
+.empty-title {
+    font-size: 16px;
+    font-weight: 500;
+    color: #909399;
+    margin-bottom: 8px;
+}
+
+.empty-desc {
+    font-size: 14px;
+    color: #c0c4cc;
+}
+
+.timeline-wrapper {
+    padding: 4px 0;
+}
+
+.timeline {
+    padding-left: 0;
+}
+
+.timeline-item {
+    display: flex;
+    position: relative;
+}
+
+.timeline-dot-wrapper {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    width: 32px;
+    flex-shrink: 0;
+}
+
+.timeline-dot {
+    width: 24px;
+    height: 24px;
+    border-radius: 50%;
+    border: 3px solid #ff9900;
+    background: #fff;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    z-index: 1;
+}
+
+.dot-inner {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+}
+
+.timeline-line {
+    width: 2px;
+    flex: 1;
+    min-height: 24px;
+    background: #e4e7ed;
+    margin: 4px 0;
+}
+
+.timeline-content {
+    flex: 1;
+    padding-left: 14px;
+    padding-bottom: 28px;
+    min-width: 0;
+}
+
+.step-time {
+    font-size: 13px;
+    color: #909399;
+    margin-bottom: 10px;
+}
+
+.step-card {
+    background: #fff;
+    border-radius: 10px;
+    padding: 16px;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
+}
+
+.step-title {
+    margin: 0 0 10px;
+    font-size: 16px;
+    font-weight: 600;
+    color: #303133;
+    line-height: 1.4;
+}
+
+.step-desc {
+    margin: 0 0 14px;
+    font-size: 14px;
+    color: #606266;
+    line-height: 1.7;
+    word-break: break-word;
+}
+
+.step-media {
+    display: flex;
+    gap: 8px;
+    flex-wrap: wrap;
+}
+
+.media-item {
+    flex-shrink: 0;
+}
+
+.media-img {
+    width: 100px;
+    height: 100px;
+    border-radius: 6px;
+    object-fit: cover;
+    display: block;
+    border: 1px solid #ebeef5;
+}
+
+.media-video {
+    position: relative;
+    width: 100px;
+    height: 100px;
+    border-radius: 6px;
+    overflow: hidden;
+    cursor: pointer;
+}
+
+.media-video video {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+}
+
+.video-play-icon {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(0, 0, 0, 0.3);
+}
+
+.video-preview-box {
+    display: flex;
+    justify-content: center;
+    background: #000;
+    border-radius: 4px;
+    overflow: hidden;
+}
+</style>

+ 22 - 0
src/views/fulfiller/pool/index.vue

@@ -96,6 +96,15 @@
           </template>
         </el-table-column>
 
+        <el-table-column label="服务类型" width="150">
+          <template #default="scope">
+            <el-tag v-for="st in getServiceTypeList(scope.row.serviceTypes)" :key="st" size="small" type="warning"
+              effect="light" style="margin-right:4px;margin-bottom:4px;">
+              {{ st }}
+            </el-tag>
+          </template>
+        </el-table-column>
+
         <el-table-column label="技能标签" min-width="180">
           <template #default="scope">
             <el-tag v-for="tag in scope.row.tags" :key="tag.id" :type="tag.colorType" size="small" class="skill-tag"
@@ -201,6 +210,12 @@
               <el-tag size="small" type="warning" effect="plain" v-if="currentItem.workType === 'full_time'"
                 style="margin-left:5px">全职专送</el-tag>
             </div>
+            <div class="tags-row" style="margin-top:8px;">
+              <el-tag v-for="st in getServiceTypeList(currentItem.serviceTypes)" :key="st" size="small" type="warning"
+                effect="light" style="margin-right:6px;">
+                {{ st }}
+              </el-tag>
+            </div>
           </div>
         </div>
 
@@ -909,6 +924,13 @@ const getServiceName = (serviceId: number | string) => {
   return item ? item.name : '未知服务'
 }
 
+/** 获取服务类型名称列表(从逗号分隔字符串解析) @Author: Antigravity */
+const getServiceTypeList = (serviceTypes: string | undefined): string[] => {
+  if (!serviceTypes) return []
+  const ids = serviceTypes.split(',').filter(Boolean)
+  return ids.map(id => getServiceName(id))
+}
+
 const getSubOrderStatusName = (status: number) => {
   const map: Record<number, string> = { 0: '待派单', 1: '待接单', 2: '待服务', 3: '服务中', 4: '已完成', 5: '已取消' }
   return map[status] || '未知'

+ 55 - 58
src/views/order/orderList/components/OrderDetailDrawer.vue

@@ -98,11 +98,11 @@
                         </div>
                         <el-descriptions :column="2" size="small" class="pet-desc" border>
                             <el-descriptions-item label="宠物品种">{{ order.petBreed || '-'
-                                }}</el-descriptions-item>
+                            }}</el-descriptions-item>
                             <el-descriptions-item label="疫苗状态"><span style="color:#67c23a">{{ order.petVaccine || '-'
-                                    }}</span></el-descriptions-item>
+                            }}</span></el-descriptions-item>
                             <el-descriptions-item label="性格特点">{{ order.petPersonality || order.petCharacter || '-'
-                                }}</el-descriptions-item>
+                            }}</el-descriptions-item>
                             <el-descriptions-item label="健康状况">{{ order.petHealth || '-' }}</el-descriptions-item>
                         </el-descriptions>
                     </div>
@@ -115,7 +115,7 @@
                         <div class="user-content">
                             <div class="u-row">
                                 <el-avatar :size="40" :src="order.userAvatar">{{ (order.userName || '').charAt(0)
-                                    }}</el-avatar>
+                                }}</el-avatar>
                                 <div class="u-info">
                                     <div class="nm">{{ order.userName }}</div>
                                     <div class="ph">{{ order.contactPhone }}</div>
@@ -148,19 +148,19 @@
                                     <el-descriptions-item label="归属门店">{{ order.merchantName }}
                                         ({{ Number(order.platformId) === 1 ? '门店下单' : '平台代下单' }})</el-descriptions-item>
                                     <el-descriptions-item label="宠主信息">{{ order.userName }} / {{ order.contactPhone
-                                        }}</el-descriptions-item>
+                                    }}</el-descriptions-item>
                                     <el-descriptions-item label="履约佣金" label-class-name="money-label">
                                         <span style="color:#f56c6c; font-weight:bold;">¥ {{ order.fulfillerFee }}</span>
                                     </el-descriptions-item>
 
                                     <el-descriptions-item label="预约时间">{{ getServiceTimeRange(order.serviceTime)
-                                        }}</el-descriptions-item>
+                                    }}</el-descriptions-item>
                                     <el-descriptions-item label="团购套餐">{{ order.groupBuyPackage || '-'
-                                        }}</el-descriptions-item>
+                                    }}</el-descriptions-item>
                                     <el-descriptions-item label="创建时间">{{ order.createTime }}</el-descriptions-item>
                                     <el-descriptions-item label="订单佣金" label-class-name="money-label">
                                         <span style="color:#f56c6c; font-weight:bold;">¥ {{ order.orderCommission || 0
-                                            }}</span>
+                                        }}</span>
                                     </el-descriptions-item>
 
                                     <el-descriptions-item label="订单备注" :span="3">
@@ -180,7 +180,7 @@
                                     <div class="t-row">
                                         <span class="t-k">起点</span>
                                         <span class="t-v">{{ order.detail?.fromAddress || order.detail?.pickAddr || '-'
-                                        }}</span>
+                                            }}</span>
                                     </div>
                                     <div class="t-row">
                                         <span class="t-k">终点</span>
@@ -200,7 +200,7 @@
                                 <div class="sec-title-bar">服务执行要求</div>
                                 <el-descriptions :column="2" border size="default" class="custom-desc">
                                     <el-descriptions-item label="服务地址" :span="2">{{ order.detail.area || order.address
-                                        }}</el-descriptions-item>
+                                    }}</el-descriptions-item>
                                 </el-descriptions>
                             </div>
                         </div>
@@ -212,7 +212,7 @@
                             <div v-if="order.fulfillerName" class="fulfiller-card">
                                 <div class="f-left">
                                     <el-avatar :size="60" :src="order.fulfillerAvatar">{{ order.fulfillerName.charAt(0)
-                                        }}</el-avatar>
+                                    }}</el-avatar>
                                 </div>
                                 <div class="f-right">
                                     <div class="f-row1">
@@ -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"
@@ -437,13 +440,13 @@
                         <!-- 时间 -->
                         <div style="font-size: 14px; color: #909399; margin-bottom: 10px; font-weight: 500;">{{
                             step.time
-                            }}</div>
+                        }}</div>
                         <!-- 进度卡片 -->
                         <div
                             style="background: #f8fcfb; border-radius: 8px; padding: 20px; border: 1px solid #ebeef5; width: calc(100% - 10px); box-sizing: border-box;">
                             <h4 style="margin: 0 0 12px; font-size: 17px; font-weight: bold; color: #303133;">{{
                                 step.title
-                                }}</h4>
+                            }}</h4>
                             <p
                                 style="margin: 0 0 18px; color: #606266; font-size: 14px; line-height: 1.7; text-align: justify;">
                                 {{ step.desc }}</p>
@@ -492,7 +495,6 @@ import { getFulfiller } from '@/api/fulfiller/fulfiller/index'
 import { listSubOrderLog, exportSubOrderLogUrl } from '@/api/order/subOrderLog/index'
 import { listComplaintByOrder } from '@/api/fulfiller/complaint'
 import { listSubOrderAppealByOrderId } from '@/api/order/subOrderAppeal'
-import { listByIds, downloadOssBlob } from '@/api/system/oss'
 import ImagePreview from '@/components/ImagePreview/index.vue'
 
 const { proxy } = getCurrentInstance()
@@ -551,24 +553,7 @@ const loadOrderLogs = async (order) => {
         const list = res?.data?.data || res?.data || []
         const arr = Array.isArray(list) ? list : []
         orderLogs.value = arr.filter(i => Number(i?.logType) === 0)
-
-        // 对履约者日志中含有 photos(ossId 串)的条目,调用 listByIds 解析出可访问的 OSS URL
-        const fLogs = arr.filter(i => Number(i?.logType) === 1)
-        await Promise.all(fLogs.map(async (item) => {
-            const photoIds = item?.photos
-            if (photoIds) {
-                try {
-                    const ossRes = await listByIds(photoIds)
-                    const ossList = ossRes?.data || []
-                    item._resolvedUrls = ossList.map(o => ({ type: isVideo(o.url) ? 'video' : 'image', url: o.url, ossId: o.ossId }))
-                } catch {
-                    item._resolvedUrls = []
-                }
-            } else {
-                item._resolvedUrls = []
-            }
-        }))
-        fulfillerLogs.value = fLogs
+        fulfillerLogs.value = arr.filter(i => Number(i?.logType) === 1)
     } catch {
         orderLogs.value = []
         fulfillerLogs.value = []
@@ -827,8 +812,10 @@ const acceptTime = computed(() => orderLogs.value.find(l => l.title === '接单
 const serviceProgressSteps = computed(() => {
     const list = fulfillerLogs.value || []
     return list.map((i) => {
-        // 使用 _resolvedUrls(通过 listByIds 解析后的 OSS 可访问 URL),而非直接使用 photoUrls
-        const media = Array.isArray(i?._resolvedUrls) ? i._resolvedUrls : []
+        const urls = i?.photoUrls || []
+        const media = Array.isArray(urls)
+            ? urls.map(url => ({ type: isVideo(url) ? 'video' : 'image', url }))
+            : []
 
         return {
             title: i?.title || '--',
@@ -872,28 +859,20 @@ const captureTime = ref('')
 const captureBase64Cache = ref({})
 
 /**
- * 将图片/OSS对象转换为 base64 DataURL @Author: Antigravity
- * 优先采用后端代理下载以解决跨域问题,如果无 ossId 则尝试直接 fetch
+ * 将图片URL转换为 base64 DataURL @Author: Antigravity
  */
-const loadImageAsBase64 = async (item) => {
-    const { ossId, url } = item;
+const loadImageAsBase64 = async (url) => {
     try {
-        let blob;
-        if (ossId) {
-            blob = await downloadOssBlob(ossId);
-        } else if (url) {
-            const response = await fetch(url);
-            blob = await response.blob();
-        }
+        const response = await fetch(url);
+        const blob = await response.blob();
 
         if (!blob || blob.size === 0) {
-            console.warn('[FlowChart] 下载到的 Blob 为空:', url || ossId);
+            console.warn('[FlowChart] 下载到的 Blob 为空:', url);
             return '';
         }
 
-        console.log(`[FlowChart] 成功获取 Blob: 尺寸=${blob.size}, 类型=${blob.type}, ID=${ossId || 'N/A'}`);
+        console.log(`[FlowChart] 成功获取 Blob: 尺寸=${blob.size}, 类型=${blob.type}`);
 
-        // 如果是报错返回的 JSON
         if (blob.type === 'application/json') {
             const text = await blob.text();
             console.error('[FlowChart] 图片下载返回了错误 JSON:', text);
@@ -904,11 +883,10 @@ const loadImageAsBase64 = async (item) => {
             const reader = new FileReader();
             reader.onloadend = () => {
                 const result = reader.result;
-                // 只要是有效的 DataURL 且长度合理就通过,不再严格限制 image/ 前缀
                 if (typeof result === 'string' && result.length > 100) {
                     resolve(result);
                 } else {
-                    console.warn('[FlowChart] 生成的 Base64 长度不足或无效:', url || ossId);
+                    console.warn('[FlowChart] 生成的 Base64 长度不足或无效:', url);
                     resolve('');
                 }
             };
@@ -919,7 +897,7 @@ const loadImageAsBase64 = async (item) => {
             reader.readAsDataURL(blob);
         });
     } catch (err) {
-        console.error('[FlowChart] 图片加载异常:', url || ossId, err);
+        console.error('[FlowChart] 图片加载异常:', url, err);
         return '';
     }
 }
@@ -942,15 +920,14 @@ const handleExportProgressImage = async () => {
     captureTime.value = new Date().toLocaleString()
     captureBase64Cache.value = {}
 
-    // 收集所有需要预加载的图片 @Author: Antigravity
+    // 收集所有需要预加载的图片URL @Author: Antigravity
     const itemsToLoad = []
     for (const step of serviceProgressSteps.value) {
         if (!step.media || !step.media.length) continue
         for (const mediaItem of step.media) {
             if (mediaItem.type === 'image' && mediaItem.url) {
-                // 仅收集还未加载过的
                 if (!captureBase64Cache.value[mediaItem.url]) {
-                    itemsToLoad.push(mediaItem)
+                    itemsToLoad.push(mediaItem.url)
                 }
             }
         }
@@ -960,9 +937,9 @@ const handleExportProgressImage = async () => {
     if (itemsToLoad.length > 0) {
         console.log(`[FlowChart] 开始预加载 ${itemsToLoad.length} 张图片...`);
         const results = await Promise.all(
-            itemsToLoad.map(async (item) => {
-                const b64 = await loadImageAsBase64(item);
-                return { url: item.url, b64 };
+            itemsToLoad.map(async (url) => {
+                const b64 = await loadImageAsBase64(url);
+                return { url, b64 };
             })
         );
 
@@ -1042,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>

+ 37 - 3
src/views/order/orderList/index.vue

@@ -14,6 +14,9 @@
             </el-radio-group>
             <el-input v-model="filters.content" placeholder="订单号/品牌/宠主/手机号" class="search-input" prefix-icon="Search"
               clearable @clear="handleSearch" @keyup.enter="handleSearch" />
+            <el-cascader v-model="filterCascaderValue" :options="areaTreeOptions"
+              :props="{ checkStrictly: true, value: 'id', label: 'name' }" placeholder="所属站点" clearable
+              style="width: 350px; margin-left: 10px;" @change="handleFilterCascaderChange" />
             <el-button type="primary" icon="Search" @click="handleSearch">查询</el-button>
             <el-button type="success" icon="Download" @click="handleExport"
               v-hasPermi="['order:orderList:export']">导出Excel</el-button>
@@ -224,7 +227,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
+import { ref, reactive, computed, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
 import { useRouter, useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import fulfillerEnums from '@/json/fulfiller.json';
@@ -259,7 +262,8 @@ const loading = ref(false);
 const filters = reactive({
   service: '',
   status: '',
-  content: ''
+  content: '',
+  site: undefined as number | undefined
 });
 
 const pagination = reactive({
@@ -272,6 +276,20 @@ const tableData = ref([]);
 const serviceOptions = ref([]);
 const areaStationList = ref([]);
 const areaStationMap = ref({});
+const areaTreeOptions = computed(() => {
+  const buildTree = (data: any[], parentId: any): any[] => {
+    return data
+      .filter(item => String(item.parentId) === String(parentId))
+      .map(item => {
+        const children = buildTree(data, item.id);
+        const res: any = { id: item.id, name: item.name };
+        if (children && children.length > 0) res.children = children;
+        return res;
+      });
+  };
+  return buildTree(areaStationList.value, 0);
+});
+const filterCascaderValue = ref<any[]>([]);
 const storeMap = ref({});
 
 onMounted(() => {
@@ -343,6 +361,21 @@ const handleStatusTabChange = async () => {
   handleSearch();
 };
 
+const handleFilterCascaderChange = (val: any[]) => {
+  if (val && val.length > 0) {
+    const lastId = val[val.length - 1];
+    const node = areaStationList.value.find((item: any) => item.id === lastId);
+    if (node && node.type === 2) {
+      filters.site = lastId;
+    } else {
+      filters.site = undefined;
+    }
+  } else {
+    filters.site = undefined;
+  }
+  handleSearch();
+};
+
 const handleSearch = (isPolling: any = false) => {
   const isAutoPoll = isPolling === true;
   if (!isAutoPoll) {
@@ -353,7 +386,8 @@ const handleSearch = (isPolling: any = false) => {
     pageSize: pagination.size,
     service: filters.service !== '' ? filters.service : undefined,
     status: filters.status !== '' ? Number(filters.status) : undefined,
-    content: filters.content || undefined
+    content: filters.content || undefined,
+    site: filters.site || undefined
   })
     .then((res) => {
       tableData.value = res.rows || [];

+ 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>

+ 332 - 9
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">
 
       <!-- 左侧:下单填写区 -->
@@ -23,7 +28,8 @@
                     </template>
                     <PageSelect v-model="form.merchantId" placeholder="请选择商户门店" size="large" style="width: 100%"
                       :options="merchantOptions" :total="storeTotal" :page-size="5" @page-change="handleStorePageChange"
-                      @update:modelValue="handleStoreChange" />
+                      @update:modelValue="handleStoreChange" @filter-method="handleStoreFilter"
+                      @visible-change="handleStoreVisible" />
                   </el-form-item>
                 </el-col>
                 <el-col :span="12">
@@ -53,7 +59,7 @@
                     @click="form.petId = p.id">
                     <el-avatar :size="48" :src="p.avatarUrl || p.avatar" shape="square" style="border-radius: 6px;">{{
                       p.name.charAt(0)
-                      }}</el-avatar>
+                    }}</el-avatar>
                     <div class="pet-info">
                       <div class="name">{{ p.name }}</div>
                       <div class="sub">{{ p.breed }}</div>
@@ -195,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>
@@ -210,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'
@@ -222,7 +264,7 @@ import { createOrder } from '@/api/order/order'
 // --- State ---
 const userOptions = ref([])
 const userTotal = ref(0)
-const userQuery = reactive({ pageNum: 1, pageSize: 5, content: '', tenantId: undefined })
+const userQuery = reactive({ pageNum: 1, pageSize: 5, content: '', tenantId: undefined, stationId: undefined })
 const userLoading = ref(false)
 
 const serviceList = [
@@ -244,7 +286,7 @@ const currentPets = ref([])
 const stores = ref([])
 const storeTotal = ref(0)
 const allServices = ref([])
-const storeQuery = reactive({ pageNum: 1, pageSize: 5 })
+const storeQuery = reactive({ pageNum: 1, pageSize: 5, name: '' })
 
 const form = reactive({
   merchantId: '',
@@ -335,6 +377,20 @@ const handleStorePageChange = (page) => {
   fetchStores()
 }
 
+const handleStoreFilter = (query) => {
+  storeQuery.name = query || ''
+  storeQuery.pageNum = 1
+  fetchStores()
+}
+
+const handleStoreVisible = (visible) => {
+  if (visible) {
+    storeQuery.name = ''
+    storeQuery.pageNum = 1
+    fetchStores()
+  }
+}
+
 const handleStoreChange = (val) => {
   // 切换门店时强制清空已选客户和宠物
   form.userId = ''
@@ -450,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 => {
@@ -506,14 +817,20 @@ 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
   userLoading.value = true
 
-  // 关联当前选中门店的租户ID
+  // 关联当前选中门店的租户ID和站点ID
   const store = stores.value.find(s => s.id === form.merchantId)
   userQuery.tenantId = store ? store.tenantId : undefined
+  userQuery.stationId = store ? store.site : undefined
 
   listCustomerOnOrder(userQuery).then(res => {
     userOptions.value = res.rows || []
@@ -769,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;

+ 1 - 1
src/views/system/store/index.vue

@@ -896,7 +896,7 @@ watch(addressCascaderValue, (newValue) => {
 
 const getServiceName = (serviceId: number): string => {
   const service = serviceList.value.find(item => item.id === serviceId);
-  return service ? service.name : String(serviceId);
+  return service ? service.name : '未知服务';
 };
 
 const getStatusList = async () => {

+ 73 - 80
src/views/systemConfig/protocol/index.vue

@@ -1,47 +1,30 @@
 <template>
   <div class="protocol-config-setting">
-    <!-- 顶部提示信息 -->
-    <div class="setting-hint">
-      <el-alert :closable="false" type="info" class="custom-alert">
-        <template #title>
-          <div class="alert-content">
-            <el-icon class="info-icon">
-              <InfoFilled />
-            </el-icon>
-            <span>协议配置:目前支持用户协议、隐私政策、履约者说明、托运协议、宠物洗护服务规范;请确保内容准确合规。</span>
-          </div>
-        </template>
-      </el-alert>
-    </div>
-
-    <!-- 配置表单区 -->
     <div class="setting-body">
+      <!-- 一级 Tab:平台 -->
+      <el-tabs v-model="activePlatform" class="platform-tabs" @tab-change="handlePlatformChange">
+        <el-tab-pane label="好萌友" name="haomengyou" />
+        <el-tab-pane label="履约守护" name="fulfiller" />
+      </el-tabs>
+
+      <!-- 二级 Tab:协议类型 -->
+      <el-tabs v-model="activeProtocol" class="protocol-tabs" @tab-change="handleProtocolChange">
+        <el-tab-pane v-for="item in currentProtocols" :key="item.id" :label="item.label" :name="String(item.id)" />
+      </el-tabs>
+
+      <!-- 配置表单区 -->
       <el-form ref="protocolFormRef" :model="form" :rules="rules" label-width="140px" label-position="right"
-        class="premium-setting-form">
-
-        <!-- 协议选择 -->
-        <el-form-item label="协议选择:">
-          <el-radio-group v-model="currentId" @change="handleTypeChange" class="custom-radio-group">
-            <el-radio :label="1" border>用户协议</el-radio>
-            <el-radio :label="2" border>隐私政策</el-radio>
-            <el-radio :label="3" border>履约者说明</el-radio>
-            <el-radio :label="4" border>托运协议</el-radio>
-            <el-radio :label="5" border>宠物洗护服务规范</el-radio>
-          </el-radio-group>
-        </el-form-item>
+        class="premium-setting-form" :key="currentId">
 
-        <!-- 协议标题 -->
         <el-form-item label="协议标题:" prop="title">
           <el-input v-model="form.title" placeholder="请输入协议标题" class="config-input" />
         </el-form-item>
 
-        <!-- 协议内容 -->
         <el-form-item label="协议内容:" prop="content">
           <Editor v-model="form.content" :min-height="400" class="config-editor" />
           <div class="form-tip">协议内容将以 HTML 格式保存,支持图片和视频插入。</div>
         </el-form-item>
 
-        <!-- 保存按钮 -->
         <el-form-item class="action-item">
           <el-button type="primary" class="save-btn" :loading="buttonLoading" @click="submitForm">保存设置</el-button>
         </el-form-item>
@@ -51,9 +34,8 @@
 </template>
 
 <script setup name="ProtocolConfig" lang="ts">
-import { ref, reactive, onMounted, getCurrentInstance, ComponentInternalInstance } from 'vue';
+import { ref, reactive, computed, onMounted, getCurrentInstance, ComponentInternalInstance } from 'vue';
 import { getAgreement, editAgreement } from '@/api/system/agreement';
-import { InfoFilled } from '@element-plus/icons-vue';
 import { ElForm } from 'element-plus';
 import Editor from '@/components/Editor/index.vue';
 
@@ -61,7 +43,32 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 const buttonLoading = ref(false);
 const protocolFormRef = ref<InstanceType<typeof ElForm>>();
-const currentId = ref(1); // 默认选择用户协议 ID:1
+const activePlatform = ref('haomengyou');
+const currentId = ref(1);
+
+interface ProtocolItem {
+  id: number;
+  label: string;
+}
+
+const haomengyouProtocols: ProtocolItem[] = [
+  { id: 1, label: '用户协议' },
+  { id: 2, label: '隐私政策' },
+  // { id: 4, label: '托运协议' },
+  // { id: 5, label: '宠物洗护服务规范' }
+];
+
+const fulfillerProtocols: ProtocolItem[] = [
+  { id: 6, label: '用户协议' },
+  { id: 7, label: '隐私政策' },
+  { id: 3, label: '履约者说明' }
+];
+
+const currentProtocols = computed<ProtocolItem[]>(() => {
+  return activePlatform.value === 'haomengyou' ? haomengyouProtocols : fulfillerProtocols;
+});
+
+const activeProtocol = ref('1');
 
 const form = reactive({
   id: undefined,
@@ -74,7 +81,6 @@ const rules = {
   content: [{ required: true, message: "协议内容不能为空", trigger: "blur" }],
 };
 
-/** 加载协议配置 */
 const loadProtocolConfig = async (id: number) => {
   try {
     const res = await getAgreement(id);
@@ -86,18 +92,26 @@ const loadProtocolConfig = async (id: number) => {
   }
 };
 
-/** 切换协议 */
-const handleTypeChange = (val: any) => {
-  loadProtocolConfig(val as number);
+const handlePlatformChange = () => {
+  const first = currentProtocols.value[0];
+  if (first) {
+    activeProtocol.value = String(first.id);
+    currentId.value = first.id;
+    loadProtocolConfig(first.id);
+  }
+};
+
+const handleProtocolChange = (val: string | number) => {
+  const id = Number(val);
+  currentId.value = id;
+  loadProtocolConfig(id);
 };
 
-/** 提交按钮 */
 const submitForm = () => {
   protocolFormRef.value?.validate(async (valid: boolean) => {
     if (valid) {
       buttonLoading.value = true;
       try {
-        // 将 content 按照 Base64 方式编码,同时处理非西欧语言字符集
         const contentBase64 = btoa(unescape(encodeURIComponent(form.content)));
 
         const submitData = {
@@ -128,32 +142,29 @@ onMounted(() => {
   padding: 8px 0;
 }
 
-.setting-hint {
-  margin-bottom: 32px;
-
-  .custom-alert {
-    background-color: #e8f4ff;
-    border: none;
-    border-radius: 8px;
-    padding: 12px 16px;
-
-    .alert-content {
-      display: flex;
-      align-items: center;
-      gap: 10px;
-      color: #1890ff;
-      font-size: 14px;
-      line-height: 1.5;
-
-      .info-icon {
-        font-size: 18px;
-      }
-    }
+.setting-body {
+  padding-left: 20px;
+}
+
+.platform-tabs {
+  margin-bottom: 4px;
+
+  :deep(.el-tabs__header) {
+    margin-bottom: 8px;
+  }
+
+  :deep(.el-tabs__item) {
+    font-size: 15px;
+    font-weight: 500;
   }
 }
 
-.setting-body {
-  padding-left: 20px;
+.protocol-tabs {
+  margin-bottom: 24px;
+
+  :deep(.el-tabs__header) {
+    margin-bottom: 0;
+  }
 }
 
 .premium-setting-form {
@@ -189,24 +200,6 @@ onMounted(() => {
     line-height: 1.4;
   }
 
-  .custom-radio-group {
-    :deep(.el-radio) {
-      margin-right: 16px;
-      border-radius: 6px;
-      padding: 0 20px;
-      height: 36px;
-
-      &.is-bordered.is-checked {
-        border-color: #409eff;
-        background-color: rgba(64, 158, 255, 0.04);
-      }
-
-      .el-radio__label {
-        font-size: 14px;
-      }
-    }
-  }
-
   .action-item {
     margin-top: 40px;
   }

+ 1 - 0
vite.config.ts

@@ -26,6 +26,7 @@ export default defineConfig(({ mode, command }) => {
       proxy: {
         [env.VITE_APP_BASE_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,