feat: 중고 판매 장비별 AI 시세 분석 기능 추가

- server/api/ai/market-price.post.ts: 시세 분석 전용 스트리밍 API 추가
  (현재 시세 범위 / 희망가 평가 / 추천 판매가 / 판매 팁)
- UsedSalesMarketPriceAnalysis: 스트리밍 분석 결과 표시 모달 컴포넌트 추가
  (USkeleton 로딩 / whitespace-pre-wrap 텍스트 / 재분석 버튼)
- used-sales/index: sparkles 버튼으로 모달 연동
This commit is contained in:
hyeonggil
2026-03-08 21:27:21 +09:00
parent ce9fce5d44
commit f3ebb6002d
3 changed files with 226 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { PLATFORM_LABELS } from '~/types/used-sale'
import type { UsedSale } from '~/types/used-sale'
const props = defineProps<{
open: boolean
sale: UsedSale | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const streamingContent = ref('')
const isStreaming = ref(false)
const error = ref<string | null>(null)
async function startAnalysis() {
if (!props.sale) return
streamingContent.value = ''
isStreaming.value = true
error.value = null
try {
const response = await fetch('/api/ai/market-price', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemName: props.sale.item_name,
platform: PLATFORM_LABELS[props.sale.platform],
salePrice: props.sale.sale_price
})
})
if (!response.ok) {
throw new Error(`분석 요청 실패 (${response.status})`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
streamingContent.value += decoder.decode(value, { stream: true })
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '분석 중 오류가 발생했습니다'
} finally {
isStreaming.value = false
}
}
// 모달이 열릴 때 자동으로 분석 시작
watch(() => props.open, (isOpen) => {
if (isOpen && props.sale) {
startAnalysis()
}
})
function formatPrice(price: number) {
return price.toLocaleString('ko-KR') + '원'
}
</script>
<template>
<UModal
:open="open"
title="AI 시세 분석"
description="AI가 현재 중고 시장 시세를 분석합니다"
:ui="{ body: 'space-y-4' }"
@update:open="emit('update:open', $event)"
>
<template #body>
<!-- 장비 정보 -->
<div v-if="sale" class="flex flex-wrap gap-2">
<UBadge color="neutral" variant="outline" size="lg">
<UIcon name="i-lucide-tent" class="mr-1" />
{{ sale.item_name }}
</UBadge>
<UBadge color="neutral" variant="outline" size="lg">
<UIcon name="i-lucide-store" class="mr-1" />
{{ PLATFORM_LABELS[sale.platform] }}
</UBadge>
<UBadge color="primary" variant="subtle" size="lg">
<UIcon name="i-lucide-tag" class="mr-1" />
{{ formatPrice(sale.sale_price) }}
</UBadge>
</div>
<!-- 분석 결과 영역 -->
<div class="min-h-40 rounded-lg bg-gray-50 dark:bg-gray-900 p-4">
<!-- 로딩 스켈레톤 -->
<div v-if="isStreaming && !streamingContent" class="space-y-3">
<USkeleton class="h-4 w-3/4" />
<USkeleton class="h-4 w-full" />
<USkeleton class="h-4 w-5/6" />
</div>
<!-- 스트리밍 텍스트 -->
<pre
v-else-if="streamingContent"
class="whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-200 font-sans leading-relaxed"
>{{ streamingContent }}</pre>
<!-- 에러 -->
<UAlert
v-else-if="error"
color="error"
icon="i-lucide-alert-circle"
:description="error"
/>
<!-- 대기 상태 (아직 아무것도 없을 ) -->
<div v-else class="flex items-center justify-center h-32 text-gray-400 dark:text-gray-600">
<UIcon name="i-lucide-sparkles" class="text-2xl" />
</div>
</div>
<!-- 스트리밍 인디케이터 -->
<div v-if="isStreaming && streamingContent" class="flex items-center gap-2 text-sm text-gray-500">
<span class="inline-block w-2 h-2 bg-primary-500 rounded-full animate-pulse" />
분석 ...
</div>
</template>
<template #footer>
<div class="flex justify-between w-full">
<UButton
icon="i-lucide-refresh-cw"
color="neutral"
variant="outline"
label="재분석"
:disabled="isStreaming"
@click="startAnalysis"
/>
<UButton
label="닫기"
color="neutral"
variant="ghost"
@click="emit('update:open', false)"
/>
</div>
</template>
</UModal>
</template>

View File

@@ -13,6 +13,14 @@ const showModal = ref(false)
const editingSale = ref<UsedSale | undefined>(undefined)
const activeTab = ref<SaleStatus | 'all'>('all')
const showAnalysis = ref(false)
const analyzingSale = ref<UsedSale | null>(null)
function openAnalysis(sale: UsedSale) {
analyzingSale.value = sale
showAnalysis.value = true
}
const tabOptions = [
{ value: 'all', label: '전체' },
{ value: 'listing', label: `판매중 (${byStatus.value.listing.length})` },
@@ -132,6 +140,14 @@ function formatPrice(price?: number) {
<NuxtLink :to="`/used-sales/${row.original.id}`">
<UButton icon="i-lucide-eye" size="sm" color="neutral" variant="ghost" />
</NuxtLink>
<UButton
icon="i-lucide-sparkles"
size="sm"
color="primary"
variant="ghost"
title="AI 시세 분석"
@click="openAnalysis(row.original)"
/>
<UButton
v-if="row.original.status === 'listing' || row.original.status === 'reserved'"
icon="i-lucide-check"
@@ -148,6 +164,12 @@ function formatPrice(price?: number) {
</UTable>
</UCard>
<!-- AI 시세 분석 모달 -->
<UsedSalesMarketPriceAnalysis
v-model:open="showAnalysis"
:sale="analyzingSale"
/>
<!-- Modal -->
<UModal :open="showModal" :title="editingSale ? '판매 수정' : '판매 등록'" @update:open="showModal = $event">
<template #body>

View File

@@ -0,0 +1,57 @@
import Anthropic from '@anthropic-ai/sdk'
const SYSTEM_PROMPT = `당신은 한국 중고 캠핑 장비 시장 전문가입니다.
사용자가 제공한 장비 정보를 바탕으로 중고 시세를 분석하고 판매 전략을 제안합니다.
분석 항목:
1. **현재 시세 범위**: 해당 장비의 국내 중고 시장 시세 (최저~최고)
2. **희망가 평가**: 제시된 희망가가 적정한지, 높은지, 낮은지 평가
3. **추천 판매가**: 빠른 판매와 적정 수익을 고려한 추천 가격
4. **판매 팁**: 플랫폼별 특성을 고려한 판매 전략 및 주의사항
응답 스타일:
- 한국어로 간결하고 실용적으로 답변
- 구체적인 가격대와 근거 제시
- 마크다운 형식으로 구조화된 답변 제공`
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const body = await readBody(event)
const { itemName, platform, salePrice } = body as {
itemName: string
platform: string
salePrice: number
}
const anthropic = new Anthropic({ apiKey: config.anthropicApiKey })
const userMessage = `다음 캠핑 장비의 중고 시세를 분석해주세요.
- 장비명: ${itemName}
- 판매 플랫폼: ${platform}
- 희망 판매가: ${salePrice.toLocaleString('ko-KR')}`
const stream = anthropic.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMessage }]
})
const readableStream = new ReadableStream({
async start(controller) {
stream.on('text', (text) => {
controller.enqueue(new TextEncoder().encode(text))
})
stream.on('finalMessage', () => {
controller.close()
})
stream.on('error', (err) => {
controller.error(err)
})
}
})
return sendStream(event, readableStream)
})