Files
verdaccio-cache-manager/frontend/src/App.vue
T
陈银军 02ac744e13 feat(auth): 内置账号密码登录,Docker 部署可通过环境变量覆盖
- 后端读取 VCM_USERNAME/VCM_PASSWORD(默认 admin/admin123),新增 /api/login、/api/logout,Bearer token 中间件保护全部 /api/*(登录/登出除外)
- 前端登录接真实接口,token 存 localStorage(记住我)/ sessionStorage;401 自动清除会话跳登录页
- 修复 App.vue 初始路由未就绪导致登录页误触发数据加载;退出登录先清会话再跳转
- docker-compose 增加 VCM_USERNAME/VCM_PASSWORD 环境变量(可用宿主机 .env 覆盖)
2026-09-19 22:49:32 +08:00

238 lines
7.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppTopbar from './components/AppTopbar.vue'
import MobileTabbar from './components/MobileTabbar.vue'
import SettingDrawer from './components/SettingDrawer.vue'
import { useStore } from './store'
const route = useRoute()
const router = useRouter()
const { state, refreshAll, scanNow } = useStore()
const settingsOpen = ref(false)
const isLogin = () => route.path === '/login'
const isMobile = () => window.matchMedia('(max-width: 767px)').matches
// 移动端软键盘弹出时隐藏底部 Tabbar:iOS Safari 会把 fixed 底部导航顶到视口中部
const kbOpen = ref(false)
function onFocusIn(e) {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) kbOpen.value = true
}
function onFocusOut() {
setTimeout(() => {
const a = document.activeElement
if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) kbOpen.value = false
}, 120)
}
onMounted(async () => {
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
// 等待初始路由导航完成后再判断页面,避免登录页上误触发数据加载(此时 route.path 仍是 /)
await router.isReady()
if (!isLogin()) refreshAll()
})
// 数据基于何时扫描(后端扫描时间,缓存复用)
const fmtScanTime = computed(() => {
const t = state.scannedAt
if (!t) return ''
const d = new Date(t)
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
})
async function doRescan() {
await scanNow()
}
// 扫描进度字节数 → 可读文本
const fmtScanBytes = computed(() => {
const b = state.scanProgress.bytes || 0
const mb = b / 1024 / 1024
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
return `${mb.toFixed(1)} MB`
})
</script>
<template>
<div class="ambient"></div>
<div v-if="!isLogin()">
<!-- 桌面/平板顶栏 -->
<AppTopbar class="desktop-topbar" @rescan="doRescan" />
<!-- 移动端顶栏:☰ + 品牌 -->
<header class="m-topbar">
<div class="m-brand">
<div class="m-logo">📦</div>
<div><b>Verdaccio</b>&nbsp;<span>Manager</span></div>
</div>
<div class="m-menu" @click="settingsOpen = true">☰</div>
</header>
<!-- 导航栏下方:扫描信息条 -->
<div class="scan-banner" v-if="state.loading || state.scannedAt">
<span v-if="state.loading" class="scan-banner-loading">⏳ 正在扫描缓存目录,请稍候…</span>
<span v-else>🕐 数据基于 {{ fmtScanTime }} 扫描</span>
</div>
<!-- 扫描遮罩:毛玻璃 + loading + 实时进度 -->
<div v-if="state.loading" class="scan-mask">
<div class="scan-card">
<div class="scan-spinner"></div>
<div class="scan-title">正在扫描缓存目录…</div>
<div class="scan-info">
已扫描 <b>{{ state.scanProgress.packages.toLocaleString() }}</b> 个包
<span class="dot">·</span> <b>{{ fmtScanBytes }}</b>
</div>
<div class="scan-bar"><i></i></div>
</div>
</div>
</div>
<router-view v-slot="{ Component }">
<transition name="fade-slide" appear>
<component :is="Component" :key="route.path" />
</transition>
</router-view>
<MobileTabbar v-if="!isLogin() && isMobile()" :kb="kbOpen" />
<SettingDrawer v-model="settingsOpen" />
</template>
<style scoped>
.m-topbar { display: none; }
/* —— 扫描信息条(顶栏下方) —— */
.scan-banner {
display: flex;
align-items: center;
justify-content: flex-start; /* 靠左对齐 */
gap: 8px;
padding: 6px 20px;
font-size: 12px;
color: var(--ink-faint);
background: var(--panel-soft);
border-bottom: 1px solid var(--line-soft);
font-family: var(--num);
}
.scan-banner-loading {
color: var(--orange);
font-weight: 600;
animation: blink 1.2s ease-in-out infinite;
}
/* —— 扫描遮罩(玻璃拟态:半透明背景 + 页面模糊 + 细边框/高光)
遮罩层负责模糊:半透明背景 + backdrop-filter blur 使背后页面内容呈现磨砂玻璃感;
卡片只做半透明 + 细边框 + 高光,不叠加自身 blur(避免双倍模糊) —— */
.scan-mask {
position: fixed;
inset: 0;
z-index: 500;
display: grid;
place-items: center;
background: rgba(18, 16, 14, .38);
backdrop-filter: blur(14px) saturate(1.3);
}
html[data-theme="bright"] .scan-mask {
background: rgba(244, 242, 239, .45);
backdrop-filter: blur(14px) saturate(1.3);
}
.scan-card {
position: relative;
background: rgba(28, 26, 24, .62);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 20px;
box-shadow:
0 24px 60px -16px rgba(0, 0, 0, .35),
inset 0 1px 0 rgba(255, 255, 255, .18);
padding: 32px 44px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 300px;
max-width: 92vw;
}
html[data-theme="bright"] .scan-card {
background: rgba(255, 255, 255, .6);
border-color: rgba(255, 255, 255, .55);
box-shadow:
0 24px 60px -16px rgba(0, 0, 0, .22),
inset 0 1px 0 rgba(255, 255, 255, .85);
}
.scan-spinner {
width: 40px;
height: 40px;
border-radius: 50%;
border: 4px solid var(--line);
border-top-color: var(--orange);
animation: spin360 .8s linear infinite;
}
.scan-title { font-size: 15px; font-weight: 700; }
.scan-info { font-family: var(--num); font-size: 12.5px; color: var(--ink-dim); }
.scan-info b { color: var(--orange); font-weight: 700; }
.scan-info .dot { margin: 0 4px; color: var(--ink-faint); }
.scan-bar {
width: 100%;
height: 6px;
border-radius: 99px;
background: var(--num-bg);
overflow: hidden;
margin-top: 6px;
}
.scan-bar i {
display: block;
height: 100%;
width: 42%;
border-radius: 99px;
background: linear-gradient(90deg, transparent, var(--orange), transparent);
animation: scan-slide 1.1s ease-in-out infinite;
}
@keyframes scan-slide {
from { transform: translateX(-110%); }
to { transform: translateX(340%); }
}
@media (max-width: 767px) {
.desktop-topbar { display: none; }
.m-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
background: var(--card);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 100;
}
.scan-banner {
padding: 5px 16px;
font-size: 11px;
}
.m-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
.m-logo {
width: 30px;
height: 30px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 15px;
background: var(--grad-main);
}
.m-brand b { color: var(--orange); }
.m-brand span { color: var(--ink-faint); font-weight: 600; font-size: 12px; }
.m-menu {
width: 34px;
height: 34px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--card-2);
border: 1px solid var(--line);
font-size: 16px;
color: var(--ink-dim);
cursor: pointer;
}
}
</style>