index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. <template>
  2. <el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)"
  3. :title="isEdit ? '编辑宠物' : '新增宠物'" width="880px" destroy-on-close append-to-body class="add-pet-dialog">
  4. <div class="dialog-body">
  5. <!-- 头像区 -->
  6. <div class="avatar-section">
  7. <el-upload class="avatar-uploader" action="#" :show-file-list="false" :auto-upload="false"
  8. :on-change="handleUploadFile">
  9. <el-avatar v-if="avatarDisplayUrl" :src="avatarDisplayUrl" :size="80" class="avatar-preview" />
  10. <div v-else class="avatar-placeholder">
  11. <el-icon :size="28">
  12. <Plus />
  13. </el-icon>
  14. <span>上传头像</span>
  15. </div>
  16. </el-upload>
  17. </div>
  18. <el-form :model="form" label-position="top" class="pet-form">
  19. <!-- 基本信息 -->
  20. <div class="section-heading">
  21. <span class="section-line"></span>
  22. <span class="section-text">基本信息</span>
  23. <span class="section-line"></span>
  24. </div>
  25. <el-row :gutter="20">
  26. <el-col :span="8">
  27. <el-form-item label="宠物姓名" required><el-input v-model="form.name" placeholder="请输入" /></el-form-item>
  28. </el-col>
  29. <el-col :span="8">
  30. <el-form-item label="所属主人" required>
  31. <el-select v-model="form.userId" placeholder="选择主人" filterable :disabled="lockedOwner">
  32. <el-option v-for="user in userOptions" :key="user.id"
  33. :label="user.name + ' - ' + (user.phone || user.phoneNumber || '')" :value="user.id" />
  34. </el-select>
  35. </el-form-item>
  36. </el-col>
  37. <el-col :span="8">
  38. <el-form-item label="性别">
  39. <el-select v-model="form.gender" placeholder="请选择">
  40. <el-option v-for="dict in sys_pet_gender" :key="dict.value" :label="dict.label"
  41. :value="parseInt(dict.value)" />
  42. </el-select>
  43. </el-form-item>
  44. </el-col>
  45. <el-col :span="8">
  46. <el-form-item label="品种" required><el-input v-model="form.breed" placeholder="请输入" /></el-form-item>
  47. </el-col>
  48. <el-col :span="8">
  49. <el-form-item label="体型" required>
  50. <el-select v-model="form.size">
  51. <el-option v-for="dict in sys_pet_size" :key="dict.value" :label="dict.label" :value="dict.value" />
  52. </el-select>
  53. </el-form-item>
  54. </el-col>
  55. <el-col :span="4">
  56. <el-form-item label="体重(kg)" required>
  57. <el-input-number v-model="form.weight" :min="0" :precision="1" controls-position="right" />
  58. </el-form-item>
  59. </el-col>
  60. <el-col :span="4">
  61. <el-form-item label="年龄" required>
  62. <el-input-number v-model="form.age" :min="0" controls-position="right" />
  63. </el-form-item>
  64. </el-col>
  65. <el-col :span="12">
  66. <el-form-item label="性格关键词">
  67. <el-input v-model="form.personality" placeholder="如:活泼、粘人" />
  68. </el-form-item>
  69. </el-col>
  70. <el-col :span="12">
  71. <el-form-item label="宠物标签">
  72. <el-select v-model="form.tagIds" multiple placeholder="选择标签" collapse-tags collapse-tags-tooltip>
  73. <el-option v-for="tag in allPetTags" :key="tag.id" :label="tag.name" :value="tag.id">
  74. <el-tag :type="tag.colorType || 'info'" effect="light" size="small">{{ tag.name }}</el-tag>
  75. </el-option>
  76. </el-select>
  77. </el-form-item>
  78. </el-col>
  79. <el-col :span="24">
  80. <el-form-item label="萌宠性格">
  81. <el-input v-model="form.cutePersonality" type="textarea" :rows="2" placeholder="详细描述萌宠的性格特点" />
  82. </el-form-item>
  83. </el-col>
  84. </el-row>
  85. <!-- 家庭信息 -->
  86. <div class="section-heading">
  87. <span class="section-line"></span>
  88. <span class="section-text">家庭信息</span>
  89. <span class="section-line"></span>
  90. </div>
  91. <el-row :gutter="20">
  92. <el-col :span="8">
  93. <el-form-item label="新来家庭时间">
  94. <el-date-picker v-model="form.arrivalTime" type="date" placeholder="选择日期" />
  95. </el-form-item>
  96. </el-col>
  97. <el-col :span="8">
  98. <el-form-item label="家庭房屋类型" required>
  99. <el-select v-model="form.houseType" placeholder="请选择">
  100. <el-option v-for="dict in sys_house_type" :key="dict.value" :label="dict.label" :value="dict.value" />
  101. </el-select>
  102. </el-form-item>
  103. </el-col>
  104. <el-col :span="8">
  105. <el-form-item label="入门方式">
  106. <el-select v-model="form.entryMethod" placeholder="请选择">
  107. <el-option v-for="dict in sys_entry_method" :key="dict.value" :label="dict.label" :value="dict.value" />
  108. </el-select>
  109. </el-form-item>
  110. </el-col>
  111. <el-col :span="8" v-if="form.entryMethod === 'password'">
  112. <el-form-item label="门锁密码" required>
  113. <el-input v-model="form.entryPassword" placeholder="请输入门锁密码" />
  114. </el-form-item>
  115. </el-col>
  116. <el-col :span="8" v-if="form.entryMethod === 'key'">
  117. <el-form-item label="钥匙位置" required>
  118. <el-input v-model="form.keyLocation" placeholder="如:地毯下" />
  119. </el-form-item>
  120. </el-col>
  121. </el-row>
  122. <!-- 健康状况 -->
  123. <div class="section-heading">
  124. <span class="section-line"></span>
  125. <span class="section-text">健康状况</span>
  126. <span class="section-line"></span>
  127. </div>
  128. <el-row :gutter="20">
  129. <el-col :span="showVaccineCert ? 6 : 8">
  130. <el-form-item label="健康状态">
  131. <el-select v-model="form.healthStatus" placeholder="请选择">
  132. <el-option value="健康" label="健康" />
  133. <el-option value="亚健康" label="亚健康" />
  134. <el-option value="疾病" label="疾病" />
  135. </el-select>
  136. </el-form-item>
  137. </el-col>
  138. <el-col :span="showVaccineCert ? 6 : 8">
  139. <el-form-item label="攻击倾向">
  140. <el-switch v-model="form.aggression" active-text="是" inactive-text="否" :active-value="1"
  141. :inactive-value="0" />
  142. </el-form-item>
  143. </el-col>
  144. <el-col :span="showVaccineCert ? 6 : 8">
  145. <el-form-item label="疫苗情况" required>
  146. <el-select v-model="form.vaccineStatus" placeholder="请选择">
  147. <el-option value="无" label="无" />
  148. <el-option value="已打1次" label="已打1次" />
  149. <el-option value="已打2次" label="已打2次" />
  150. <el-option value="已打3次" label="已打3次" />
  151. </el-select>
  152. </el-form-item>
  153. </el-col>
  154. <el-col v-if="showVaccineCert" :span="6">
  155. <el-form-item label="疫苗凭证">
  156. <div class="cert-row">
  157. <div class="cert-box" :class="{ 'has-image': vaccineCertDisplayUrl }">
  158. <el-image v-if="vaccineCertDisplayUrl" :src="vaccineCertDisplayUrl" class="cert-thumb"
  159. :preview-src-list="[vaccineCertDisplayUrl]" fit="cover" />
  160. <el-icon v-else :size="18" class="cert-plus-icon">
  161. <Plus />
  162. </el-icon>
  163. <div v-if="!vaccineCertDisplayUrl" class="cert-upload-mask" @click="triggerVaccineCertUpload" />
  164. </div>
  165. <el-upload ref="vaccineCertUploadRef" style="display:none" action="#" :show-file-list="false"
  166. :auto-upload="false" :on-change="handleUploadVaccineCert" />
  167. <div v-if="vaccineCertDisplayUrl" class="cert-actions">
  168. <span class="cert-action-icon cert-action-edit" @click="triggerVaccineCertUpload">
  169. <el-icon :size="14">
  170. <Edit />
  171. </el-icon>
  172. </span>
  173. <span class="cert-action-icon cert-action-del" @click="removeVaccineCert">
  174. <el-icon :size="14">
  175. <Delete />
  176. </el-icon>
  177. </span>
  178. </div>
  179. </div>
  180. </el-form-item>
  181. </el-col>
  182. <el-col :span="12">
  183. <el-form-item label="既往病史">
  184. <el-input v-model="form.medicalHistory" type="textarea" :rows="2" placeholder="如有病史请记录" />
  185. </el-form-item>
  186. </el-col>
  187. <el-col :span="12">
  188. <el-form-item label="过敏史">
  189. <el-input v-model="form.allergies" type="textarea" :rows="2" placeholder="如有过敏源请记录" />
  190. </el-form-item>
  191. </el-col>
  192. </el-row>
  193. </el-form>
  194. </div>
  195. <template #footer>
  196. <div class="dialog-footer">
  197. <el-button @click="$emit('update:visible', false)" size="large">取消</el-button>
  198. <el-button type="primary" :loading="submitLoading" @click="saveData" size="large">保存</el-button>
  199. </div>
  200. </template>
  201. </el-dialog>
  202. </template>
  203. <script setup>
  204. import { ref, reactive, watch, onMounted, getCurrentInstance, toRefs, computed } from 'vue'
  205. import { ElMessage } from 'element-plus'
  206. import { globalHeaders } from '@/utils/request'
  207. import { addPet, updatePet } from '@/api/archieves/pet'
  208. import { getCustomer } from '@/api/archieves/customer'
  209. import { listAllTag } from '@/api/archieves/tag'
  210. const props = defineProps({
  211. visible: { type: Boolean, default: false },
  212. userId: { type: [String, Number], default: undefined },
  213. userOptions: { type: Array, default: () => [] },
  214. lockedOwner: { type: Boolean, default: false },
  215. petData: { type: Object, default: null }
  216. })
  217. const emit = defineEmits(['update:visible', 'success'])
  218. const { proxy } = getCurrentInstance()
  219. const { sys_pet_gender, sys_pet_size, sys_house_type, sys_entry_method } = toRefs(
  220. proxy?.useDict('sys_pet_gender', 'sys_pet_size', 'sys_house_type', 'sys_entry_method')
  221. )
  222. const submitLoading = ref(false)
  223. const allPetTags = ref([])
  224. const avatarDisplayUrl = ref('')
  225. const vaccineCertDisplayUrl = ref('')
  226. const isEdit = ref(false)
  227. const baseUrl = import.meta.env.VITE_APP_BASE_API
  228. const uploadUrl = baseUrl + '/resource/oss/upload'
  229. const defaultForm = () => ({
  230. id: undefined, userId: undefined, avatar: undefined,
  231. name: '', type: 0, gender: undefined, breed: '', birthday: '',
  232. age: 1, weight: 5, size: 'small', isSterilized: 0,
  233. arrivalTime: '', houseType: '', entryMethod: '', entryPassword: '', keyLocation: '',
  234. personality: '', cutePersonality: '',
  235. healthStatus: '健康', aggression: 0, vaccineStatus: '无', vaccineCert: undefined,
  236. medicalHistory: '', allergies: '', remark: '', tagIds: []
  237. })
  238. const form = reactive(defaultForm())
  239. watch(() => props.visible, (val) => {
  240. if (val) {
  241. submitLoading.value = false
  242. avatarDisplayUrl.value = ''
  243. vaccineCertDisplayUrl.value = ''
  244. if (props.petData) {
  245. isEdit.value = true
  246. const d = props.petData
  247. Object.assign(form, {
  248. id: d.id, userId: d.userId, avatar: d.avatar,
  249. name: d.name, type: d.type, gender: d.gender,
  250. breed: d.breed, birthday: d.birthday || '',
  251. age: d.age, weight: d.weight, size: d.size,
  252. isSterilized: d.isSterilized, arrivalTime: d.arrivalTime || '',
  253. houseType: d.houseType || '', entryMethod: d.entryMethod || '',
  254. entryPassword: d.entryPassword || '', keyLocation: d.keyLocation || '',
  255. personality: d.personality || '', cutePersonality: d.cutePersonality || '',
  256. healthStatus: d.healthStatus || '健康', aggression: d.aggression ?? 0,
  257. vaccineStatus: d.vaccineStatus || '无', vaccineCert: d.vaccineCert,
  258. medicalHistory: d.medicalHistory || '', allergies: d.allergies || '',
  259. remark: d.remark || '',
  260. tagIds: d.tags ? d.tags.map(t => t.id) : (d.tagIds || [])
  261. })
  262. avatarDisplayUrl.value = d.avatarUrl || ''
  263. vaccineCertDisplayUrl.value = d.vaccineCertUrl || ''
  264. } else {
  265. isEdit.value = false
  266. Object.assign(form, defaultForm())
  267. form.userId = props.userId || undefined
  268. if (props.userId) fetchAndFillOwnerInfo(props.userId)
  269. }
  270. }
  271. })
  272. watch(() => form.userId, (newUserId) => {
  273. if (!newUserId || isEdit.value) return
  274. fetchAndFillOwnerInfo(newUserId)
  275. })
  276. const fetchAndFillOwnerInfo = (userId) => {
  277. getCustomer(userId).then((res) => {
  278. const data = res.data
  279. if (data) {
  280. if (!form.houseType) form.houseType = data.houseType || ''
  281. if (!form.entryMethod) form.entryMethod = data.entryMethod || ''
  282. if (!form.entryPassword) form.entryPassword = data.entryPassword || ''
  283. if (!form.keyLocation) form.keyLocation = data.keyLocation || ''
  284. }
  285. }).catch(() => { })
  286. }
  287. const loadTags = () => { listAllTag({ category: 'pet', status: 0 }).then(res => { allPetTags.value = res.data || [] }) }
  288. const handleUploadFile = async (file) => {
  289. const fd = new FormData(); fd.append('file', file.raw)
  290. try {
  291. const headers = globalHeaders()
  292. const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': headers.Authorization, 'clientid': headers.clientid }, body: fd })
  293. const result = await res.json()
  294. if (result.code === 200) { form.avatar = result.data.ossId; avatarDisplayUrl.value = result.data.url }
  295. else ElMessage.error(result.msg || '头像上传失败')
  296. } catch (e) { ElMessage.error('头像上传失败') }
  297. }
  298. const vaccineCertUploadRef = ref(null)
  299. const showVaccineCert = computed(() => form.vaccineStatus && form.vaccineStatus !== '无')
  300. const handleUploadVaccineCert = async (file) => {
  301. const fd = new FormData(); fd.append('file', file.raw)
  302. try {
  303. const headers = globalHeaders()
  304. const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': headers.Authorization, 'clientid': headers.clientid }, body: fd })
  305. const result = await res.json()
  306. if (result.code === 200) { form.vaccineCert = result.data.ossId; vaccineCertDisplayUrl.value = result.data.url }
  307. else ElMessage.error(result.msg || '疫苗凭证上传失败')
  308. } catch (e) { ElMessage.error('疫苗凭证上传失败') }
  309. }
  310. const triggerVaccineCertUpload = () => {
  311. const el = vaccineCertUploadRef.value?.$el || vaccineCertUploadRef.value
  312. const input = el?.querySelector?.('input[type="file"]')
  313. if (input) input.click()
  314. }
  315. const removeVaccineCert = () => {
  316. form.vaccineCert = undefined
  317. vaccineCertDisplayUrl.value = ''
  318. }
  319. const saveData = () => {
  320. if (!form.name) return ElMessage.warning('请输入宠物姓名')
  321. if (!form.userId) return ElMessage.warning('请选择所属主人')
  322. if (!form.breed) return ElMessage.warning('请输入品种')
  323. if (!form.size) return ElMessage.warning('请选择体型')
  324. if (form.weight === undefined || form.weight === null) return ElMessage.warning('请输入体重(kg)')
  325. if (form.age === undefined || form.age === null) return ElMessage.warning('请输入年龄(岁)')
  326. if (!form.houseType) return ElMessage.warning('请选择家庭房屋类型')
  327. if (form.entryMethod === 'password' && !form.entryPassword) return ElMessage.warning('请输入门锁密码')
  328. if (form.entryMethod === 'key' && !form.keyLocation) return ElMessage.warning('请输入钥匙存放位置')
  329. if (!form.vaccineStatus) return ElMessage.warning('请选择疫苗情况')
  330. submitLoading.value = true
  331. const data = { ...form, aggression: Number(form.aggression) || 0 }
  332. const api = isEdit.value ? updatePet(data) : addPet(data)
  333. api.then((res) => {
  334. ElMessage.success(isEdit.value ? '宠物档案更新成功' : '宠物档案保存成功')
  335. emit('success', res.data || data)
  336. emit('update:visible', false)
  337. }).finally(() => { submitLoading.value = false })
  338. }
  339. onMounted(() => { loadTags() })
  340. </script>
  341. <style scoped>
  342. .add-pet-dialog :deep(.el-dialog__body) {
  343. padding: 0 36px 0;
  344. max-height: 62vh;
  345. overflow-y: auto;
  346. }
  347. .dialog-body {
  348. padding-top: 12px;
  349. }
  350. .avatar-section {
  351. display: flex;
  352. justify-content: center;
  353. margin-bottom: 24px;
  354. }
  355. .avatar-uploader {
  356. cursor: pointer;
  357. }
  358. .avatar-preview {
  359. box-shadow: 0 2px 12px rgba(0, 0, 0, .1);
  360. border: 3px solid #fff;
  361. outline: 1px solid #e8e8e8;
  362. }
  363. .avatar-placeholder {
  364. width: 80px;
  365. height: 80px;
  366. border-radius: 50%;
  367. border: 2px dashed #dcdfe6;
  368. display: flex;
  369. flex-direction: column;
  370. align-items: center;
  371. justify-content: center;
  372. color: #a8abb2;
  373. font-size: 12px;
  374. gap: 2px;
  375. transition: all .3s;
  376. }
  377. .avatar-placeholder:hover {
  378. border-color: #409eff;
  379. color: #409eff;
  380. }
  381. .pet-form {
  382. padding-bottom: 12px;
  383. }
  384. .section-heading {
  385. display: flex;
  386. align-items: center;
  387. margin: 32px 0 20px;
  388. }
  389. .section-heading:first-of-type {
  390. margin-top: 0;
  391. }
  392. .section-line {
  393. flex: 1;
  394. height: 1px;
  395. background: #e8e8e8;
  396. }
  397. .section-text {
  398. padding: 0 20px;
  399. font-size: 14px;
  400. font-weight: 600;
  401. color: #303133;
  402. white-space: nowrap;
  403. }
  404. .cert-row {
  405. display: flex;
  406. align-items: center;
  407. gap: 10px;
  408. }
  409. .cert-box {
  410. width: 32px;
  411. height: 32px;
  412. border-radius: 6px;
  413. border: 1.5px dashed #d0d5dd;
  414. display: flex;
  415. align-items: center;
  416. justify-content: center;
  417. position: relative;
  418. overflow: hidden;
  419. transition: border-color .25s, box-shadow .25s;
  420. cursor: pointer;
  421. flex-shrink: 0;
  422. }
  423. .cert-box:hover {
  424. border-color: #409eff;
  425. box-shadow: 0 0 0 3px rgba(64, 158, 255, .08);
  426. }
  427. .cert-plus-icon {
  428. color: #a0a5b0;
  429. transition: color .25s;
  430. }
  431. .cert-box:hover .cert-plus-icon {
  432. color: #409eff;
  433. }
  434. .cert-upload-mask {
  435. position: absolute;
  436. inset: 0;
  437. z-index: 1;
  438. }
  439. .cert-box.has-image {
  440. border-style: solid;
  441. border-color: #e4e7ed;
  442. cursor: default;
  443. box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
  444. }
  445. .cert-box.has-image:hover {
  446. border-color: #e4e7ed;
  447. box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
  448. }
  449. .cert-thumb {
  450. width: 100%;
  451. height: 100%;
  452. }
  453. .cert-actions {
  454. display: flex;
  455. gap: 4px;
  456. }
  457. .cert-action-icon {
  458. width: 32px;
  459. height: 32px;
  460. border-radius: 6px;
  461. display: inline-flex;
  462. align-items: center;
  463. justify-content: center;
  464. cursor: pointer;
  465. transition: all .2s;
  466. color: #606266;
  467. background: #f5f6f8;
  468. }
  469. .cert-action-icon:hover {
  470. background: #ecf5ff;
  471. color: #409eff;
  472. }
  473. .cert-action-del:hover {
  474. background: #fef0f0;
  475. color: #f56c6c;
  476. }
  477. .dialog-footer {
  478. display: flex;
  479. justify-content: flex-end;
  480. gap: 12px;
  481. padding-top: 12px;
  482. }
  483. </style>