Files
sales-trainer/frontend/src/App.vue
Macky ab5fff0bd9 [verified] IP-protect pain (hide from all roles) + persona summary button + topbar user dropdown
- Backend: strip pain/painProgress/revealed_persona.pains from debrief
  serializer and remove pain/initialPainFit from admin report so the
  coaching formula never leaks to any user-facing role (persona keeps
  pains internally to drive the judge/training)
- Fix [object Object] array-of-objects rendering in Chat/SessionDetail
- Personas: trained persona shows 'สรุปผล/Summary' button -> chat page
  with past result (chat already loads finished session)
- Top bar: username dropdown containing Settings + Logout (was separate
  logout button); variant-from-base already preserves tier/difficulty
- Updated debrief allowlist tests to reflect new redaction
2026-08-19 07:26:21 +07:00

136 lines
5.0 KiB
Vue

<template>
<div class="app">
<nav v-if="auth.user" class="topnav">
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link>
<div class="tabs">
<router-link v-if="auth.isAdmin" to="/" class="tab" :class="{ active: $route.path === '/' }">
{{ i18n.t('adminOverview') }}
</router-link>
<router-link to="/my/board" class="tab" :class="{ active: $route.path === '/my/board' }">
{{ i18n.t('myDashboard') }}
</router-link>
<router-link to="/training" class="tab" :class="{ active: $route.path === '/training' }">
{{ i18n.t('training') }}
</router-link>
</div>
<div class="nav-right">
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
<div class="user-menu" ref="menuRef">
<button @click="menuOpen = !menuOpen" class="user-btn" :class="{ open: menuOpen }">
<UserCircle :size="20" :stroke-width="1.8" />
<span class="txt">{{ auth.user.username || auth.user.email || auth.user.name || auth.user.id }}</span>
<ChevronDown :size="14" :stroke-width="2" class="chev" :class="{ up: menuOpen }" />
</button>
<div v-if="menuOpen" class="dropdown">
<router-link to="/settings" class="item" @click="menuOpen = false">
<SettingsIcon :size="16" :stroke-width="1.8" /> {{ i18n.t('settings') }}
</router-link>
<button class="item" @click="logout"><LogOut :size="16" :stroke-width="1.8" /> {{ i18n.t('logout') }}</button>
</div>
</div>
</div>
</nav>
<main class="main">
<!-- :key forces each view to re-evaluate i18n.t() when locale changes -->
<router-view :key="i18n.locale" />
</main>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Settings as SettingsIcon, LogOut, UserCircle, ChevronDown } from 'lucide-vue-next'
import { auth } from './store/auth'
import { i18n } from './i18n'
const router = useRouter()
const menuOpen = ref(false)
const menuRef = ref(null)
function toggleLang() {
i18n.set(i18n.locale === 'th' ? 'en' : 'th')
}
function logout() {
menuOpen.value = false
auth.logout()
router.push('/login')
}
function onDocClick(e) {
if (menuRef.value && !menuRef.value.contains(e.target)) menuOpen.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
if (auth.token && !auth.user) auth.load()
})
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
</script>
<style scoped>
.topnav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 24px;
background: #fff;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
flex-wrap: wrap;
}
.brand { font-weight: 800; text-decoration: none; color: var(--ink); white-space: nowrap; }
.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
.tab {
text-decoration: none;
color: var(--muted);
padding: 8px 16px;
border-radius: 10px;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
}
.tab:hover { background: #f1f3f9; color: var(--ink); }
.tab.active { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; }
.nav-right { display: flex; align-items: center; gap: 8px; }
.lang { padding: 8px 12px; }
.user-menu { position: relative; }
.user-btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 12px; border-radius: 8px; font-weight: 600; color: var(--ink);
}
.user-btn:hover, .user-btn.open { background: #f1f3f9; }
.chev { transition: transform .15s ease; }
.chev.up { transform: rotate(180deg); }
.dropdown {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 30;
background: #fff; border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 8px 24px rgba(15,23,42,.10); min-width: 190px; overflow: hidden;
}
.dropdown .item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 10px 14px; border-radius: 0; background: transparent; color: var(--ink);
text-align: left; font-size: 14px;
}
.dropdown .item:hover { background: #f1f3f9; }
.dropdown .item + .item { border-top: 1px solid var(--border); }
.dropdown a.item { text-decoration: none; }
.icon-btn { text-decoration: none; display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 8px; color: var(--ink); }
.icon-btn:hover { background: #f1f3f9; }
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
@media (max-width: 640px) {
.topnav { padding: 8px 10px; gap: 8px; }
.brand { font-size: 14px; }
.tabs { order: 3; width: 100%; justify-content: center; }
.tab { padding: 6px 10px; font-size: 12px; flex: 1; text-align: center; }
.nav-right { margin-left: auto; }
.lang { padding: 6px 8px; font-size: 12px; }
.user-btn { padding: 6px 8px; }
.user-btn .txt { max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (max-width: 380px) {
.brand { display: none; }
}
</style>