2026-09-19 15:00:04 +08:00
|
|
|
|
<script setup>
|
2026-09-19 21:07:27 +08:00
|
|
|
|
import { ref, computed, onMounted } from 'vue'
|
2026-09-19 15:00:04 +08:00
|
|
|
|
import { useRoute } 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()
|
2026-09-19 21:07:27 +08:00
|
|
|
|
const { state, refreshAll, scanNow } = useStore()
|
2026-09-19 15:00:04 +08:00
|
|
|
|
const settingsOpen = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
const isLogin = () => route.path === '/login'
|
|
|
|
|
|
const isMobile = () => window.matchMedia('(max-width: 767px)').matches
|
|
|
|
|
|
|
2026-09-19 21:07:27 +08:00
|
|
|
|
// 数据基于何时扫描(后端扫描时间,缓存复用)
|
|
|
|
|
|
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`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-19 15:00:04 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (!isLogin()) refreshAll()
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="ambient"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="!isLogin()">
|
|
|
|
|
|
<!-- 桌面/平板顶栏 -->
|
2026-09-19 21:07:27 +08:00
|
|
|
|
<AppTopbar class="desktop-topbar" @open-settings="settingsOpen = true" @rescan="doRescan" />
|
2026-09-19 15:00:04 +08:00
|
|
|
|
<!-- 移动端顶栏:☰ + 品牌 -->
|
|
|
|
|
|
<header class="m-topbar">
|
|
|
|
|
|
<div class="m-brand">
|
|
|
|
|
|
<div class="m-logo">📦</div>
|
2026-09-19 15:34:41 +08:00
|
|
|
|
<div><b>Verdaccio</b> <span>Manager</span></div>
|
2026-09-19 15:00:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="m-menu" @click="settingsOpen = true">☰</div>
|
|
|
|
|
|
</header>
|
2026-09-19 21:07:27 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 导航栏下方:扫描信息条 -->
|
|
|
|
|
|
<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>
|
2026-09-19 15:00:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<router-view v-slot="{ Component }">
|
2026-09-19 15:29:20 +08:00
|
|
|
|
<transition name="fade-slide" appear>
|
|
|
|
|
|
<component :is="Component" :key="route.path" />
|
2026-09-19 15:00:04 +08:00
|
|
|
|
</transition>
|
|
|
|
|
|
</router-view>
|
|
|
|
|
|
|
|
|
|
|
|
<MobileTabbar v-if="!isLogin() && isMobile()" />
|
|
|
|
|
|
<SettingDrawer v-model="settingsOpen" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.m-topbar { display: none; }
|
|
|
|
|
|
|
2026-09-19 21:07:27 +08:00
|
|
|
|
/* —— 扫描信息条(顶栏下方) —— */
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* —— 扫描遮罩(玻璃拟态:半透明背景 + 卡片磨砂模糊 + 细边框/高光)
|
|
|
|
|
|
遮罩层只做半透明(避免全屏 blur 的性能开销与双倍模糊),
|
|
|
|
|
|
真正的毛玻璃效果做在卡片上:半透明背景 + backdrop-filter 模糊 + 细边框 + 高光 —— */
|
|
|
|
|
|
.scan-mask {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 500;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
background: rgba(18, 16, 14, .38);
|
|
|
|
|
|
}
|
|
|
|
|
|
html[data-theme="bright"] .scan-mask { background: rgba(244, 242, 239, .45); }
|
|
|
|
|
|
.scan-card {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
background: rgba(28, 26, 24, .55);
|
|
|
|
|
|
backdrop-filter: blur(18px) saturate(1.35);
|
|
|
|
|
|
-webkit-backdrop-filter: blur(18px) saturate(1.35);
|
|
|
|
|
|
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, .55);
|
|
|
|
|
|
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%); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:00:04 +08:00
|
|
|
|
@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;
|
|
|
|
|
|
}
|
2026-09-19 21:07:27 +08:00
|
|
|
|
.scan-banner {
|
|
|
|
|
|
padding: 5px 16px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
2026-09-19 15:00:04 +08:00
|
|
|
|
.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>
|