Add persistent multi-user sessions and Docker deployment
This commit is contained in:
parent
51a6e845d1
commit
104577c826
24 changed files with 2094 additions and 468 deletions
|
|
@ -1,10 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import TranslatorView from './views/TranslatorView.vue'
|
||||
import AdminView from './views/AdminView.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const page = ref<'translator' | 'admin'>('translator')
|
||||
|
||||
watch(() => auth.isLoggedIn, (loggedIn) => {
|
||||
if (!loggedIn) page.value = 'translator'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
auth.check()
|
||||
|
|
@ -13,8 +19,10 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-900 dark:to-slate-800 transition-colors">
|
||||
<!-- Show Login if not authenticated, otherwise Translator -->
|
||||
<LoginView v-if="!auth.isLoggedIn" />
|
||||
<TranslatorView v-else />
|
||||
<template v-else>
|
||||
<TranslatorView v-show="page === 'translator'" @open-admin="page = 'admin'" />
|
||||
<AdminView v-if="page === 'admin' && auth.isAdmin" @close="page = 'translator'" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
/** Axios-based API client with JWT token management */
|
||||
|
||||
import axios from 'axios'
|
||||
import type { LLMModel, PhaseProgress, ProperNoun, SessionConfig, SessionResponse } from './types'
|
||||
import type {
|
||||
LLMModel, PhaseProgress, ProperNoun, RevisionResponse, SessionConfig,
|
||||
SessionResponse, SessionSummary, UserSummary,
|
||||
} from './types'
|
||||
|
||||
export const AUTH_UNAUTHORIZED_EVENT = 'llm-translator:unauthorized'
|
||||
|
||||
|
|
@ -44,11 +47,29 @@ export async function logout(): Promise<void> {
|
|||
localStorage.removeItem('jwt_token')
|
||||
}
|
||||
|
||||
export async function me(): Promise<{ user_id: string }> {
|
||||
export async function me(): Promise<{ user_id: string; is_admin: boolean }> {
|
||||
const res = await api.get('/auth/me')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── Administration ────────────────────────────────────
|
||||
|
||||
export async function listUsers(): Promise<UserSummary[]> {
|
||||
const res = await api.get('/admin/users')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createUser(params: { id: string; password: string; is_admin: boolean }): Promise<void> {
|
||||
await api.post('/admin/users', params)
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
userId: string,
|
||||
patch: { password?: string; is_admin?: boolean; is_active?: boolean },
|
||||
): Promise<void> {
|
||||
await api.patch(`/admin/users/${encodeURIComponent(userId)}`, patch)
|
||||
}
|
||||
|
||||
// ── Models ───────────────────────────────────────────
|
||||
|
||||
export async function listModels(): Promise<LLMModel[]> {
|
||||
|
|
@ -68,6 +89,11 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
|
|||
return res.data
|
||||
}
|
||||
|
||||
export async function listSessions(includeArchived = false): Promise<SessionSummary[]> {
|
||||
const res = await api.get('/sessions', { params: { include_archived: includeArchived } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getSessionProgress(
|
||||
sessionId: string,
|
||||
): Promise<{ progress: PhaseProgress | null }> {
|
||||
|
|
@ -81,12 +107,35 @@ export async function deleteSession(sessionId: string): Promise<void> {
|
|||
|
||||
export async function updateSession(
|
||||
sessionId: string,
|
||||
patch: Partial<SessionConfig>,
|
||||
patch: Partial<SessionConfig> & { title?: string; expected_version?: number },
|
||||
): Promise<SessionResponse> {
|
||||
const res = await api.patch(`/sessions/${sessionId}`, patch)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function archiveSession(sessionId: string): Promise<void> {
|
||||
await api.post(`/sessions/${sessionId}/archive`)
|
||||
}
|
||||
|
||||
export async function restoreSession(sessionId: string): Promise<void> {
|
||||
await api.post(`/sessions/${sessionId}/restore`)
|
||||
}
|
||||
|
||||
export async function cloneSession(sessionId: string): Promise<{ session_id: string }> {
|
||||
const res = await api.post(`/sessions/${sessionId}/clone`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getRevision(sessionId: string): Promise<RevisionResponse> {
|
||||
const res = await api.get(`/sessions/${sessionId}/revision`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function restoreRevision(sessionId: string): Promise<SessionResponse> {
|
||||
const res = await api.post(`/sessions/${sessionId}/revision/restore`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── Phase Execution ──────────────────────────────────
|
||||
|
||||
export async function runPhase1(sessionId: string): Promise<SessionResponse> {
|
||||
|
|
@ -98,8 +147,13 @@ export async function runPhase2(
|
|||
sessionId: string,
|
||||
proper_nouns: ProperNoun[],
|
||||
style: string,
|
||||
expected_version: number,
|
||||
): Promise<SessionResponse> {
|
||||
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })
|
||||
const res = await api.post(`/translate/${sessionId}/phase2`, {
|
||||
proper_nouns,
|
||||
style,
|
||||
expected_version,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const props = defineProps<{
|
|||
label: string
|
||||
value: string
|
||||
models: LLMModel[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:value', v: string): void }>()
|
||||
|
||||
|
|
@ -18,8 +19,9 @@ function update(v: string) {
|
|||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ label }}</label>
|
||||
<select
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
@change="update((($event.target as HTMLSelectElement).value))"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">-- 선택 --</option>
|
||||
<option v-for="m in models" :key="m.alias" :value="m.alias">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { ProperNoun } from '../types'
|
|||
const props = defineProps<{
|
||||
proper_nouns: ProperNoun[]
|
||||
style: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:proper_nouns', v: ProperNoun[]): void
|
||||
|
|
@ -39,9 +40,10 @@ function emitAll() {
|
|||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">문체 (수정 가능)</label>
|
||||
<input
|
||||
v-model="styleText"
|
||||
:disabled="disabled"
|
||||
@change="emitAll()"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -65,6 +67,7 @@ function emitAll() {
|
|||
<td class="py-1 px-2">
|
||||
<input
|
||||
v-model="nouns[idx].final"
|
||||
:disabled="disabled"
|
||||
@change="emitAll()"
|
||||
type="text"
|
||||
class="w-full px-2 py-1 border border-gray-200 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { AUTH_UNAUTHORIZED_EVENT, me as apiMe, logout as apiLogout } from '../ap
|
|||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user_id = ref<string | null>(null)
|
||||
const isAdmin = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
|
||||
|
||||
function handleUnauthorized() {
|
||||
user_id.value = null
|
||||
isAdmin.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
|
|
@ -21,8 +23,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
try {
|
||||
const data = await apiMe()
|
||||
user_id.value = data.user_id
|
||||
isAdmin.value = data.is_admin
|
||||
} catch {
|
||||
user_id.value = null
|
||||
isAdmin.value = false
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
|
|
@ -31,7 +35,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
async function logout() {
|
||||
await apiLogout()
|
||||
user_id.value = null
|
||||
isAdmin.value = false
|
||||
}
|
||||
|
||||
return { user_id, loaded, isLoggedIn, check, logout }
|
||||
return { user_id, isAdmin, loaded, isLoggedIn, check, logout }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import type { HistoryEntry, LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig, TranslationSessionData } from '../types'
|
||||
import type {
|
||||
LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig,
|
||||
SessionResponse, SessionSummary, TranslationSessionData,
|
||||
} from '../types'
|
||||
import * as api from '../api'
|
||||
import { useAuthStore } from './auth'
|
||||
|
||||
const HISTORY_KEY = 'llm_translator_history'
|
||||
const MODELS_KEY = 'llm_translator_models'
|
||||
|
||||
interface SavedModels {
|
||||
|
|
@ -15,9 +16,10 @@ interface SavedModels {
|
|||
}
|
||||
|
||||
export const useTranslationStore = defineStore('translation', () => {
|
||||
const auth = useAuthStore()
|
||||
// ── State ────────────────────────────────────────
|
||||
const sessionId = ref<string | null>(null)
|
||||
const sessionTitle = ref('')
|
||||
const sessionVersion = ref(0)
|
||||
const archivedAt = ref<string | null>(null)
|
||||
const sourceText = ref('')
|
||||
const sourceLanguage = ref('auto')
|
||||
const targetLanguage = ref('한국어')
|
||||
|
|
@ -25,37 +27,41 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
const modelPhase3 = ref('')
|
||||
const modelPhase4 = ref('')
|
||||
|
||||
// Phase results
|
||||
const phase1Result = ref<Phase1Result | null>(null)
|
||||
const phase2ProperNouns = ref<ProperNoun[]>([])
|
||||
const phase2Style = ref('')
|
||||
const phase3Result = ref('')
|
||||
const phase4Result = ref('')
|
||||
|
||||
// UI state
|
||||
const loading = ref(false)
|
||||
const sessionLoading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const currentPhaseLoading = ref<number | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const models = ref<LLMModel[]>([])
|
||||
const history = ref<HistoryEntry[]>([])
|
||||
const historySnapshot = ref(false)
|
||||
const history = ref<SessionSummary[]>([])
|
||||
const progress = ref<PhaseProgress | null>(null)
|
||||
let hydrating = false
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let phase2SaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let phase2EditGeneration = 0
|
||||
let phase2SavePromise: Promise<void> | null = null
|
||||
let editGeneration = 0
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||||
let progressRequestPending = false
|
||||
|
||||
// ── Computed ─────────────────────────────────────
|
||||
const phase1Done = computed(() => !!phase1Result.value)
|
||||
const phase2Ready = computed(() => phase1Done.value && !historySnapshot.value)
|
||||
const phase3Ready = computed(() => phase1Done.value && !historySnapshot.value)
|
||||
const phase4Ready = computed(() => !!phase3Result.value && !historySnapshot.value)
|
||||
const editable = computed(() => !archivedAt.value && !loading.value && !sessionLoading.value)
|
||||
const phase2Ready = computed(() => phase1Done.value && editable.value)
|
||||
const phase3Ready = computed(() => phase1Done.value && editable.value)
|
||||
const phase4Ready = computed(() => !!phase3Result.value && editable.value)
|
||||
|
||||
// ── Actions ──────────────────────────────────────
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (isAxiosError<{ detail?: string }>(error)) {
|
||||
return error.response?.data?.detail || error.message || fallback
|
||||
function getErrorMessage(caught: unknown, fallback: string): string {
|
||||
if (isAxiosError<{ detail?: string }>(caught)) {
|
||||
return caught.response?.data?.detail || caught.message || fallback
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback
|
||||
return caught instanceof Error ? caught.message : fallback
|
||||
}
|
||||
|
||||
function loadSavedModels(): SavedModels {
|
||||
|
|
@ -81,13 +87,21 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
async function loadModels() {
|
||||
try {
|
||||
models.value = await api.listModels()
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load models:', error)
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '모델 목록을 불러오지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
|
||||
editGeneration += 1
|
||||
phase2EditGeneration += 1
|
||||
hydrating = true
|
||||
sessionId.value = null
|
||||
sessionTitle.value = ''
|
||||
sessionVersion.value = 0
|
||||
archivedAt.value = null
|
||||
sourceText.value = ''
|
||||
sourceLanguage.value = 'auto'
|
||||
targetLanguage.value = '한국어'
|
||||
|
|
@ -96,53 +110,22 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
phase2Style.value = ''
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
progress.value = null
|
||||
error.value = null
|
||||
historySnapshot.value = false
|
||||
|
||||
const savedModels = loadSavedModels()
|
||||
const saved = loadSavedModels()
|
||||
const defaultModel = models.value[0]?.alias || ''
|
||||
modelPhase1.value = validModel(savedModels.p1, defaultModel)
|
||||
modelPhase3.value = validModel(savedModels.p3, defaultModel)
|
||||
modelPhase4.value = validModel(savedModels.p4, models.value[1]?.alias || defaultModel)
|
||||
modelPhase1.value = validModel(saved.p1, defaultModel)
|
||||
modelPhase3.value = validModel(saved.p3, defaultModel)
|
||||
modelPhase4.value = validModel(saved.p4, models.value[1]?.alias || defaultModel)
|
||||
hydrating = false
|
||||
}
|
||||
|
||||
function saveModelSelections() {
|
||||
try {
|
||||
localStorage.setItem(MODELS_KEY, JSON.stringify({
|
||||
p1: modelPhase1.value,
|
||||
p3: modelPhase3.value,
|
||||
p4: modelPhase4.value,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to save model selections:', error)
|
||||
}
|
||||
}
|
||||
|
||||
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
|
||||
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
|
||||
phase1Result.value = null
|
||||
phase2ProperNouns.value = []
|
||||
phase2Style.value = ''
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
}, { flush: 'sync' })
|
||||
watch(modelPhase3, () => {
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
}, { flush: 'sync' })
|
||||
watch(modelPhase4, () => {
|
||||
phase4Result.value = ''
|
||||
}, { flush: 'sync' })
|
||||
watch([phase2ProperNouns, phase2Style], () => {
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
}, { deep: true, flush: 'sync' })
|
||||
|
||||
async function ensureSession() {
|
||||
if (!sessionId.value) {
|
||||
const res = await api.createSession(getSessionParams())
|
||||
sessionId.value = res.session_id
|
||||
}
|
||||
localStorage.setItem(MODELS_KEY, JSON.stringify({
|
||||
p1: modelPhase1.value,
|
||||
p3: modelPhase3.value,
|
||||
p4: modelPhase4.value,
|
||||
}))
|
||||
}
|
||||
|
||||
function getSessionParams(): SessionConfig {
|
||||
|
|
@ -157,30 +140,223 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
}
|
||||
}
|
||||
|
||||
function hydrate(response: SessionResponse) {
|
||||
hydrating = true
|
||||
const data = response.data
|
||||
sessionId.value = response.session_id
|
||||
sessionTitle.value = response.title
|
||||
sessionVersion.value = response.version
|
||||
archivedAt.value = response.archived_at
|
||||
sourceText.value = data.source_text
|
||||
sourceLanguage.value = data.source_language
|
||||
targetLanguage.value = data.target_language
|
||||
const defaultModel = models.value[0]?.alias || ''
|
||||
modelPhase1.value = validModel(data.model_phase1, defaultModel)
|
||||
modelPhase3.value = validModel(data.model_phase3, defaultModel)
|
||||
modelPhase4.value = validModel(data.model_phase4, models.value[1]?.alias || defaultModel)
|
||||
phase1Result.value = data.phase1_result
|
||||
phase2ProperNouns.value = data.phase2_proper_nouns || []
|
||||
phase2Style.value = data.phase2_style || ''
|
||||
phase3Result.value = data.phase3_result || ''
|
||||
phase4Result.value = data.phase4_result || ''
|
||||
progress.value = data.progress || null
|
||||
hydrating = false
|
||||
saveModelSelections()
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
history.value = await api.listSessions(true)
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '번역 기록을 불러오지 못했습니다')
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(id: string) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
|
||||
editGeneration += 1
|
||||
phase2EditGeneration += 1
|
||||
sessionLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
hydrate(await api.getSession(id))
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '세션을 불러오지 못했습니다')
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (hydrating || loading.value || sessionLoading.value || archivedAt.value) return
|
||||
editGeneration += 1
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => void flushAutoSave(), 650)
|
||||
}
|
||||
|
||||
async function flushAutoSave() {
|
||||
saveTimer = null
|
||||
if (archivedAt.value || hydrating || loading.value) return
|
||||
if (saving.value) {
|
||||
saveTimer = setTimeout(() => void flushAutoSave(), 250)
|
||||
return
|
||||
}
|
||||
const generation = editGeneration
|
||||
saving.value = true
|
||||
try {
|
||||
if (!sessionId.value) {
|
||||
const created = await api.createSession(getSessionParams())
|
||||
if (generation !== editGeneration) return
|
||||
hydrate(await api.getSession(created.session_id))
|
||||
await loadHistory()
|
||||
return
|
||||
}
|
||||
const savingSessionId = sessionId.value
|
||||
const response = await api.updateSession(savingSessionId, {
|
||||
...getSessionParams(),
|
||||
expected_version: sessionVersion.value,
|
||||
})
|
||||
if (sessionId.value === savingSessionId) {
|
||||
sessionVersion.value = response.version
|
||||
sessionTitle.value = response.title
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '변경사항을 저장하지 못했습니다')
|
||||
} finally {
|
||||
saving.value = false
|
||||
if (generation !== editGeneration) scheduleAutoSave()
|
||||
else void loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
|
||||
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
|
||||
if (hydrating) return
|
||||
phase1Result.value = null
|
||||
phase2ProperNouns.value = []
|
||||
phase2Style.value = ''
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
scheduleAutoSave()
|
||||
}, { flush: 'sync' })
|
||||
watch(modelPhase3, () => {
|
||||
if (hydrating) return
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
scheduleAutoSave()
|
||||
}, { flush: 'sync' })
|
||||
watch(modelPhase4, () => {
|
||||
if (hydrating) return
|
||||
phase4Result.value = ''
|
||||
scheduleAutoSave()
|
||||
}, { flush: 'sync' })
|
||||
watch([phase2ProperNouns, phase2Style], () => {
|
||||
if (hydrating) return
|
||||
phase2EditGeneration += 1
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
if (sessionId.value && phase1Result.value && !loading.value && !archivedAt.value) {
|
||||
schedulePhase2Save()
|
||||
}
|
||||
}, { deep: true, flush: 'sync' })
|
||||
|
||||
function schedulePhase2Save(delay = 650) {
|
||||
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
|
||||
phase2SaveTimer = setTimeout(() => void savePhase2Draft(), delay)
|
||||
}
|
||||
|
||||
async function savePhase2Draft() {
|
||||
phase2SaveTimer = null
|
||||
if (!sessionId.value || !phase1Result.value || loading.value || archivedAt.value) return
|
||||
if (phase2SavePromise) {
|
||||
await phase2SavePromise
|
||||
if (sessionId.value && phase1Result.value && !loading.value && !archivedAt.value) {
|
||||
schedulePhase2Save(100)
|
||||
}
|
||||
return
|
||||
}
|
||||
const requestSessionId = sessionId.value
|
||||
const requestGeneration = phase2EditGeneration
|
||||
const nouns = phase2ProperNouns.value.map(noun => ({
|
||||
...noun,
|
||||
final: noun.final || noun.suggested,
|
||||
}))
|
||||
const style = phase2Style.value
|
||||
phase2SavePromise = (async () => {
|
||||
const response = await api.runPhase2(
|
||||
requestSessionId,
|
||||
nouns,
|
||||
style,
|
||||
sessionVersion.value,
|
||||
)
|
||||
if (sessionId.value !== requestSessionId) return
|
||||
if (phase2EditGeneration === requestGeneration) {
|
||||
hydrate(response)
|
||||
} else {
|
||||
sessionVersion.value = response.version
|
||||
sessionTitle.value = response.title
|
||||
}
|
||||
})()
|
||||
try {
|
||||
await phase2SavePromise
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '고유명사와 문체를 저장하지 못했습니다')
|
||||
} finally {
|
||||
phase2SavePromise = null
|
||||
if (
|
||||
sessionId.value === requestSessionId
|
||||
&& phase2EditGeneration !== requestGeneration
|
||||
&& !loading.value
|
||||
) {
|
||||
schedulePhase2Save(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (sessionId.value) return
|
||||
const created = await api.createSession(getSessionParams())
|
||||
hydrate(await api.getSession(created.session_id))
|
||||
await loadHistory()
|
||||
}
|
||||
|
||||
async function syncToServer() {
|
||||
if (!sessionId.value) return
|
||||
await api.updateSession(sessionId.value, getSessionParams())
|
||||
if (!sessionId.value || archivedAt.value) return
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
const response = await api.updateSession(sessionId.value, {
|
||||
...getSessionParams(),
|
||||
expected_version: sessionVersion.value,
|
||||
})
|
||||
sessionVersion.value = response.version
|
||||
sessionTitle.value = response.title
|
||||
}
|
||||
|
||||
async function submitPhase2() {
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
const res = await api.runPhase2(
|
||||
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
|
||||
phase2SaveTimer = null
|
||||
if (phase2SavePromise) await phase2SavePromise
|
||||
hydrate(await api.runPhase2(
|
||||
sessionId.value,
|
||||
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
|
||||
phase2ProperNouns.value.map(noun => ({ ...noun, final: noun.final || noun.suggested })),
|
||||
phase2Style.value,
|
||||
)
|
||||
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
||||
phase2Style.value = res.data.phase2_style || ''
|
||||
sessionVersion.value,
|
||||
))
|
||||
}
|
||||
|
||||
async function pollProgress() {
|
||||
if (!sessionId.value || progressRequestPending) return
|
||||
progressRequestPending = true
|
||||
try {
|
||||
const response = await api.getSessionProgress(sessionId.value)
|
||||
progress.value = response.progress
|
||||
progress.value = (await api.getSessionProgress(sessionId.value)).progress
|
||||
} catch {
|
||||
// The phase request handles user-visible errors; polling is best-effort only.
|
||||
// Phase requests surface errors; progress polling is best-effort.
|
||||
} finally {
|
||||
progressRequestPending = false
|
||||
}
|
||||
|
|
@ -194,42 +370,27 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
}
|
||||
|
||||
function stopProgressPolling() {
|
||||
if (progressTimer !== null) {
|
||||
clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
if (progressTimer !== null) clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
|
||||
// ── Phase Execution ──────────────────────────────
|
||||
|
||||
async function executePhase1() {
|
||||
if (!sourceText.value.trim()) {
|
||||
error.value = '원문을 입력해주세요'
|
||||
if (!sourceText.value.trim() || !modelPhase1.value) {
|
||||
error.value = !sourceText.value.trim() ? '원문을 입력해주세요' : 'Phase 1 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
if (!modelPhase1.value) {
|
||||
error.value = 'Phase 1 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 1
|
||||
error.value = null
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
await syncToServer()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
startProgressPolling()
|
||||
const res = await api.runPhase1(sessionId.value)
|
||||
phase1Result.value = res.data.phase1_result
|
||||
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
||||
phase2Style.value = res.data.phase2_style || ''
|
||||
historySnapshot.value = false
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 1 실행 실패')
|
||||
hydrate(await api.runPhase1(sessionId.value))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 1 실행 실패')
|
||||
} finally {
|
||||
stopProgressPolling()
|
||||
loading.value = false
|
||||
|
|
@ -238,20 +399,15 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
}
|
||||
|
||||
async function executePhase2() {
|
||||
// Phase 2 is a user confirmation step — we just save the edited proper nouns and style
|
||||
if (!sessionId.value) return
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 2
|
||||
error.value = null
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
|
||||
try {
|
||||
await submitPhase2()
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 2 확인 실패')
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 2 확인 실패')
|
||||
} finally {
|
||||
loading.value = false
|
||||
currentPhaseLoading.value = null
|
||||
|
|
@ -263,29 +419,19 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
error.value = '먼저 Phase 1을 실행하세요'
|
||||
return
|
||||
}
|
||||
if (!modelPhase3.value && !modelPhase1.value) {
|
||||
error.value = 'Phase 3 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 3
|
||||
error.value = null
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
await syncToServer()
|
||||
await submitPhase2()
|
||||
saveToHistory()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
startProgressPolling()
|
||||
const res = await api.runPhase3(sessionId.value)
|
||||
phase3Result.value = res.data.phase3_result || ''
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 3 실행 실패')
|
||||
hydrate(await api.runPhase3(sessionId.value))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 3 실행 실패')
|
||||
} finally {
|
||||
stopProgressPolling()
|
||||
loading.value = false
|
||||
|
|
@ -298,21 +444,18 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
error.value = '먼저 Phase 3을 실행하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 4
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
await syncToServer()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
startProgressPolling()
|
||||
const res = await api.runPhase4(sessionId.value)
|
||||
phase4Result.value = res.data.phase4_result || ''
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 4 실행 실패')
|
||||
hydrate(await api.runPhase4(sessionId.value))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 4 실행 실패')
|
||||
} finally {
|
||||
stopProgressPolling()
|
||||
loading.value = false
|
||||
|
|
@ -320,129 +463,75 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// ── History (localStorage) ───────────────────────
|
||||
|
||||
function saveToHistory() {
|
||||
const entry: HistoryEntry = {
|
||||
id: sessionId.value || Date.now().toString(),
|
||||
source_preview: sourceText.value.slice(0, 100),
|
||||
target_language: targetLanguage.value,
|
||||
timestamp: Date.now(),
|
||||
data: getSessionData(),
|
||||
}
|
||||
|
||||
// Avoid duplicates
|
||||
const idx = history.value.findIndex(h => h.id === entry.id)
|
||||
if (idx >= 0) {
|
||||
history.value[idx] = entry
|
||||
} else {
|
||||
history.value.unshift(entry)
|
||||
}
|
||||
// Keep max 50 entries
|
||||
if (history.value.length > 50) history.value.pop()
|
||||
|
||||
async function archive(id: string, shouldArchive: boolean) {
|
||||
try {
|
||||
localStorage.setItem(historyStorageKey(), JSON.stringify(history.value))
|
||||
} catch (error) {
|
||||
console.error('Failed to save translation history:', error)
|
||||
if (shouldArchive) await api.archiveSession(id)
|
||||
else await api.restoreSession(id)
|
||||
if (sessionId.value === id) hydrate(await api.getSession(id))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, shouldArchive ? '보관하지 못했습니다' : '복원하지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
function historyStorageKey(): string {
|
||||
return `${HISTORY_KEY}:${auth.user_id || 'anonymous'}`
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
async function clone(id: string) {
|
||||
try {
|
||||
const key = historyStorageKey()
|
||||
const raw = localStorage.getItem(key) || '[]'
|
||||
history.value = JSON.parse(raw) as HistoryEntry[]
|
||||
localStorage.removeItem(HISTORY_KEY)
|
||||
} catch {
|
||||
history.value = []
|
||||
const cloned = await api.cloneSession(id)
|
||||
await loadHistory()
|
||||
await openSession(cloned.session_id)
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '세션을 복제하지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFromHistory(entry: HistoryEntry) {
|
||||
sessionId.value = null
|
||||
sourceText.value = entry.data.source_text
|
||||
sourceLanguage.value = entry.data.source_language
|
||||
targetLanguage.value = entry.data.target_language
|
||||
const defaultModel = models.value[0]?.alias || ''
|
||||
modelPhase1.value = validModel(entry.data.model_phase1, defaultModel)
|
||||
modelPhase3.value = validModel(entry.data.model_phase3, defaultModel)
|
||||
modelPhase4.value = validModel(entry.data.model_phase4, models.value[1]?.alias || defaultModel)
|
||||
phase1Result.value = entry.data.phase1_result
|
||||
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
|
||||
phase2Style.value = entry.data.phase2_style || ''
|
||||
phase3Result.value = entry.data.phase3_result || ''
|
||||
phase4Result.value = entry.data.phase4_result || ''
|
||||
historySnapshot.value = true
|
||||
|
||||
// Save model selections for future sessions
|
||||
saveModelSelections()
|
||||
}
|
||||
|
||||
function getSessionData(): TranslationSessionData {
|
||||
return {
|
||||
source_text: sourceText.value,
|
||||
source_language: sourceLanguage.value,
|
||||
target_language: targetLanguage.value,
|
||||
model_phase1: modelPhase1.value,
|
||||
model_phase2: '',
|
||||
model_phase3: modelPhase3.value,
|
||||
model_phase4: modelPhase4.value,
|
||||
phase1_result: phase1Result.value || null,
|
||||
phase2_proper_nouns: phase2ProperNouns.value,
|
||||
phase2_style: phase2Style.value,
|
||||
phase3_result: phase3Result.value,
|
||||
phase4_result: phase4Result.value,
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.deleteSession(id)
|
||||
if (sessionId.value === id) newSession()
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '세션을 삭제하지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────
|
||||
async function restorePreviousRevision() {
|
||||
if (!sessionId.value) return
|
||||
try {
|
||||
await api.getRevision(sessionId.value)
|
||||
hydrate(await api.restoreRevision(sessionId.value))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '복원할 이전 버전이 없습니다')
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
error.value = '클립보드 복사에 실패했습니다'
|
||||
})
|
||||
navigator.clipboard.writeText(text).catch(() => { error.value = '클립보드 복사에 실패했습니다' })
|
||||
}
|
||||
|
||||
function downloadTxt(filename: string, content: string): void {
|
||||
function downloadTxt(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function downloadMd(filename: string, content: string): void {
|
||||
// Add simple markdown formatting — frontmatter with metadata
|
||||
const md = `---
|
||||
source_language: ${sourceLanguage.value}
|
||||
target_language: ${targetLanguage.value}
|
||||
timestamp: ${new Date().toISOString()}
|
||||
---\n\n${content}`
|
||||
downloadTxt(filename.replace('.txt', '.md'), md)
|
||||
function downloadMd(filename: string, content: string) {
|
||||
const markdown = `---\nsource_language: ${sourceLanguage.value}\ntarget_language: ${targetLanguage.value}\ntimestamp: ${new Date().toISOString()}\n---\n\n${content}`
|
||||
downloadTxt(filename.replace('.txt', '.md'), markdown)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
sessionId, sourceText, sourceLanguage, targetLanguage,
|
||||
modelPhase1, modelPhase3, modelPhase4,
|
||||
phase1Result, phase2ProperNouns, phase2Style,
|
||||
phase3Result, phase4Result,
|
||||
loading, currentPhaseLoading, error, models, history, progress,
|
||||
|
||||
// Computed
|
||||
phase1Done, phase2Ready, phase3Ready, phase4Ready,
|
||||
|
||||
// Actions
|
||||
loadModels, newSession, saveModelSelections, ensureSession, syncToServer,
|
||||
executePhase1, executePhase2, executePhase3, executePhase4,
|
||||
saveToHistory, loadHistory, restoreFromHistory,
|
||||
copyToClipboard, downloadTxt, downloadMd, getSessionData,
|
||||
sessionId, sessionTitle, sessionVersion, archivedAt, sourceText, sourceLanguage, targetLanguage,
|
||||
modelPhase1, modelPhase3, modelPhase4, phase1Result, phase2ProperNouns, phase2Style,
|
||||
phase3Result, phase4Result, loading, sessionLoading, historyLoading, saving,
|
||||
currentPhaseLoading, error, models, history, progress, phase1Done, phase2Ready,
|
||||
phase3Ready, phase4Ready, editable, loadModels, newSession, ensureSession, syncToServer,
|
||||
loadHistory, openSession, executePhase1, executePhase2, executePhase3, executePhase4,
|
||||
archive, clone, remove, restorePreviousRevision, copyToClipboard, downloadTxt, downloadMd,
|
||||
getSessionParams,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface TranslationSessionData {
|
|||
phase1_chunks?: Array<{ source_text: string; translated: string }>
|
||||
phase2_proper_nouns: ProperNoun[]
|
||||
phase2_style: string
|
||||
phase2_confirmed: boolean
|
||||
phase3_result: string
|
||||
phase3_chunks?: string[]
|
||||
phase4_result: string
|
||||
|
|
@ -60,13 +61,38 @@ export type SessionConfig = Pick<
|
|||
export interface SessionResponse {
|
||||
session_id: string
|
||||
data: TranslationSessionData
|
||||
title: string
|
||||
status: string
|
||||
current_phase: number
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
archived_at: string | null
|
||||
}
|
||||
|
||||
/** A saved translation history entry (localStorage) */
|
||||
export interface HistoryEntry {
|
||||
id: string
|
||||
export interface SessionSummary {
|
||||
session_id: string
|
||||
title: string
|
||||
source_preview: string
|
||||
target_language: string
|
||||
timestamp: number
|
||||
data: TranslationSessionData
|
||||
status: string
|
||||
current_phase: number
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
archived_at: string | null
|
||||
}
|
||||
|
||||
export interface RevisionResponse {
|
||||
data: TranslationSessionData
|
||||
reason: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: string
|
||||
is_admin: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
|
|||
126
frontend/src/views/AdminView.vue
Normal file
126
frontend/src/views/AdminView.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import * as api from '../api'
|
||||
import type { UserSummary } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
|
||||
defineEmits<{ (event: 'close'): void }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
const users = ref<UserSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const form = reactive({ id: '', password: '', is_admin: false })
|
||||
const passwords = reactive<Record<string, string>>({})
|
||||
|
||||
function message(caught: unknown, fallback: string) {
|
||||
return isAxiosError<{ detail?: string }>(caught)
|
||||
? caught.response?.data?.detail || fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
users.value = await api.listUsers()
|
||||
} catch (caught: unknown) {
|
||||
error.value = message(caught, '사용자 목록을 불러오지 못했습니다')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
await api.createUser(form)
|
||||
success.value = `${form.id} 사용자를 만들었습니다.`
|
||||
form.id = ''
|
||||
form.password = ''
|
||||
form.is_admin = false
|
||||
await loadUsers()
|
||||
} catch (caught: unknown) {
|
||||
error.value = message(caught, '사용자를 만들지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
async function update(user: UserSummary, patch: { password?: string; is_admin?: boolean; is_active?: boolean }) {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
await api.updateUser(user.id, patch)
|
||||
success.value = `${user.id} 계정을 변경했습니다.`
|
||||
passwords[user.id] = ''
|
||||
await loadUsers()
|
||||
} catch (caught: unknown) {
|
||||
error.value = message(caught, '사용자를 변경하지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen text-gray-800 dark:text-gray-100">
|
||||
<header class="sticky top-0 z-10 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
|
||||
<div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="$emit('close')" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">번역기로</button>
|
||||
<h1 class="text-lg font-bold">사용자 관리</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="theme.toggle()" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm dark:bg-gray-700">{{ theme.isDark ? '라이트' : '다크' }}</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-300">{{ auth.user_id }}</span>
|
||||
<button @click="auth.logout()" class="rounded-lg bg-red-50 px-3 py-1.5 text-sm text-red-600 dark:bg-red-900/30">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl space-y-6 px-4 py-6">
|
||||
<section class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-4 font-semibold">새 사용자</h2>
|
||||
<form class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]" @submit.prevent="addUser">
|
||||
<input v-model.trim="form.id" required maxlength="50" pattern="[A-Za-z0-9_.-]+" placeholder="사용자 ID" class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
|
||||
<input v-model="form.password" required minlength="8" type="password" autocomplete="new-password" placeholder="비밀번호 (8자 이상)" class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="form.is_admin" type="checkbox" /> 관리자</label>
|
||||
<button :disabled="loading" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">추가</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">{{ error }}</div>
|
||||
<div v-if="success" class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300">{{ success }}</div>
|
||||
|
||||
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
|
||||
<h2 class="font-semibold">사용자 {{ users.length }}명</h2>
|
||||
<button @click="loadUsers" :disabled="loading" class="text-sm text-blue-600 disabled:opacity-50 dark:text-blue-400">새로고침</button>
|
||||
</div>
|
||||
<div v-if="loading && !users.length" class="p-8 text-center text-sm text-gray-400">불러오는 중...</div>
|
||||
<div v-else class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<article v-for="user in users" :key="user.id" class="grid gap-4 p-5 lg:grid-cols-[1fr_auto_auto_2fr] lg:items-center">
|
||||
<div>
|
||||
<div class="font-medium">{{ user.id }} <span v-if="user.id === auth.user_id" class="text-xs text-blue-500">내 계정</span></div>
|
||||
<div class="mt-1 text-xs text-gray-400">생성 {{ new Date(user.created_at).toLocaleDateString('ko-KR') }}</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" :checked="user.is_admin" :disabled="user.id === auth.user_id" @change="update(user, { is_admin: ($event.target as HTMLInputElement).checked })" /> 관리자
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" :checked="user.is_active" :disabled="user.id === auth.user_id" @change="update(user, { is_active: ($event.target as HTMLInputElement).checked })" /> 활성
|
||||
</label>
|
||||
<form class="flex gap-2" @submit.prevent="update(user, { password: passwords[user.id] })">
|
||||
<input v-model="passwords[user.id]" required minlength="8" type="password" autocomplete="new-password" placeholder="새 비밀번호" class="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
|
||||
<button class="whitespace-nowrap rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">재설정</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -11,11 +11,12 @@ const store = useTranslationStore()
|
|||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
const showHistorySidebar = ref(false)
|
||||
defineEmits<{ (event: 'open-admin'): void }>()
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadModels()
|
||||
store.newSession()
|
||||
store.loadHistory()
|
||||
await store.loadHistory()
|
||||
})
|
||||
|
||||
// Language options
|
||||
|
|
@ -56,13 +57,26 @@ function handleNewTranslation() {
|
|||
}
|
||||
store.newSession()
|
||||
}
|
||||
|
||||
async function openHistorySession(id: string) {
|
||||
await store.openSession(id)
|
||||
showHistorySidebar.value = false
|
||||
}
|
||||
|
||||
async function removeSession(id: string) {
|
||||
if (confirm('이 번역 기록을 영구 삭제하시겠습니까?')) await store.remove(id)
|
||||
}
|
||||
|
||||
async function restoreRevision() {
|
||||
if (confirm('현재 내용을 직전 저장 버전으로 되돌리시겠습니까?')) await store.restorePreviousRevision()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- Top Bar -->
|
||||
<header class="bg-white/90 dark:bg-gray-800/90 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 shadow-sm backdrop-blur">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -72,7 +86,7 @@ function handleNewTranslation() {
|
|||
<h1 class="text-lg font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button
|
||||
@click="theme.toggle()"
|
||||
|
|
@ -84,13 +98,21 @@ function handleNewTranslation() {
|
|||
|
||||
<!-- History Button -->
|
||||
<button
|
||||
@click="showHistorySidebar = true"
|
||||
@click="showHistorySidebar = true; store.loadHistory()"
|
||||
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5"
|
||||
title="히스토리"
|
||||
>
|
||||
🕐 히스토리 ({{ store.history.length }})
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="auth.isAdmin"
|
||||
@click="$emit('open-admin')"
|
||||
class="px-3 py-1.5 text-sm bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg transition-colors"
|
||||
>
|
||||
사용자 관리
|
||||
</button>
|
||||
|
||||
<!-- User Info & Logout -->
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ auth.user_id }}</span>
|
||||
<button @click="auth.logout()" class="px-3 py-1.5 text-sm bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 rounded-lg transition-colors">
|
||||
|
|
@ -102,18 +124,24 @@ function handleNewTranslation() {
|
|||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div v-if="store.archivedAt" class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
<span>보관된 번역입니다. 내용을 편집하거나 단계를 실행하려면 먼저 복원하세요.</span>
|
||||
<button @click="store.archive(store.sessionId!, false)" class="rounded-lg bg-amber-100 px-3 py-1.5 font-medium hover:bg-amber-200 dark:bg-amber-900/50">복원</button>
|
||||
</div>
|
||||
|
||||
<!-- Model Selection Row -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" />
|
||||
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" />
|
||||
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" />
|
||||
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" :disabled="!store.editable" />
|
||||
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" :disabled="!store.editable" />
|
||||
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" :disabled="!store.editable" />
|
||||
|
||||
<!-- Language Selectors -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">출발어</label>
|
||||
<select
|
||||
v-model="store.sourceLanguage"
|
||||
:disabled="!store.editable"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option v-for="lang in languages" :key="lang.value" :value="lang.value">
|
||||
|
|
@ -128,6 +156,7 @@ function handleNewTranslation() {
|
|||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">도착어</label>
|
||||
<select
|
||||
v-model="store.targetLanguage"
|
||||
:disabled="!store.editable"
|
||||
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option v-for="lang in targetLanguages" :key="lang.value" :value="lang.value">
|
||||
|
|
@ -137,9 +166,13 @@ function handleNewTranslation() {
|
|||
</div>
|
||||
|
||||
<!-- New Translation Button -->
|
||||
<button @click="handleNewTranslation()" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
<button @click="handleNewTranslation()" :disabled="store.loading || store.saving" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50">
|
||||
🔄 새 번역
|
||||
</button>
|
||||
<button v-if="store.sessionId && store.editable" @click="restoreRevision" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||
이전 버전 복원
|
||||
</button>
|
||||
<span v-if="store.saving" class="text-xs text-gray-400">저장 중...</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -151,9 +184,10 @@ function handleNewTranslation() {
|
|||
</div>
|
||||
<textarea
|
||||
v-model="store.sourceText"
|
||||
:disabled="!store.editable"
|
||||
rows="10"
|
||||
placeholder="번역할 원문을 여기에 붙여넣으세요..."
|
||||
class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
|
|
@ -161,7 +195,7 @@ function handleNewTranslation() {
|
|||
<section class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="runPhase(1)"
|
||||
:disabled="store.loading || !store.sourceText.trim() || !store.modelPhase1"
|
||||
:disabled="store.loading || store.saving || !store.sourceText.trim() || !store.modelPhase1"
|
||||
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 1">⏳</span>
|
||||
|
|
@ -171,7 +205,7 @@ function handleNewTranslation() {
|
|||
|
||||
<button
|
||||
@click="runPhase(2)"
|
||||
:disabled="store.loading || !store.phase2Ready"
|
||||
:disabled="store.loading || store.saving || !store.phase2Ready"
|
||||
class="px-5 py-2.5 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 2">⏳</span>
|
||||
|
|
@ -181,7 +215,7 @@ function handleNewTranslation() {
|
|||
|
||||
<button
|
||||
@click="runPhase(3)"
|
||||
:disabled="store.loading || !store.phase3Ready"
|
||||
:disabled="store.loading || store.saving || !store.phase3Ready"
|
||||
class="px-5 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 3">⏳</span>
|
||||
|
|
@ -191,7 +225,7 @@ function handleNewTranslation() {
|
|||
|
||||
<button
|
||||
@click="runPhase(4)"
|
||||
:disabled="store.loading || !store.phase4Ready"
|
||||
:disabled="store.loading || store.saving || !store.phase4Ready"
|
||||
class="px-5 py-2.5 bg-pink-600 text-white rounded-lg font-medium hover:bg-pink-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 4">⏳</span>
|
||||
|
|
@ -236,6 +270,7 @@ function handleNewTranslation() {
|
|||
<ProperNounsEditor
|
||||
:proper_nouns="store.phase2ProperNouns"
|
||||
:style="store.phase2Style"
|
||||
:disabled="!store.editable"
|
||||
@update:proper_nouns="(v) => store.phase2ProperNouns = v"
|
||||
@update:style="(v) => store.phase2Style = v"
|
||||
/>
|
||||
|
|
@ -259,31 +294,45 @@ function handleNewTranslation() {
|
|||
|
||||
<!-- History Sidebar (overlay) -->
|
||||
<Transition name="slide">
|
||||
<div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-80 bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
|
||||
<div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-full max-w-sm bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center justify-between">
|
||||
번역 히스토리
|
||||
<button @click="showHistorySidebar = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
|
||||
</h3>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
|
||||
<div class="mb-4 flex items-center justify-between text-xs text-gray-400">
|
||||
<span>서버에 저장된 번역과 보관함</span>
|
||||
<button @click="store.loadHistory()" :disabled="store.historyLoading" class="text-blue-500 disabled:opacity-50">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.historyLoading && store.history.length === 0" class="text-sm text-gray-400 italic mt-4">불러오는 중...</div>
|
||||
<div v-else-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
|
||||
아직 번역 기록이 없습니다.
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-for="(entry, idx) in store.history" :key="idx">
|
||||
<li v-for="entry in store.history" :key="entry.session_id" class="rounded-lg border border-gray-100 p-3 dark:border-gray-600">
|
||||
<button
|
||||
@click="store.restoreFromHistory(entry); showHistorySidebar = false"
|
||||
class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-600 transition-colors"
|
||||
@click="openHistorySession(entry.session_id)"
|
||||
:disabled="store.saving || store.sessionLoading"
|
||||
class="w-full text-left rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<div class="text-xs text-gray-400 mb-1">
|
||||
{{ new Date(entry.timestamp).toLocaleString('ko-KR') }}
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-gray-400">
|
||||
<span>{{ new Date(entry.updated_at).toLocaleString('ko-KR') }}</span>
|
||||
<span v-if="entry.archived_at" class="rounded bg-amber-100 px-1.5 py-0.5 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">보관됨</span>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-700 dark:text-gray-200 line-clamp-2">
|
||||
<div class="text-sm font-semibold text-gray-700 dark:text-gray-200 line-clamp-2">{{ entry.title }}</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{{ entry.source_preview || '(빈 원문)' }}
|
||||
</div>
|
||||
<div class="text-xs text-blue-500 mt-1">→ {{ entry.target_language }}</div>
|
||||
<div class="text-xs text-blue-500 mt-1">Phase {{ entry.current_phase }} · → {{ entry.target_language }}</div>
|
||||
</button>
|
||||
<div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 pt-2 text-xs dark:border-gray-700">
|
||||
<button @click="store.clone(entry.session_id)" class="text-blue-600 hover:underline dark:text-blue-400">복제</button>
|
||||
<button v-if="entry.archived_at" @click="store.archive(entry.session_id, false)" class="text-emerald-600 hover:underline dark:text-emerald-400">복원</button>
|
||||
<button v-else @click="store.archive(entry.session_id, true)" class="text-amber-600 hover:underline dark:text-amber-400">보관</button>
|
||||
<button @click="removeSession(entry.session_id)" class="ml-auto text-red-600 hover:underline dark:text-red-400">삭제</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue