m
This commit is contained in:
+278
-30
@@ -3,14 +3,22 @@
|
||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content flex flex-col h-screen overflow-hidden bg-base-100 text-base-content">
|
||||
<!-- Mobile Navbar -->
|
||||
<div class="w-full navbar bg-base-200 lg:hidden border-b border-base-300 shrink-0">
|
||||
<!-- Mobile Navbar (Only if logged in) -->
|
||||
<div v-if="authStore.isLoggedIn" class="w-full navbar bg-base-200 lg:hidden border-b border-base-300 shrink-0">
|
||||
<div class="flex-none">
|
||||
<label for="main-drawer" class="btn btn-square btn-ghost">
|
||||
<MenuIcon :size="24" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 px-2 mx-2 font-black text-xl">CBT 마스터</div>
|
||||
<div @click="goHome" class="flex-1 px-2 mx-2 font-black text-xl cursor-pointer active:opacity-80 select-none whitespace-nowrap">CBT 마스터</div>
|
||||
<div class="flex-none gap-2 px-2">
|
||||
<span
|
||||
@click="openProfileModal"
|
||||
class="text-xs opacity-75 font-semibold bg-base-300 hover:bg-base-100 px-2.5 py-1 rounded-full cursor-pointer transition-colors active:scale-95 whitespace-nowrap"
|
||||
>
|
||||
{{ authStore.nickname }}님
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
@@ -25,16 +33,16 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side z-50">
|
||||
<!-- Sidebar (Only if logged in) -->
|
||||
<div v-if="authStore.isLoggedIn" class="drawer-side z-50">
|
||||
<label for="main-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<aside class="w-72 min-h-full bg-base-200 border-r border-base-300 flex flex-col shrink-0">
|
||||
<div class="p-8">
|
||||
<div class="p-8 cursor-pointer select-none active:scale-[0.98] transition-transform" @click="goHome">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary p-2 rounded-xl shadow-lg shadow-primary/20 text-primary-content">
|
||||
<TrophyIcon :size="24" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-black tracking-tight">CBT 마스터</h1>
|
||||
<h1 class="text-2xl font-black tracking-tight whitespace-nowrap">CBT 마스터</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,22 +59,49 @@
|
||||
<BookmarkIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
책갈피
|
||||
</router-link>
|
||||
|
||||
<!-- Gemini Menu Link (Admin Only) -->
|
||||
<router-link v-if="authStore.isAdmin" to="/settings" class="flex items-center gap-4 px-5 py-4 rounded-2xl hover:bg-base-300 transition-all font-bold group" active-class="bg-primary text-primary-content shadow-lg shadow-primary/20" @click="closeDrawer">
|
||||
<KeyIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
Gemini 설정
|
||||
</router-link>
|
||||
|
||||
<!-- Admin Menu Link -->
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/users" class="flex items-center gap-4 px-5 py-4 rounded-2xl hover:bg-base-300 transition-all font-bold group text-secondary" active-class="bg-secondary text-secondary-content shadow-lg shadow-secondary/20" @click="closeDrawer">
|
||||
<UsersIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
회원 및 권한 관리
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="p-6 m-4 bg-base-300 rounded-3xl border border-base-content/5 space-y-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3 px-2">
|
||||
<KeyIcon :size="16" class="text-primary" />
|
||||
<span class="text-xs font-black uppercase tracking-widest opacity-60">Gemini API Key</span>
|
||||
<!-- User Information & Logout -->
|
||||
<div class="px-6 py-4 border-t border-base-content/5 space-y-3 shrink-0">
|
||||
<div class="flex items-center justify-between px-2 gap-2">
|
||||
<div @click="openProfileModal" class="flex flex-col cursor-pointer group/user select-none min-w-0 flex-1 mr-1">
|
||||
<span class="text-sm font-bold text-base-content group-hover/user:text-primary transition-colors flex items-center gap-1 truncate whitespace-nowrap">
|
||||
{{ authStore.nickname }}
|
||||
<UserCogIcon :size="14" class="opacity-0 group-hover/user:opacity-100 transition-opacity shrink-0" />
|
||||
</span>
|
||||
<span class="text-xs opacity-60 truncate whitespace-nowrap">@{{ authStore.username }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
v-model="geminiKey"
|
||||
@change="updateKey"
|
||||
placeholder="Paste key here..."
|
||||
class="input input-bordered input-sm w-full bg-base-100 font-mono text-xs focus:input-primary"
|
||||
/>
|
||||
<p class="text-[10px] mt-3 px-2 opacity-40 font-medium">상세 해설시 사용</p>
|
||||
<button @click="handleLogout" class="btn btn-ghost btn-xs btn-outline hover:btn-error font-bold rounded-lg px-2.5 whitespace-nowrap shrink-0">
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini Settings Card (Admin Only) -->
|
||||
<div v-if="authStore.isAdmin" class="p-6 m-4 mt-0 bg-base-300 rounded-3xl border border-base-content/5 space-y-4">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3 px-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyIcon :size="16" class="text-primary" />
|
||||
<span class="text-xs font-black uppercase tracking-widest opacity-60">Gemini Keys</span>
|
||||
</div>
|
||||
<span class="badge badge-primary badge-sm font-black">{{ store.geminiKeysCount }}</span>
|
||||
</div>
|
||||
<router-link to="/settings" class="btn btn-outline btn-sm btn-primary w-full text-xs font-bold" @click="closeDrawer">
|
||||
API 키 관리하기
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-base-content/5 pt-4">
|
||||
@@ -83,34 +118,247 @@
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info & Change Password Modal -->
|
||||
<div v-if="showProfileModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div class="bg-base-200 border border-base-content/10 w-full max-w-md p-6 sm:p-8 rounded-3xl shadow-2xl relative space-y-6">
|
||||
<!-- Close Button -->
|
||||
<button @click="closeProfileModal" class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4">✕</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-2.5 rounded-2xl text-primary">
|
||||
<UserIcon :size="24" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-black">내 회원 정보</h3>
|
||||
<p class="text-xs opacity-60">현재 계정의 가입 정보를 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info Details -->
|
||||
<div class="bg-base-300/50 p-4 rounded-2xl border border-base-content/5 space-y-3">
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<span class="opacity-60 font-bold">닉네임</span>
|
||||
<span class="font-black text-sm">{{ authStore.nickname }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<span class="opacity-60 font-bold">아이디</span>
|
||||
<span class="font-mono font-bold">{{ authStore.user?.username }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<span class="opacity-60 font-bold">연락처</span>
|
||||
<span class="font-bold">{{ authStore.user?.phone }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<span class="opacity-60 font-bold">API 키 등록 여부</span>
|
||||
<span
|
||||
class="badge badge-xs font-bold"
|
||||
:class="authStore.user?.apiKey ? 'badge-info text-info-content' : 'badge-neutral opacity-60'"
|
||||
>
|
||||
{{ authStore.user?.apiKey ? '등록됨' : '미등록' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Registration / Update -->
|
||||
<div class="border-t border-base-content/10 pt-4 space-y-3">
|
||||
<h4 class="text-sm font-bold flex items-center gap-2">
|
||||
<KeyIcon :size="16" class="text-primary" />
|
||||
Gemini API 키 등록
|
||||
</h4>
|
||||
<p class="text-[10px] opacity-60">
|
||||
등록한 API 키는 AI 해설과 채점 기능에 활용됩니다. (보안을 위해 기존에 저장된 목록은 조회할 수 없습니다)
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="modalApiKey"
|
||||
type="password"
|
||||
placeholder="새로운 API 키 입력"
|
||||
class="input input-bordered input-sm flex-1 bg-base-100/50 focus:input-primary py-3"
|
||||
/>
|
||||
<button
|
||||
@click="handleRegisterApiKey"
|
||||
:disabled="registeringApiKey"
|
||||
class="btn btn-primary btn-sm font-bold rounded-xl px-4 h-auto whitespace-nowrap"
|
||||
>
|
||||
<span v-if="registeringApiKey" class="loading loading-spinner loading-xs mr-0.5"></span>
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Change Form Inside Modal -->
|
||||
<div class="border-t border-base-content/10 pt-4 space-y-4">
|
||||
<h4 class="text-sm font-bold flex items-center gap-2">
|
||||
<LockIcon :size="16" class="text-primary" />
|
||||
비밀번호 변경
|
||||
</h4>
|
||||
|
||||
<form @submit.prevent="handleModalPasswordChange" class="space-y-3">
|
||||
<div class="form-control">
|
||||
<input
|
||||
v-model="modalOldPassword"
|
||||
type="password"
|
||||
required
|
||||
placeholder="현재 비밀번호"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<input
|
||||
v-model="modalNewPassword"
|
||||
type="password"
|
||||
required
|
||||
placeholder="새 비밀번호"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<input
|
||||
v-model="modalNewPasswordConfirm"
|
||||
type="password"
|
||||
required
|
||||
placeholder="새 비밀번호 확인"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="modalChangingPassword"
|
||||
class="btn btn-primary btn-sm w-full font-bold py-2 h-auto rounded-xl"
|
||||
>
|
||||
<span v-if="modalChangingPassword" class="loading loading-spinner loading-xs mr-1"></span>
|
||||
비밀번호 변경 적용
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { LayoutDashboardIcon, BookOpenIcon, KeyIcon, TrophyIcon, MenuIcon, ShuffleIcon, BookmarkIcon } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LayoutDashboardIcon, BookOpenIcon, KeyIcon, TrophyIcon, MenuIcon, ShuffleIcon, BookmarkIcon, UsersIcon, UserIcon, UserCogIcon, LockIcon } from 'lucide-vue-next'
|
||||
import { useQuizStore } from './store/quiz'
|
||||
import { SetGeminiKey } from '../bindings/changeme/quizservice'
|
||||
import { useAuthStore } from './store/auth'
|
||||
import { GetGeminiKeys, ChangePassword, UpdateUserApiKey } from '../bindings/changeme/quizservice'
|
||||
|
||||
const store = useQuizStore()
|
||||
const geminiKey = ref(store.geminiKey)
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
function goHome() {
|
||||
closeDrawer()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const randomizeQuestions = computed({
|
||||
get: () => store.randomizeQuestions,
|
||||
set: (val) => store.setRandomizeQuestions(val)
|
||||
})
|
||||
|
||||
async function updateKeysCount() {
|
||||
try {
|
||||
const list = await GetGeminiKeys()
|
||||
store.setGeminiKeysCount(list ? list.length : 0)
|
||||
} catch (err) {
|
||||
console.error('Failed to load Gemini keys count:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Profile modal state
|
||||
const showProfileModal = ref(false)
|
||||
const modalOldPassword = ref('')
|
||||
const modalNewPassword = ref('')
|
||||
const modalNewPasswordConfirm = ref('')
|
||||
const modalChangingPassword = ref(false)
|
||||
|
||||
const modalApiKey = ref('')
|
||||
const registeringApiKey = ref(false)
|
||||
|
||||
function openProfileModal() {
|
||||
closeDrawer()
|
||||
showProfileModal.value = true
|
||||
}
|
||||
|
||||
function closeProfileModal() {
|
||||
showProfileModal.value = false
|
||||
modalOldPassword.value = ''
|
||||
modalNewPassword.value = ''
|
||||
modalNewPasswordConfirm.value = ''
|
||||
modalApiKey.value = ''
|
||||
}
|
||||
|
||||
async function handleModalPasswordChange() {
|
||||
const oldPw = modalOldPassword.value.trim()
|
||||
const newPw = modalNewPassword.value.trim()
|
||||
const newPwConfirm = modalNewPasswordConfirm.value.trim()
|
||||
|
||||
if (!oldPw || !newPw || !newPwConfirm) {
|
||||
alert('모든 항목을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPw !== newPwConfirm) {
|
||||
alert('새 비밀번호가 확인 비밀번호와 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
modalChangingPassword.value = true
|
||||
try {
|
||||
await ChangePassword(authStore.username, oldPw, newPw)
|
||||
alert('비밀번호가 성공적으로 변경되었습니다.')
|
||||
closeProfileModal()
|
||||
} catch (err: any) {
|
||||
alert(`비밀번호 변경 실패: ${err.message || String(err)}`)
|
||||
} finally {
|
||||
modalChangingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegisterApiKey() {
|
||||
const key = modalApiKey.value.trim()
|
||||
if (!key) {
|
||||
alert('등록할 API 키를 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
registeringApiKey.value = true
|
||||
try {
|
||||
await UpdateUserApiKey(authStore.username, key)
|
||||
alert('API 키가 성공적으로 등록/변경되었습니다.')
|
||||
|
||||
// Update local Pinia user state
|
||||
if (authStore.user) {
|
||||
authStore.user.apiKey = key
|
||||
localStorage.setItem('user', JSON.stringify(authStore.user))
|
||||
}
|
||||
|
||||
modalApiKey.value = ''
|
||||
if (authStore.isAdmin) {
|
||||
await updateKeysCount()
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`API 키 등록 실패: ${err.message || String(err)}`)
|
||||
} finally {
|
||||
registeringApiKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (geminiKey.value) {
|
||||
SetGeminiKey(geminiKey.value)
|
||||
if (authStore.isLoggedIn) {
|
||||
updateKeysCount()
|
||||
}
|
||||
})
|
||||
|
||||
function updateKey() {
|
||||
store.setGeminiKey(geminiKey.value)
|
||||
SetGeminiKey(geminiKey.value)
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
const drawer = document.getElementById('main-drawer') as HTMLInputElement
|
||||
if (drawer) drawer.checked = false
|
||||
|
||||
@@ -3,10 +3,16 @@ import DashboardView from '../views/DashboardView.vue'
|
||||
import CategoryListView from '../views/CategoryListView.vue'
|
||||
import SubjectListView from '../views/SubjectListView.vue'
|
||||
import QuizView from '../views/QuizView.vue'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
@@ -43,8 +49,31 @@ const router = createRouter({
|
||||
difficulty: route.params.difficulty,
|
||||
bookmarkOnly: true
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'admin-users',
|
||||
component: () => import('../views/AdminUserView.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
if (to.name !== 'login' && !authStore.isLoggedIn) {
|
||||
next({ name: 'login' })
|
||||
} else if (to.path.startsWith('/admin') && !authStore.isAdmin) {
|
||||
next({ name: 'dashboard' })
|
||||
} else if (to.name === 'login' && authStore.isLoggedIn) {
|
||||
next({ name: 'dashboard' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
phone: string
|
||||
status: string
|
||||
apiKey: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null') as User | null,
|
||||
}),
|
||||
getters: {
|
||||
isLoggedIn(): boolean {
|
||||
return this.user !== null
|
||||
},
|
||||
isAdmin(): boolean {
|
||||
return this.user?.username === 'admin'
|
||||
},
|
||||
username(): string {
|
||||
return this.user?.username || 'guest'
|
||||
},
|
||||
nickname(): string {
|
||||
return this.user?.nickname || '게스트'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
login(user: User) {
|
||||
this.user = user
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
},
|
||||
logout() {
|
||||
this.user = null
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ export const useQuizStore = defineStore('quiz', {
|
||||
state: () => ({
|
||||
geminiKey: localStorage.getItem('geminiKey') || '',
|
||||
randomizeQuestions: localStorage.getItem('randomizeQuestions') === 'true',
|
||||
geminiKeysCount: 0,
|
||||
}),
|
||||
actions: {
|
||||
setGeminiKey(key: string) {
|
||||
@@ -13,6 +14,9 @@ export const useQuizStore = defineStore('quiz', {
|
||||
setRandomizeQuestions(randomize: boolean) {
|
||||
this.randomizeQuestions = randomize
|
||||
localStorage.setItem('randomizeQuestions', String(randomize))
|
||||
},
|
||||
setGeminiKeysCount(count: number) {
|
||||
this.geminiKeysCount = count
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-6xl mx-auto space-y-8 select-none" data-theme="dark">
|
||||
<!-- 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-secondary/10 p-3 rounded-2xl text-secondary shadow-lg shadow-secondary/5">
|
||||
<UsersIcon :size="28" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-black tracking-tight">회원 및 권한 관리</h1>
|
||||
<p class="text-sm opacity-60 mt-1">회원가입 승인 대기 처리 및 회원별 접근 가능한 카테고리를 설정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Left / Top: Approval Pending List -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<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">
|
||||
<UserPlusIcon :size="20" class="text-secondary" />
|
||||
가입 승인 대기 목록
|
||||
<div class="badge badge-secondary badge-sm font-black">{{ pendingUsers.length }}</div>
|
||||
</h2>
|
||||
|
||||
<div v-if="pendingLoading" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md text-secondary"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pendingUsers.length === 0" class="text-center py-10 border border-dashed border-base-content/10 rounded-2xl">
|
||||
<p class="opacity-55 text-sm font-bold">승인 대기 중인 회원가입 신청이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 max-h-80 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="user in pendingUsers"
|
||||
:key="user.username"
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-base-100 border border-base-content/5 rounded-2xl gap-4"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-black text-sm whitespace-nowrap">{{ user.nickname }}</span>
|
||||
<span class="text-xs opacity-50 font-mono whitespace-nowrap">@{{ user.username }}</span>
|
||||
<span
|
||||
class="badge badge-xs font-bold whitespace-nowrap"
|
||||
:class="user.apiKey ? 'badge-info text-info-content' : 'badge-neutral opacity-60'"
|
||||
>
|
||||
{{ user.apiKey ? 'API 키 등록됨' : 'API 키 없음' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs opacity-75 flex flex-wrap gap-x-4">
|
||||
<span class="whitespace-nowrap">연락처: {{ user.phone }}</span>
|
||||
<span class="whitespace-nowrap">신청일: {{ formatDate(user.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 self-end sm:self-center shrink-0">
|
||||
<button @click="handleApprove(user.username)" class="btn btn-success btn-sm font-bold rounded-xl px-4 whitespace-nowrap">
|
||||
승인
|
||||
</button>
|
||||
<button @click="handleReject(user.username)" class="btn btn-error btn-sm btn-ghost hover:btn-error font-bold rounded-xl px-4 whitespace-nowrap">
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Users List -->
|
||||
<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">
|
||||
<ListIcon :size="20" class="text-secondary" />
|
||||
전체 회원 목록
|
||||
<div class="badge badge-secondary badge-sm font-black">{{ allUsers.length }}</div>
|
||||
</h2>
|
||||
|
||||
<div v-if="usersLoading" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md text-secondary"></span>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-transparent border-b border-base-content/5 text-xs font-black uppercase opacity-60 whitespace-nowrap">사용자</th>
|
||||
<th class="bg-transparent border-b border-base-content/5 text-xs font-black uppercase opacity-60 whitespace-nowrap">연락처</th>
|
||||
<th class="bg-transparent border-b border-base-content/5 text-xs font-black uppercase opacity-60 whitespace-nowrap">API 키</th>
|
||||
<th class="bg-transparent border-b border-base-content/5 text-xs font-black uppercase opacity-60 whitespace-nowrap">상태</th>
|
||||
<th class="bg-transparent border-b border-base-content/5 text-xs font-black uppercase opacity-60 text-center whitespace-nowrap">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in allUsers"
|
||||
:key="user.username"
|
||||
class="hover:bg-base-100/30 cursor-pointer"
|
||||
:class="selectedUser?.username === user.username ? 'bg-secondary/10 hover:bg-secondary/15 font-bold' : ''"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
<td class="border-b border-base-content/5 whitespace-nowrap">
|
||||
<div class="font-bold text-sm">{{ user.nickname }}</div>
|
||||
<div class="text-xs opacity-50 font-mono">@{{ user.username }}</div>
|
||||
</td>
|
||||
<td class="border-b border-base-content/5 text-xs font-semibold whitespace-nowrap">{{ user.phone }}</td>
|
||||
<td class="border-b border-base-content/5 whitespace-nowrap">
|
||||
<span
|
||||
class="badge badge-xs font-bold"
|
||||
:class="user.apiKey ? 'badge-info text-info-content' : 'badge-neutral opacity-40'"
|
||||
>
|
||||
{{ user.apiKey ? '등록됨' : '미등록' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border-b border-base-content/5 whitespace-nowrap">
|
||||
<span
|
||||
class="badge badge-sm font-black uppercase whitespace-nowrap"
|
||||
:class="{
|
||||
'badge-success': user.status === 'approved',
|
||||
'badge-warning': user.status === 'pending',
|
||||
'badge-error': user.status === 'rejected'
|
||||
}"
|
||||
>
|
||||
{{ user.status === 'approved' ? '승인완료' : user.status === 'pending' ? '대기중' : '거절됨' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border-b border-base-content/5 text-center whitespace-nowrap space-x-1">
|
||||
<button
|
||||
@click.stop="selectUser(user)"
|
||||
class="btn btn-xs btn-outline btn-secondary font-bold rounded-lg whitespace-nowrap"
|
||||
:class="selectedUser?.username === user.username ? 'btn-active' : ''"
|
||||
>
|
||||
권한 설정
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username !== 'admin'"
|
||||
@click.stop="openEditModal(user)"
|
||||
class="btn btn-xs btn-warning btn-outline font-bold rounded-lg whitespace-nowrap"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username !== 'admin'"
|
||||
@click.stop="handleDeleteUser(user.username)"
|
||||
class="btn btn-xs btn-error btn-outline font-bold rounded-lg whitespace-nowrap"
|
||||
>
|
||||
탈퇴
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right/Bottom: Category Permission Configuration -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-base-200 border border-base-content/5 rounded-3xl p-6 shadow-xl space-y-6 sticky top-8">
|
||||
<div class="pb-4 border-b border-base-content/10">
|
||||
<h2 class="text-lg font-bold flex items-center gap-2">
|
||||
<ShieldCheckIcon :size="20" class="text-secondary" />
|
||||
카테고리 열람 권한 설정
|
||||
</h2>
|
||||
<p class="text-xs opacity-60 mt-1">목록에서 사용자를 선택하여 허용할 카테고리를 지정하세요.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedUser" class="text-center py-16 opacity-40">
|
||||
<UserCheckIcon :size="48" class="mx-auto mb-3" />
|
||||
<p class="text-sm font-bold">선택된 사용자가 없습니다.</p>
|
||||
<p class="text-xs mt-1">좌측 목록에서 회원을 선택해 주세요.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- User Header -->
|
||||
<div class="bg-base-300 p-4 rounded-2xl border border-base-content/5">
|
||||
<div class="text-xs font-black uppercase tracking-widest opacity-60 mb-1">선택된 사용자</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-black text-lg text-secondary">{{ selectedUser.nickname }}</span>
|
||||
<span class="text-xs opacity-50 font-mono">@{{ selectedUser.username }}</span>
|
||||
</div>
|
||||
<div v-if="selectedUser.username === 'admin'" class="text-xs text-info font-black mt-2">
|
||||
* 관리자는 모든 카테고리에 상시 접근 가능합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories Checklist -->
|
||||
<div v-if="selectedUser.username !== 'admin'" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-bold opacity-80">허용할 카테고리 목록</span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="selectAllCategories" class="btn btn-xs btn-ghost text-[10px] font-bold">전체 선택</button>
|
||||
<button @click="deselectAllCategories" class="btn btn-xs btn-ghost text-[10px] font-bold">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 bg-base-300/40 p-4 rounded-2xl border border-base-content/5 max-h-72 overflow-y-auto">
|
||||
<label
|
||||
v-for="cat in availableCategories"
|
||||
:key="cat.name"
|
||||
class="flex items-center gap-3 hover:bg-base-100/40 px-3 py-2.5 rounded-xl cursor-pointer transition-colors h-14 border border-transparent hover:border-base-content/5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="cat.name"
|
||||
v-model="allowedCategories"
|
||||
class="checkbox checkbox-sm checkbox-secondary shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col text-left min-w-0 flex-1">
|
||||
<span class="text-sm font-bold truncate whitespace-nowrap block" :title="cat.name">{{ cat.name }}</span>
|
||||
<span class="text-[10px] opacity-50 font-bold mt-0.5">문항수: {{ cat.count }}개</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
@click="savePermissions"
|
||||
:disabled="savingPermissions"
|
||||
class="btn btn-secondary w-full font-bold text-base rounded-2xl"
|
||||
>
|
||||
<span v-if="savingPermissions" class="loading loading-spinner"></span>
|
||||
{{ savingPermissions ? '저장 중...' : '권한 설정 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-10 text-xs opacity-60">
|
||||
관리자 계정은 권한 제어 대상이 아닙니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit User Modal (Admin Force Edit) -->
|
||||
<div v-if="showEditModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div class="bg-base-200 border border-base-content/10 w-full max-w-md p-6 sm:p-8 rounded-3xl shadow-2xl relative space-y-6">
|
||||
<button @click="closeEditModal" class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4">✕</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-warning/10 p-2.5 rounded-2xl text-warning">
|
||||
<UsersIcon :size="24" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-black">회원 정보 강제 수정</h3>
|
||||
<p class="text-xs opacity-60">관리자 권한으로 회원의 정보를 수정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleUpdateUserInfo" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-bold text-xs opacity-80">대상 계정명</span>
|
||||
</label>
|
||||
<input
|
||||
:value="editingUser?.username"
|
||||
type="text"
|
||||
disabled
|
||||
class="input input-bordered input-sm w-full bg-base-300 font-mono py-4 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-bold text-xs opacity-80">닉네임</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editNickname"
|
||||
type="text"
|
||||
required
|
||||
placeholder="닉네임 입력"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-warning py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-bold text-xs opacity-80">연락처</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editPhone"
|
||||
type="text"
|
||||
required
|
||||
placeholder="연락처 입력"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-warning py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-bold text-xs opacity-80">새 비밀번호 강제 변경 (선택사항)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="editPassword"
|
||||
type="password"
|
||||
placeholder="미기입 시 기존 비번 유지"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-warning py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updatingUser"
|
||||
class="btn btn-warning btn-sm w-full font-bold py-2.5 h-auto rounded-xl mt-4"
|
||||
>
|
||||
<span v-if="updatingUser" class="loading loading-spinner loading-xs mr-1"></span>
|
||||
회원 정보 강제 저장
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { UsersIcon, UserPlusIcon, ListIcon, ShieldCheckIcon, UserCheckIcon } from 'lucide-vue-next'
|
||||
import { QuizService, CategoryInfo, User } from '../../bindings/changeme'
|
||||
import { UpdateUserInfoForce, DeleteUser } from '../../bindings/changeme/quizservice'
|
||||
|
||||
const pendingUsers = ref<User[]>([])
|
||||
const allUsers = ref<User[]>([])
|
||||
const availableCategories = ref<CategoryInfo[]>([])
|
||||
|
||||
const pendingLoading = ref(false)
|
||||
const usersLoading = ref(false)
|
||||
const savingPermissions = ref(false)
|
||||
|
||||
const selectedUser = ref<User | null>(null)
|
||||
const allowedCategories = ref<string[]>([])
|
||||
|
||||
// Admin force edit state
|
||||
const showEditModal = ref(false)
|
||||
const editingUser = ref<User | null>(null)
|
||||
const editNickname = ref('')
|
||||
const editPhone = ref('')
|
||||
const editPassword = ref('')
|
||||
const updatingUser = ref(false)
|
||||
|
||||
async function fetchPending() {
|
||||
pendingLoading.value = true
|
||||
try {
|
||||
const list = await QuizService.GetPendingRegistrations()
|
||||
pendingUsers.value = list || []
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pending list:', err)
|
||||
} finally {
|
||||
pendingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
usersLoading.value = true
|
||||
try {
|
||||
const list = await QuizService.GetUsers()
|
||||
allUsers.value = list || []
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users:', err)
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const list = await QuizService.GetCategories()
|
||||
availableCategories.value = list || []
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch categories:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(username: string) {
|
||||
try {
|
||||
await QuizService.ApproveRegistration(username)
|
||||
await fetchPending()
|
||||
await fetchUsers()
|
||||
} catch (err) {
|
||||
alert(`승인 처리 실패: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(username: string) {
|
||||
if (!confirm(`@${username} 회원의 가입 신청을 거절하시겠습니까?`)) return
|
||||
try {
|
||||
await QuizService.RejectRegistration(username)
|
||||
await fetchPending()
|
||||
await fetchUsers()
|
||||
} catch (err) {
|
||||
alert(`거절 처리 실패: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function selectUser(user: User) {
|
||||
selectedUser.value = user
|
||||
allowedCategories.value = []
|
||||
if (user.username === 'admin') return
|
||||
|
||||
try {
|
||||
const list = await QuizService.GetUserAllowedCategories(user.username)
|
||||
allowedCategories.value = list || []
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user permissions:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllCategories() {
|
||||
allowedCategories.value = availableCategories.value.map(c => c.name)
|
||||
}
|
||||
|
||||
function deselectAllCategories() {
|
||||
allowedCategories.value = []
|
||||
}
|
||||
|
||||
async function savePermissions() {
|
||||
if (!selectedUser.value) return
|
||||
|
||||
savingPermissions.value = true
|
||||
try {
|
||||
await QuizService.SetUserAllowedCategories(selectedUser.value.username, allowedCategories.value)
|
||||
alert('권한이 성공적으로 저장되었습니다.')
|
||||
} catch (err) {
|
||||
alert(`권한 저장 실패: ${err}`)
|
||||
} finally {
|
||||
savingPermissions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
return dateStr.split(' ')[0] // Simple YYYY-MM-DD
|
||||
}
|
||||
|
||||
function openEditModal(user: User) {
|
||||
editingUser.value = user
|
||||
editNickname.value = user.nickname
|
||||
editPhone.value = user.phone
|
||||
editPassword.value = ''
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
showEditModal.value = false
|
||||
editingUser.value = null
|
||||
editNickname.value = ''
|
||||
editPhone.value = ''
|
||||
editPassword.value = ''
|
||||
}
|
||||
|
||||
async function handleUpdateUserInfo() {
|
||||
if (!editingUser.value) return
|
||||
|
||||
updatingUser.value = true
|
||||
try {
|
||||
await UpdateUserInfoForce(
|
||||
editingUser.value.username,
|
||||
editNickname.value.trim(),
|
||||
editPhone.value.trim(),
|
||||
editPassword.value.trim()
|
||||
)
|
||||
alert('회원 정보가 강제로 수정되었습니다.')
|
||||
closeEditModal()
|
||||
await fetchUsers()
|
||||
} catch (err: any) {
|
||||
alert(`정보 수정 실패: ${err.message || String(err)}`)
|
||||
} finally {
|
||||
updatingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(username: string) {
|
||||
if (!confirm(`@${username} 회원을 탈퇴(완전 삭제) 처리하시겠습니까? 데이터가 유실되며 되돌릴 수 없습니다.`)) return
|
||||
|
||||
try {
|
||||
await DeleteUser(username)
|
||||
alert('회원이 성공적으로 탈퇴 처리되었습니다.')
|
||||
if (selectedUser.value?.username === username) {
|
||||
selectedUser.value = null
|
||||
}
|
||||
await fetchUsers()
|
||||
} catch (err: any) {
|
||||
alert(`탈퇴 처리 실패: ${err.message || String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPending()
|
||||
fetchUsers()
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="p-6 md:p-10 max-w-4xl mx-auto flex flex-col min-h-screen text-base-content">
|
||||
<div class="p-6 md:p-10 max-w-4xl mx-auto flex flex-col min-h-screen text-base-content" @touchstart="handleTouchStart" @touchend="handleTouchEnd">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-8 shrink-0">
|
||||
<button @click="router.back()" class="btn btn-ghost btn-circle shrink-0">
|
||||
@@ -118,8 +118,10 @@ import { ArrowLeftIcon, BookmarkIcon, BookOpenIcon, HelpCircleIcon, PlayIcon } f
|
||||
// Custom Bookmark icons because lucide-vue-next might have variations
|
||||
import { BookmarkXIcon } from 'lucide-vue-next'
|
||||
import { QuizService, CategoryInfo, TargetInfo } from '../../bindings/changeme'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const targetsLoading = ref(false)
|
||||
@@ -131,7 +133,7 @@ const selectedTarget = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const fetched = await QuizService.GetBookmarkedCategories()
|
||||
const fetched = await QuizService.GetBookmarkedCategories(authStore.username)
|
||||
categories.value = fetched || []
|
||||
} catch (e) {
|
||||
console.error("Failed to load bookmarked categories:", e)
|
||||
@@ -146,7 +148,7 @@ async function selectCategory(catName: string) {
|
||||
targets.value = []
|
||||
targetsLoading.value = true
|
||||
try {
|
||||
const fetched = await QuizService.GetBookmarkedTargets(catName)
|
||||
const fetched = await QuizService.GetBookmarkedTargets(authStore.username, catName)
|
||||
targets.value = fetched || []
|
||||
} catch (e) {
|
||||
console.error("Failed to load bookmarked targets:", e)
|
||||
@@ -164,6 +166,27 @@ function startBookmarkedQuiz() {
|
||||
// Move to bookmarked quiz path
|
||||
router.push(`/bookmarks/quiz/${encodeURIComponent(selectedCategory.value)}/${encodeURIComponent(selectedTarget.value)}`)
|
||||
}
|
||||
|
||||
// Touch swipe gesture logic
|
||||
const touchStartX = ref(0)
|
||||
const touchStartY = ref(0)
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchStartX.value = e.touches[0].clientX
|
||||
touchStartY.value = e.touches[0].clientY
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
const deltaX = e.changedTouches[0].clientX - touchStartX.value
|
||||
const deltaY = e.changedTouches[0].clientY - touchStartY.value
|
||||
|
||||
// Swipe left detection: deltaX < -70, vertical offset deltaY < 50
|
||||
if (deltaX < -70 && Math.abs(deltaY) < 50) {
|
||||
if (selectedCategory.value) {
|
||||
router.push(`/bookmarks/quiz/${encodeURIComponent(selectedCategory.value)}/All`)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -38,13 +38,15 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { QuizService, CategoryInfo } from '../../bindings/changeme'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const categories = ref<CategoryInfo[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
categories.value = await QuizService.GetCategories()
|
||||
categories.value = await QuizService.GetCategoriesForUser(authStore.username)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch categories", e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-base-300 via-base-100 to-base-300 py-12 px-6 lg:px-8 select-none">
|
||||
<div class="max-w-md w-full space-y-8 bg-base-200/50 backdrop-blur-xl border border-base-content/5 p-8 sm:p-10 rounded-3xl shadow-2xl">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<div class="inline-flex bg-primary/10 p-4 rounded-3xl text-primary mb-4 shadow-lg shadow-primary/5">
|
||||
<TrophyIcon :size="36" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-black tracking-tight text-base-content">
|
||||
CBT 마스터
|
||||
</h2>
|
||||
<p class="mt-2 text-sm opacity-60">
|
||||
{{ isRegisterMode ? '회원가입을 진행하여 학습을 시작하세요' : '학습 계정으로 로그인하세요' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form v-if="!isRegisterMode" class="mt-8 space-y-6" @submit.prevent="handleLogin">
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-bold opacity-80">아이디</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="loginId"
|
||||
type="text"
|
||||
required
|
||||
placeholder="username"
|
||||
class="input input-bordered w-full bg-base-100/50 focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-bold opacity-80">비밀번호</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="loginPw"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
class="input input-bordered w-full bg-base-100/50 focus:input-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="text-error text-xs font-bold text-center bg-error/10 p-3 rounded-xl border border-error/10">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary w-full font-bold text-lg"
|
||||
>
|
||||
<span v-if="submitting" class="loading loading-spinner"></span>
|
||||
{{ submitting ? '로그인 중...' : '로그인' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Register Form -->
|
||||
<form v-else class="mt-8 space-y-4" @submit.prevent="handleRegister">
|
||||
<div class="space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-bold opacity-80">아이디</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="regId"
|
||||
type="text"
|
||||
required
|
||||
placeholder="username"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-bold opacity-80">비밀번호</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="regPw"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-bold opacity-80">비밀번호 확인</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="regPwConfirm"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-bold opacity-80">닉네임</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="regNickname"
|
||||
type="text"
|
||||
required
|
||||
placeholder="학습자명"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-bold opacity-80">전화번호</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="regPhone"
|
||||
type="text"
|
||||
required
|
||||
placeholder="010-1234-5678"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs font-bold opacity-80">Gemini API Key (선택사항)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="regApiKey"
|
||||
type="password"
|
||||
placeholder="AI 해설/채점용 API 키 입력"
|
||||
class="input input-bordered input-sm w-full bg-base-100/50 focus:input-primary py-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="text-error text-xs font-bold text-center bg-error/10 p-3 rounded-xl border border-error/10">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<div v-if="successMsg" class="text-success text-xs font-bold text-center bg-success/10 p-3 rounded-xl border border-success/10">
|
||||
{{ successMsg }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary w-full font-bold text-lg"
|
||||
>
|
||||
<span v-if="submitting" class="loading loading-spinner"></span>
|
||||
{{ submitting ? '가입 신청 중...' : '회원가입 신청' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Mode Toggle -->
|
||||
<div class="text-center mt-6 pt-6 border-t border-base-content/5">
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="text-sm text-primary font-bold hover:underline bg-transparent border-none outline-none"
|
||||
>
|
||||
{{ isRegisterMode ? '이미 계정이 있으신가요? 로그인하기' : '아직 계정이 없으신가요? 가입 신청하기' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { TrophyIcon } from 'lucide-vue-next'
|
||||
import { QuizService } from '../../bindings/changeme'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isRegisterMode = ref(false)
|
||||
const submitting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
|
||||
// Login fields
|
||||
const loginId = ref('')
|
||||
const loginPw = ref('')
|
||||
|
||||
// Register fields
|
||||
const regId = ref('')
|
||||
const regPw = ref('')
|
||||
const regPwConfirm = ref('')
|
||||
const regNickname = ref('')
|
||||
const regPhone = ref('')
|
||||
const regApiKey = ref('')
|
||||
|
||||
function toggleMode() {
|
||||
isRegisterMode.value = !isRegisterMode.value
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
// Clear inputs
|
||||
loginId.value = ''
|
||||
loginPw.value = ''
|
||||
regId.value = ''
|
||||
regPw.value = ''
|
||||
regPwConfirm.value = ''
|
||||
regNickname.value = ''
|
||||
regPhone.value = ''
|
||||
regApiKey.value = ''
|
||||
}
|
||||
|
||||
function getErrorMessage(err: any): string {
|
||||
if (!err) return '알 수 없는 오류가 발생했습니다.'
|
||||
|
||||
let msg = ''
|
||||
if (typeof err === 'string') {
|
||||
msg = err
|
||||
} else if (err.message) {
|
||||
msg = err.message
|
||||
} else {
|
||||
msg = JSON.stringify(err)
|
||||
}
|
||||
|
||||
if (msg.trim().startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg)
|
||||
if (parsed.message) return parsed.message
|
||||
if (parsed.Message) return parsed.Message
|
||||
} catch (e) {
|
||||
// Ignore and fallback
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const id = loginId.value.trim()
|
||||
const pw = loginPw.value.trim()
|
||||
if (!id || !pw) return
|
||||
|
||||
submitting.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const user = await QuizService.Login(id, pw)
|
||||
if (user) {
|
||||
authStore.login(user)
|
||||
router.push({ name: 'dashboard' })
|
||||
}
|
||||
} catch (err: any) {
|
||||
errorMsg.value = getErrorMessage(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
const id = regId.value.trim()
|
||||
const pw = regPw.value.trim()
|
||||
const pwConfirm = regPwConfirm.value.trim()
|
||||
const nickname = regNickname.value.trim()
|
||||
const phone = regPhone.value.trim()
|
||||
const apiKey = regApiKey.value.trim()
|
||||
|
||||
if (!id || !pw || !pwConfirm || !nickname || !phone) {
|
||||
errorMsg.value = '모든 항목을 올바르게 기입해 주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
if (pw !== pwConfirm) {
|
||||
errorMsg.value = '비밀번호가 서로 일치하지 않습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
|
||||
try {
|
||||
await QuizService.Register(id, pw, nickname, phone, apiKey)
|
||||
successMsg.value = '회원가입 신청서가 성공적으로 접수되었습니다. 관리자 승인 후 로그인이 가능합니다.'
|
||||
setTimeout(() => {
|
||||
toggleMode()
|
||||
}, 2000)
|
||||
} catch (err: any) {
|
||||
errorMsg.value = getErrorMessage(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+144
-18
@@ -441,7 +441,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CheckCircleIcon, XCircleIcon, SparklesIcon, BotIcon, TrophyIcon, FileTextIcon, ImageIcon, ExternalLinkIcon, BookmarkIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-vue-next'
|
||||
import VuePdfEmbed from 'vue-pdf-embed'
|
||||
@@ -450,9 +450,12 @@ import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { QuizService, Question, DescriptiveGradingRecord } from '../../bindings/changeme'
|
||||
import { useQuizStore } from '../store/quiz'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { Events } from '@wailsio/runtime'
|
||||
|
||||
const props = defineProps<{ category: string, difficulty: string, subject?: string, bookmarkOnly?: boolean }>()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const originalQuestions = ref<Question[]>([])
|
||||
const questions = ref<Question[]>([])
|
||||
@@ -466,15 +469,46 @@ const selectedChoiceIndex = ref(-1)
|
||||
const isCorrect = ref(false)
|
||||
const quizFinished = ref(false)
|
||||
const userTextAnswer = ref('')
|
||||
const askingGemini = ref(false)
|
||||
const userOXAnswer = ref('')
|
||||
|
||||
const userDescriptiveAnswer = ref('')
|
||||
const gradingDescriptive = ref(false)
|
||||
const descriptiveGradingResult = ref<DescriptiveGradingRecord | null>(null)
|
||||
const descriptiveHistory = ref<DescriptiveGradingRecord[]>([])
|
||||
const gradingError = ref('')
|
||||
|
||||
// Track background task states per-question using source_id keys
|
||||
const askingGeminiMap = ref<Record<string, boolean>>({})
|
||||
const gradingDescriptiveMap = ref<Record<string, boolean>>({})
|
||||
|
||||
const askingGemini = computed({
|
||||
get: () => {
|
||||
const q = currentQuestion.value
|
||||
if (!q || !q.id) return false
|
||||
return askingGeminiMap.value[`${q.source}_${q.id}`] || false
|
||||
},
|
||||
set: (val) => {
|
||||
const q = currentQuestion.value
|
||||
if (q && q.id) {
|
||||
askingGeminiMap.value[`${q.source}_${q.id}`] = val
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const gradingDescriptive = computed({
|
||||
get: () => {
|
||||
const q = currentQuestion.value
|
||||
if (!q || !q.id) return false
|
||||
return gradingDescriptiveMap.value[`${q.source}_${q.id}`] || false
|
||||
},
|
||||
set: (val) => {
|
||||
const q = currentQuestion.value
|
||||
if (q && q.id) {
|
||||
gradingDescriptiveMap.value[`${q.source}_${q.id}`] = val
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async function loadGradingHistory() {
|
||||
const q = currentQuestion.value
|
||||
if (!q || q.correctAnswerStr !== '주관식') {
|
||||
@@ -482,10 +516,9 @@ async function loadGradingHistory() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hist = await QuizService.GetDescriptiveGradingHistory(q.source, q.id)
|
||||
const hist = await QuizService.GetDescriptiveGradingHistory(authStore.username, q.source, q.id)
|
||||
descriptiveHistory.value = hist || []
|
||||
} catch (e) {
|
||||
|
||||
console.error("Failed to load grading history:", e)
|
||||
}
|
||||
}
|
||||
@@ -498,25 +531,23 @@ async function submitDescriptiveGrading() {
|
||||
gradingError.value = ''
|
||||
|
||||
try {
|
||||
const record = await QuizService.GradeDescriptiveAnswer(q.source, q.id, userDescriptiveAnswer.value)
|
||||
const record = await QuizService.GradeDescriptiveAnswer(authStore.username, q.source, q.id, userDescriptiveAnswer.value)
|
||||
if (record) {
|
||||
|
||||
descriptiveGradingResult.value = record
|
||||
|
||||
// Unshift to local list
|
||||
descriptiveHistory.value.unshift(record)
|
||||
|
||||
isCorrect.value = record.score >= 60
|
||||
if (isCorrect.value) score.value++
|
||||
isCorrect.value = false
|
||||
} else {
|
||||
isCorrect.value = true
|
||||
gradingDescriptive.value = false
|
||||
}
|
||||
|
||||
showFeedback.value = true
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
gradingError.value = e.message || String(e)
|
||||
} finally {
|
||||
gradingDescriptive.value = false
|
||||
}
|
||||
}
|
||||
@@ -666,7 +697,10 @@ function renderMarkdownWithMath(text: string): string {
|
||||
const mathBlocks: { placeholder: string; isBlock: boolean; rawExpr: string; renderedHtml: string }[] = []
|
||||
let placeholderCount = 0
|
||||
|
||||
// Normalize slash/backslash ctdot representations to standard cdots
|
||||
let workingText = text
|
||||
.replace(/\\ctdot/g, '\\cdots')
|
||||
.replace(/\/ctdot/g, '\\cdots')
|
||||
|
||||
// 1. Extract block math: $$...$$ and \[...\]
|
||||
workingText = workingText.replace(/\$\$(.*?)\$\$/gs, (match, expr) => {
|
||||
@@ -707,7 +741,8 @@ function renderMarkdownWithMath(text: string): string {
|
||||
// 4. Render KaTeX and swap placeholders
|
||||
for (const block of mathBlocks) {
|
||||
try {
|
||||
const cleanExpr = decodeHTMLEntities(block.rawExpr.trim())
|
||||
let cleanExpr = decodeHTMLEntities(block.rawExpr.trim())
|
||||
cleanExpr = cleanExpr.replace(/\\ctdot/g, '\\cdots').replace(/\/ctdot/g, '\\cdots')
|
||||
const rendered = katex.renderToString(cleanExpr, {
|
||||
displayMode: block.isBlock,
|
||||
throwOnError: false
|
||||
@@ -756,14 +791,84 @@ function shuffleArray<T>(array: T[]): T[] {
|
||||
return arr
|
||||
}
|
||||
|
||||
const scoredQuestions = ref<Record<string, boolean>>({})
|
||||
let cancelExplanationDone: (() => void) | null = null
|
||||
let cancelGradingDone: (() => void) | null = null
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cancelExplanationDone) cancelExplanationDone()
|
||||
if (cancelGradingDone) cancelGradingDone()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
cancelExplanationDone = Events.On('ai:explanation:done', (event: any) => {
|
||||
const data = event.data
|
||||
const key = `${data.source}_${data.id}`
|
||||
askingGeminiMap.value[key] = false
|
||||
|
||||
if (data.error) {
|
||||
if (currentQuestion.value && currentQuestion.value.id === data.id && currentQuestion.value.source === data.source) {
|
||||
geminiError.value = data.error
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const foundQuestion = questions.value.find(q => q.id === data.id && q.source === data.source)
|
||||
if (foundQuestion) {
|
||||
foundQuestion.geminiExplanation = data.explanation
|
||||
}
|
||||
|
||||
if (currentQuestion.value && currentQuestion.value.id === data.id && currentQuestion.value.source === data.source) {
|
||||
currentQuestion.value.geminiExplanation = data.explanation
|
||||
}
|
||||
})
|
||||
|
||||
cancelGradingDone = Events.On('ai:grading:done', (event: any) => {
|
||||
const data = event.data
|
||||
const key = `${data.source}_${data.questionId}`
|
||||
gradingDescriptiveMap.value[key] = false
|
||||
|
||||
if (data.error) {
|
||||
if (currentQuestion.value && currentQuestion.value.id === data.questionId && currentQuestion.value.source === data.source) {
|
||||
gradingError.value = data.error
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (currentQuestion.value && currentQuestion.value.id === data.questionId && currentQuestion.value.source === data.source) {
|
||||
descriptiveGradingResult.value = data.record
|
||||
|
||||
const existIdx = descriptiveHistory.value.findIndex(r => r.id === -1 || (r.score === -1 && r.questionId === data.questionId))
|
||||
if (existIdx !== -1) {
|
||||
descriptiveHistory.value[existIdx] = data.record
|
||||
} else {
|
||||
descriptiveHistory.value.unshift(data.record)
|
||||
}
|
||||
|
||||
const alreadyPassed = descriptiveHistory.value.some(r => r.score >= 60 && r.id !== -1)
|
||||
isCorrect.value = data.record.score >= 60
|
||||
if (isCorrect.value && !alreadyPassed && !scoredQuestions.value[key]) {
|
||||
scoredQuestions.value[key] = true
|
||||
score.value++
|
||||
}
|
||||
showFeedback.value = true
|
||||
} else {
|
||||
if (data.record.score >= 60) {
|
||||
if (!scoredQuestions.value[key]) {
|
||||
scoredQuestions.value[key] = true
|
||||
score.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
let fetched: Question[] = []
|
||||
if (props.bookmarkOnly) {
|
||||
fetched = await QuizService.GetBookmarkedQuestions(props.category, props.difficulty)
|
||||
fetched = await QuizService.GetBookmarkedQuestions(authStore.username, props.category, props.difficulty)
|
||||
} else {
|
||||
const querySubject = props.subject === 'dummy' ? '' : (props.subject || '')
|
||||
fetched = await QuizService.GetQuestions(props.category, props.difficulty, querySubject)
|
||||
fetched = await QuizService.GetQuestions(authStore.username, props.category, props.difficulty, querySubject)
|
||||
}
|
||||
originalQuestions.value = fetched
|
||||
|
||||
@@ -903,12 +1008,10 @@ async function askGemini() {
|
||||
if (q.imageUrl && !q.imageUrl.toLowerCase().endsWith('.pdf')) {
|
||||
base64Image = await getBase64FromUrl(q.imageUrl)
|
||||
}
|
||||
const result = await QuizService.AskGemini(q.source, q.id, prompt, base64Image)
|
||||
currentQuestion.value.geminiExplanation = result
|
||||
await QuizService.AskGemini(q.source, q.id, prompt, base64Image)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
geminiError.value = e.message || String(e)
|
||||
} finally {
|
||||
askingGemini.value = false
|
||||
}
|
||||
}
|
||||
@@ -1002,10 +1105,10 @@ async function toggleBookmark() {
|
||||
togglingBookmark.value = true
|
||||
try {
|
||||
if (q.isBookmarked) {
|
||||
await QuizService.RemoveBookmark(q.source, q.id)
|
||||
await QuizService.RemoveBookmark(authStore.username, q.source, q.id)
|
||||
q.isBookmarked = false
|
||||
} else {
|
||||
await QuizService.AddBookmark(q.source, q.id)
|
||||
await QuizService.AddBookmark(authStore.username, q.source, q.id)
|
||||
q.isBookmarked = true
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1104,4 +1207,27 @@ function handleTouchEnd(e: TouchEvent) {
|
||||
margin-bottom: 0.5rem !important;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 마크다운 표가 화면을 벗어나지 않게 하고 반응형 가로스크롤 및 폰트 축소 적용 */
|
||||
.markdown-content table {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
display: block !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
border-collapse: collapse !important;
|
||||
margin-top: 1rem !important;
|
||||
margin-bottom: 1rem !important;
|
||||
font-size: 0.85em !important;
|
||||
}
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
border: 1px solid var(--fallback-bc, oklch(var(--bc)/0.15)) !important;
|
||||
white-space: nowrap; /* 셀 내용 잘림 방지하고 깔끔한 스크롤 보장 */
|
||||
}
|
||||
.markdown-content th {
|
||||
background-color: var(--fallback-b3, oklch(var(--b3)/0.5)) !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-4xl mx-auto space-y-8" data-theme="dark">
|
||||
<!-- 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 → gemini-3.5-flash → gemini-2.5-flash → gemini-2.0-flash-lite → 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>
|
||||
Reference in New Issue
Block a user