Files
QUZ/frontend/src/views/SettingsView.vue
T
2026-07-05 09:12:04 +00:00

208 lines
8.0 KiB
Vue

<template>
<div class="p-8 max-w-4xl mx-auto space-y-8">
<!-- Header -->
<div class="flex items-center justify-between pb-6 border-b border-base-content/10">
<div class="flex items-center gap-4">
<div class="bg-primary/10 p-3 rounded-2xl text-primary shadow-lg shadow-primary/5">
<KeyIcon :size="28" />
</div>
<div>
<h1 class="text-3xl font-black tracking-tight">Gemini API 설정</h1>
<p class="text-sm opacity-60 mt-1">상세 해설 생성 주관식 채점에 사용되는 API 키를 여러 관리합니다.</p>
</div>
</div>
</div>
<!-- Preferred Model Config Section -->
<div class="bg-base-200 border border-base-content/5 rounded-3xl p-6 shadow-xl space-y-4">
<h2 class="text-lg font-bold flex items-center gap-2">
<SparklesIcon :size="20" class="text-primary" />
우선 시도 모델 설정
</h2>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<select
v-model="preferredModel"
@change="handleModelChange"
class="select select-bordered w-full sm:w-80 bg-base-100 font-medium focus:select-primary"
>
<option value="gemini-3.1-flash-lite">gemini-3.1-flash-lite (Lite / 권장)</option>
<option value="gemini-3.5-flash">gemini-3.5-flash</option>
<option value="gemini-2.5-flash">gemini-2.5-flash</option>
<option value="gemini-2.0-flash-lite">gemini-2.0-flash-lite</option>
<option value="gemini-1.5-flash">gemini-1.5-flash</option>
</select>
<p class="text-xs opacity-60 leading-normal">
API 호출 여기서 선택한 우선시도 모델이 가장 먼저 적용됩니다.<br />
만약 에러가 발생할 경우, 남은 키로 시도한 다른 모델로 순차 폴백(fallback)합니다.
</p>
</div>
</div>
<!-- API Key Input Section -->
<div class="bg-base-200 border border-base-content/5 rounded-3xl p-6 shadow-xl space-y-4">
<h2 class="text-lg font-bold flex items-center gap-2">
<PlusCircleIcon :size="20" class="text-primary" />
API 추가
</h2>
<div class="flex flex-col md:flex-row gap-3">
<input
type="text"
v-model="newKey"
placeholder="AIzaSy..."
class="input input-bordered flex-1 bg-base-100 font-mono text-sm focus:input-primary"
@keyup.enter="handleAddKey"
/>
<button class="btn btn-primary px-8 font-bold" @click="handleAddKey" :disabled="!newKey.trim()">
<PlusIcon :size="18" class="mr-1" />
추가
</button>
</div>
<p class="text-xs opacity-50 px-1">
입력한 키는 로컬 데이터베이스에 안전하게 저장됩니다. 여러 키가 등록되면 무작위로 선택되어 분산 처리됩니다.
</p>
</div>
<!-- API Keys List -->
<div class="bg-base-200 border border-base-content/5 rounded-3xl p-6 shadow-xl space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold flex items-center gap-2">
<ListIcon :size="20" class="text-primary" />
등록된 API 목록
<div class="badge badge-primary badge-sm font-black">{{ keys.length }}</div>
</h2>
<button class="btn btn-ghost btn-xs opacity-60 hover:opacity-100" @click="fetchKeys">
새로고침
</button>
</div>
<div v-if="loading" class="flex justify-center py-8">
<span class="loading loading-spinner loading-md text-primary"></span>
</div>
<div v-else-if="keys.length === 0" class="text-center py-12 border-2 border-dashed border-base-content/10 rounded-2xl">
<KeyRoundIcon :size="48" class="mx-auto opacity-20 mb-3" />
<p class="font-bold opacity-60">등록된 API 키가 없습니다.</p>
<p class="text-xs opacity-40 mt-1">상단의 입력란을 통해 번째 API 키를 추가해 주세요.</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(key, index) in keys"
:key="key"
class="flex items-center justify-between p-4 bg-base-100 border border-base-content/5 rounded-2xl hover:border-primary/20 transition-all group"
>
<div class="flex items-center gap-4 overflow-hidden">
<span class="text-xs font-mono opacity-40 shrink-0">#{{ index + 1 }}</span>
<div class="font-mono text-sm tracking-wider truncate select-all">
{{ maskKey(key) }}
</div>
</div>
<button
class="btn btn-error btn-ghost btn-sm btn-square opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
@click="handleDeleteKey(key)"
title="삭제"
>
<Trash2Icon :size="16" />
</button>
</div>
</div>
</div>
<!-- Retry logic explanation -->
<div class="bg-primary/5 border border-primary/10 rounded-3xl p-6 space-y-3">
<h3 class="font-black text-primary flex items-center gap-2">
<ShieldAlertIcon :size="18" />
작동 프로세스 (자동 모델/ 폴백)
</h3>
<ul class="text-xs space-y-2 opacity-85 list-disc pl-5 leading-relaxed">
<li>등록된 API 목록을 <strong>랜덤하게 섞은(shuffle) </strong> 순차적으로 API 호출을 시도합니다.</li>
<li>특정 키에서 호출 에러(: 429 Rate Limit ) 발생하면, 대기나 내부 재시도 없이 <strong>즉시 남은 다른 키로 순서대로 자동 재시도</strong>합니다.</li>
<li>모든 키에 대해 에러가 발생하면, 선택된 <strong>우선 시도 모델</strong>부터 시작하여 나머지 지원 모델(<strong>gemini-3.1-flash-lite &rarr; gemini-3.5-flash &rarr; gemini-2.5-flash &rarr; gemini-2.0-flash-lite &rarr; gemini-1.5-flash</strong>) 단계를 순차적으로 낮춰가며 다시 키별 즉시 시도를 실행합니다.</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { KeyIcon, PlusCircleIcon, PlusIcon, ListIcon, KeyRoundIcon, Trash2Icon, ShieldAlertIcon, SparklesIcon } from 'lucide-vue-next'
import { GetGeminiKeys, AddGeminiKey, DeleteGeminiKey, GetPreferredModel, SetPreferredModel } from '../../bindings/changeme/quizservice'
import { useQuizStore } from '../store/quiz'
import { useAuthStore } from '../store/auth'
const store = useQuizStore()
const authStore = useAuthStore()
const keys = ref<string[]>([])
const newKey = ref('')
const loading = ref(false)
const preferredModel = ref('gemini-3.1-flash-lite')
async function fetchKeys() {
loading.value = true
try {
const list = await GetGeminiKeys()
keys.value = list || []
store.setGeminiKeysCount(keys.value.length)
} catch (err) {
console.error('Failed to fetch Gemini API keys:', err)
} finally {
loading.value = false
}
}
async function fetchPreferredModel() {
try {
const model = await GetPreferredModel()
if (model) {
preferredModel.value = model
}
} catch (err) {
console.error('Failed to fetch preferred Gemini model:', err)
}
}
async function handleModelChange() {
try {
await SetPreferredModel(preferredModel.value)
} catch (err) {
alert(`우선 모델 저장 실패: ${err}`)
}
}
async function handleAddKey() {
const trimmed = newKey.value.trim()
if (!trimmed) return
try {
await AddGeminiKey(trimmed)
newKey.value = ''
await fetchKeys()
} catch (err) {
alert(`API 키 추가 실패: ${err}`)
}
}
async function handleDeleteKey(key: string) {
if (!confirm('이 API 키를 삭제하시겠습니까?')) return
try {
await DeleteGeminiKey(key)
await fetchKeys()
} catch (err) {
alert(`API 키 삭제 실패: ${err}`)
}
}
function maskKey(key: string): string {
if (key.length <= 8) return '****'
return `${key.slice(0, 6)}...${key.slice(-4)}`
}
onMounted(() => {
fetchKeys()
fetchPreferredModel()
})
</script>