This commit is contained in:
root
2026-06-28 23:48:13 +00:00
parent 4c2ce929d9
commit 9e215baa11
9538 changed files with 3146 additions and 239 deletions
+278 -30
View File
@@ -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
+29
View File
@@ -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
+41
View File
@@ -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
View File
@@ -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
}
}
})
+488
View File
@@ -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>
+26 -3
View File
@@ -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>
+3 -1
View File
@@ -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)
}
+298
View File
@@ -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
View File
@@ -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>
+207
View File
@@ -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 &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>