diff --git a/src/api/dogs.ts b/src/api/dogs.ts index c1fd356..b927d83 100644 --- a/src/api/dogs.ts +++ b/src/api/dogs.ts @@ -16,6 +16,9 @@ export interface Dog { gender: string | null neutered: boolean imageUrl: string | null + prefGender: string + prefAge: string + prefNeutered: string traits: TraitTag[] } @@ -27,6 +30,9 @@ export interface DogSaveRequest { gender?: string | null neutered?: boolean imageUrl?: string | null + prefGender?: string + prefAge?: string + prefNeutered?: string traitIds?: number[] } diff --git a/src/api/spots.ts b/src/api/spots.ts index 56f9316..0892905 100644 --- a/src/api/spots.ts +++ b/src/api/spots.ts @@ -40,11 +40,8 @@ export const spotsApi = { list: () => http.get('/api/spots'), mates: (spotId: number, userId?: number) => http.get(`/api/spots/${spotId}/mates${userId ? `?userId=${userId}` : ''}`), - aiMates: (spotId: number, dogId: number, neutered?: boolean) => - http.get( - `/api/spots/${spotId}/ai-mates?dogId=${dogId}` + - (neutered != null ? `&neutered=${neutered}` : ''), - ), + aiMates: (spotId: number, dogId: number) => + http.get(`/api/spots/${spotId}/ai-mates?dogId=${dogId}`), frequentBreeds: (spotId: number) => http.get<{ breeds: string[] }>(`/api/spots/${spotId}/frequent-breeds`), reviews: (spotId: number) => http.get(`/api/spots/${spotId}/reviews`), diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index dcb36ff..446afa6 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -40,25 +40,8 @@ · AI 분석으로 실제와 다를 수 있어요 - -
- 중성화 - +
+ 추천 조건은 우리 강아지 > AI 추천 조건에서 설정해요
@@ -186,7 +169,6 @@ const loadError = ref(false) const aiMates = ref([]) const aiLoading = ref(false) const aiTried = ref(false) -const aiNeutered = ref<'any' | 'yes' | 'no'>('any') // AI 찾기 옵션: 중성화 조건 onMounted(load) @@ -238,7 +220,7 @@ async function onCheckIn() { await spotsApi.checkIn(currentSpot.value.id, currentUserId(), dogs.currentDogId) checkedIn.value = true await refreshSpot(currentSpot.value.id) - $q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' }) + $q.notify({ color: 'primary', message: '체크인 완료!', icon: 'place', position: 'top' }) loadAiMates() // 체크인 후 AI 궁합 추천 (비동기) } catch (e) { notifyError(e) @@ -247,14 +229,14 @@ async function onCheckIn() { } } -/** 체크인 후 AI 궁합 추천 로드 — 이 스팟의 강아지 중 내 개와 사이좋게 지낼 만한 목록. */ +/** 체크인 후 AI 궁합 추천 로드 — 이 스팟의 강아지 중 내 개와 사이좋게 지낼 만한 목록. + * 추천 조건(성별/나이/중성화)은 내 강아지 프로필에 저장된 값을 백엔드가 사용한다. */ async function loadAiMates() { if (!currentSpot.value || dogs.currentDogId == null) return aiLoading.value = true aiTried.value = true - const neutered = aiNeutered.value === 'yes' ? true : aiNeutered.value === 'no' ? false : undefined try { - aiMates.value = await spotsApi.aiMates(currentSpot.value.id, dogs.currentDogId, neutered) + aiMates.value = await spotsApi.aiMates(currentSpot.value.id, dogs.currentDogId) } catch { aiMates.value = [] } finally { diff --git a/src/pages/ProfilePage.vue b/src/pages/ProfilePage.vue index b5e2fb2..626d5ca 100644 --- a/src/pages/ProfilePage.vue +++ b/src/pages/ProfilePage.vue @@ -53,7 +53,9 @@
{{ current.name }}
- {{ current.breed || '견종 미상' }} · {{ sizeLabel(current.size) }} + {{ current.breed || '견종 미상' }} · {{ sizeLabel(current.size) }} · {{ genderLabel(current.gender) }} · {{ dogAge(current.birthDate) }}
@@ -80,11 +82,47 @@ {{ tag.label }}
+ + +
+ AI 추천 조건 +
+
+ AI 추천 찰떡 메이트를 찾을 때 이 조건을 반영해요 +
+
+
선호 성별
+ +
+
+
선호 연령
+ +
+
+
선호 중성화
+ +
+ - + +
+
크기
+ +
+ +
+
성별
+ +
+ +
+
+ 연령· {{ ageText }} +
+
+ + +
+
+
중성화 여부 *
+ + + +
+ AI 추천 조건 +
+
이런 친구를 찾아달라고 AI에게 알려줘요
+
+
선호 성별
+ +
+
+
선호 연령
+ +
+
+
선호 중성화
+ +
@@ -179,16 +302,66 @@ const sizeOptions = [ { label: '중형견', value: 'MEDIUM' }, { label: '대형견', value: 'LARGE' }, ] + +// AI 추천 조건 선택지 (ANY = 상관없음) +const prefGenderOptions = [ + { label: '상관없음', value: 'ANY' }, + { label: '남아', value: 'MALE' }, + { label: '여아', value: 'FEMALE' }, +] +const prefAgeOptions = [ + { label: '상관없음', value: 'ANY' }, + { label: '퍼피', value: 'PUPPY' }, + { label: '성견', value: 'ADULT' }, + { label: '노령', value: 'SENIOR' }, +] +const prefNeuteredOptions = [ + { label: '상관없음', value: 'ANY' }, + { label: '완료', value: 'YES' }, + { label: '안 함', value: 'NO' }, +] + const form = reactive<{ name: string breed: string size: string | null + gender: string | null + birthYear: number | null + birthMonth: number | null neutered: boolean | null + prefGender: string + prefAge: string + prefNeutered: string }>({ name: '', breed: '', size: null, + gender: null, + birthYear: null, + birthMonth: null, neutered: null, + prefGender: 'ANY', + prefAge: 'ANY', + prefNeutered: 'ANY', +}) + +// 프로필 화면에서 편집하는 현재 강아지의 AI 추천 조건 +const prefGender = ref('ANY') +const prefAge = ref('ANY') +const prefNeutered = ref('ANY') + +// 출생 연도(올해 ~ 20년 전) / 월 선택지 +const thisYear = new Date().getFullYear() +const yearOptions = Array.from({ length: 21 }, (_, i) => { + const y = thisYear - i + return { label: `${y}년`, value: y } +}) +const monthOptions = Array.from({ length: 12 }, (_, i) => ({ label: `${i + 1}월`, value: i + 1 })) + +// 선택한 출생 연·월 → 연령 텍스트 (예: 2살 3개월) +const ageText = computed(() => { + if (form.birthYear == null || form.birthMonth == null) return '' + return ageFromBirth(form.birthYear, form.birthMonth) }) onMounted(async () => { @@ -199,20 +372,26 @@ onMounted(async () => { } }) -// 대표 강아지가 바뀌면 선택된 성향 동기화 +// 대표 강아지가 바뀌면 선택된 성향 + AI 추천 조건 동기화 watch( current, (d) => { selected.value = new Set(d?.traits.map((t) => t.id) ?? []) + prefGender.value = d?.prefGender ?? 'ANY' + prefAge.value = d?.prefAge ?? 'ANY' + prefNeutered.value = d?.prefNeutered ?? 'ANY' }, { immediate: true }, ) -// 변경 감지(저장 버튼 활성화) +// 변경 감지(저장 버튼 활성화) — 성향 태그 또는 AI 추천 조건 변경 시 const dirty = computed(() => { const orig = new Set(current.value?.traits.map((t) => t.id) ?? []) if (orig.size !== selected.value.size) return true for (const id of selected.value) if (!orig.has(id)) return true + if (prefGender.value !== (current.value?.prefGender ?? 'ANY')) return true + if (prefAge.value !== (current.value?.prefAge ?? 'ANY')) return true + if (prefNeutered.value !== (current.value?.prefNeutered ?? 'ANY')) return true return false }) @@ -226,6 +405,18 @@ function sizeLabel(size: string) { return sizeOptions.find((o) => o.value === size)?.label ?? size } +function genderLabel(gender: string) { + return gender === 'MALE' ? '남아' : gender === 'FEMALE' ? '여아' : gender +} + +/** birthDate(YYYY-MM-DD) → 연령 텍스트. 없으면 빈 문자열. */ +function dogAge(birthDate: string | null): string { + if (!birthDate) return '' + const [y, m] = birthDate.split('-').map(Number) + if (!y || !m) return '' + return ageFromBirth(y, m) +} + async function saveTraits() { if (!current.value) return saving.value = true @@ -238,9 +429,12 @@ async function saveTraits() { gender: current.value.gender, neutered: current.value.neutered, imageUrl: current.value.imageUrl, + prefGender: prefGender.value, + prefAge: prefAge.value, + prefNeutered: prefNeutered.value, traitIds: [...selected.value], }) - $q.notify({ color: 'primary', message: '성향을 저장했어요.', icon: 'check', position: 'top' }) + $q.notify({ color: 'primary', message: '저장했어요.', icon: 'check', position: 'top' }) } catch (e) { notifyError(e) } finally { @@ -252,7 +446,13 @@ function openCreate() { form.name = '' form.breed = '' form.size = null + form.gender = null + form.birthYear = null + form.birthMonth = null form.neutered = null + form.prefGender = 'ANY' + form.prefAge = 'ANY' + form.prefNeutered = 'ANY' createOpen.value = true } @@ -260,11 +460,21 @@ async function onCreate() { if (form.neutered === null) return creating.value = true try { + // 출생 연·월 → birthDate (해당 월 1일). 둘 다 선택해야 저장. + const birthDate = + form.birthYear != null && form.birthMonth != null + ? `${form.birthYear}-${String(form.birthMonth).padStart(2, '0')}-01` + : null await dogs.createDog({ name: form.name, breed: form.breed || null, size: form.size, + gender: form.gender, + birthDate, neutered: form.neutered, + prefGender: form.prefGender, + prefAge: form.prefAge, + prefNeutered: form.prefNeutered, }) createOpen.value = false $q.notify({ color: 'primary', message: `${form.name} 등록 완료!`, icon: 'pets', position: 'top' }) @@ -275,6 +485,17 @@ async function onCreate() { } } +/** 출생 연·월로 연령 텍스트 계산 (예: 2살 3개월 / 5개월). */ +function ageFromBirth(year: number, month: number): string { + const now = new Date() + let months = (now.getFullYear() - year) * 12 + (now.getMonth() + 1 - month) + if (months < 0) months = 0 + const y = Math.floor(months / 12) + const mo = months % 12 + if (y <= 0) return `${mo}개월` + return mo > 0 ? `${y}살 ${mo}개월` : `${y}살` +} + async function onDelete() { if (!current.value) return $q.dialog({