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 覆盖)
This commit is contained in:
陈银军
2026-09-19 22:49:32 +08:00
parent 735eaa7bf4
commit 02ac744e13
9 changed files with 130 additions and 36 deletions
+5 -6
View File
@@ -1,12 +1,13 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
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'
@@ -22,9 +23,11 @@ function onFocusOut() {
if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) kbOpen.value = false
}, 120)
}
onMounted(() => {
onMounted(async () => {
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
// 等待初始路由导航完成后再判断页面,避免登录页上误触发数据加载(此时 route.path 仍是 /)
await router.isReady()
if (!isLogin()) refreshAll()
})
@@ -48,10 +51,6 @@ const fmtScanBytes = computed(() => {
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
return `${mb.toFixed(1)} MB`
})
onMounted(() => {
if (!isLogin()) refreshAll()
})
</script>
<template>
+71 -13
View File
@@ -7,6 +7,31 @@ import * as mock from '../mock/data.js'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
// —— 登录会话:token 存 localStorage(记住我)或 sessionStorage(关浏览器失效) ——
const TOKEN_KEY = 'vcm-token'
const USER_KEY = 'vcm-user'
function getToken() {
return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY) || ''
}
/** 统一请求封装:自动附带 Bearer token;401 时清除会话并跳转登录页 */
async function authFetch(url, options = {}) {
const token = getToken()
const r = await fetch(url, {
...options,
headers: { ...(options.headers || {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
})
if (r.status === 401) {
localStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
if (!location.pathname.startsWith('/login')) location.href = '/login'
throw new Error('未登录或会话已失效')
}
return r
}
// —— mock 内存态(模拟服务端持久化) ——
const mockState = reactive({
versionThreshold: 15,
@@ -43,12 +68,45 @@ function computeStatus(p, threshold) {
}
const api = {
/** 登录:校验后端内置账号(Docker 部署由 VCM_USERNAME/VCM_PASSWORD 决定);remember 决定 token 存 localStorage 还是 sessionStorage */
async login(username, password, remember = true) {
if (USE_MOCK) {
await delay(400)
if (username !== 'admin' || password !== 'admin123') return { ok: false, message: '用户名或密码错误' }
const token = `mock-${Date.now()}`
;(remember ? localStorage : sessionStorage).setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify({ name: username, email: `${username}@verdaccio.local` }))
return { ok: true, user: { name: username } }
}
// 登录接口公开:错误密码的 401 是业务响应,不能用 authFetch(会误判为会话失效并抛异常)
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok || !data.ok) return { ok: false, message: data.message || '登录失败' }
;(remember ? localStorage : sessionStorage).setItem(TOKEN_KEY, data.token)
localStorage.setItem(USER_KEY, JSON.stringify({ name: data.user?.name || username }))
return { ok: true, user: data.user }
},
/** 退出登录:通知后端销毁会话并清除本地 token */
async logout() {
try {
if (!USE_MOCK) await authFetch('/api/logout', { method: 'POST' })
} catch { /* 忽略网络错误,本地清除即可 */ }
localStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
},
async getSummary() {
if (USE_MOCK) {
await delay()
return mock.summary()
}
const r = await fetch('/api/packages')
const r = await authFetch('/api/packages')
const { packages } = await r.json()
const valid = packages.filter(p => p.status !== 'broken')
const warnCount = valid.filter(p => p.status === 'warn').length
@@ -69,7 +127,7 @@ const api = {
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false }
}
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
const r = await authFetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
const data = await r.json()
return {
packages: data.packages || [],
@@ -88,7 +146,7 @@ const api = {
const p = clone(mockState.packages).find(x => x.name === name) || null
return { ok: !!p, pkg: p }
}
const r = await fetch(`/api/packages/${encodeURIComponent(name)}`)
const r = await authFetch(`/api/packages/${encodeURIComponent(name)}`)
if (!r.ok) return { ok: false, pkg: null }
return r.json()
},
@@ -99,7 +157,7 @@ const api = {
await delay(300)
return { ok: true, scanning: false, scannedAt: new Date().toISOString() }
}
const r = await fetch('/api/scan', { method: 'POST' })
const r = await authFetch('/api/scan', { method: 'POST' })
return r.json()
},
@@ -108,7 +166,7 @@ const api = {
if (USE_MOCK) {
return { scanning: false, done: true, packages: 33, bytes: 0 }
}
const r = await fetch('/api/scan/progress')
const r = await authFetch('/api/scan/progress')
return r.json()
},
@@ -121,7 +179,7 @@ const api = {
exceptions: clone(mockState.exceptions),
}
}
const r = await fetch('/api/thresholds')
const r = await authFetch('/api/thresholds')
return r.json()
},
@@ -133,7 +191,7 @@ const api = {
mockState.exceptions = clone(payload.exceptions || [])
return { ok: true }
}
const r = await fetch('/api/thresholds', {
const r = await authFetch('/api/thresholds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -146,7 +204,7 @@ const api = {
await delay(80)
return [...mockState.ignored]
}
const r = await fetch('/api/ignored')
const r = await authFetch('/api/ignored')
return r.json()
},
@@ -164,7 +222,7 @@ const api = {
})
return { ok: true }
}
const r = await fetch('/api/ignore', {
const r = await authFetch('/api/ignore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pkgName }),
@@ -189,7 +247,7 @@ const api = {
})
return { ok: true }
}
const r = await fetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' })
const r = await authFetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' })
return r.json()
},
@@ -221,7 +279,7 @@ const api = {
mockState.logs.unshift(log)
return { ok: true, deletedCount: toDel.length, freedSpace: `${freed.toFixed(1)} MB`, deletedVersions: log.deletedVersions }
}
const r = await fetch('/api/clean', {
const r = await authFetch('/api/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pkgName, keep }),
@@ -242,7 +300,7 @@ const api = {
p.status = computeStatus(p, mockState.versionThreshold)
return { ok: true, freedSpace: `${v.size.toFixed(1)} MB` }
}
const r = await fetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, {
const r = await authFetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, {
method: 'DELETE',
})
return r.json()
@@ -253,7 +311,7 @@ const api = {
await delay(80)
return clone(mockState.logs)
}
const r = await fetch('/api/logs')
const r = await authFetch('/api/logs')
return r.json()
},
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme'
import { useStore } from '../store'
import api from '../api'
const route = useRoute()
const router = useRouter()
@@ -25,7 +26,7 @@ function go(path) {
if (route.path !== path) router.push(path)
}
function pick(action) {
async function pick(action) {
if (action === 'theme') {
// 主题切换即时生效,保持菜单打开以便反复对比
toggle()
@@ -35,7 +36,7 @@ function pick(action) {
if (action === 'rescan') emit('rescan')
else if (action === 'thresholds') router.push('/thresholds')
else if (action === 'logout') {
localStorage.removeItem('vcm-user')
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页(否则守卫会因 token 仍在而重定向回首页)
router.push('/login')
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme'
import api from '../api'
const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue'])
@@ -18,8 +19,8 @@ function goThresholds() {
router.push('/thresholds')
}
function logout() {
localStorage.removeItem('vcm-user')
async function logout() {
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页
close()
router.push('/login')
}
+1 -1
View File
@@ -26,7 +26,7 @@ const router = createRouter({
})
router.beforeEach((to) => {
const logged = localStorage.getItem('vcm-user') !== null
const logged = localStorage.getItem('vcm-token') !== null || sessionStorage.getItem('vcm-token') !== null
if (!to.meta.public && !logged) {
return { name: 'login', query: { redirect: to.fullPath } }
}
+11 -12
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTheme } from '../composables/useTheme'
import api from '../api'
const router = useRouter()
const route = useRoute()
@@ -12,22 +13,20 @@ const password = ref('')
const remember = ref(true)
const loading = ref(false)
function login() {
async function login() {
if (!username.value.trim() || !password.value) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
// 演示账号:任意非空即可登录(真实环境对接后端鉴权)
setTimeout(() => {
localStorage.setItem('vcm-user', JSON.stringify({
name: username.value.trim(),
email: `${username.value.trim()}@verdaccio.local`,
}))
loading.value = false
ElMessage.success('欢迎回来 👋')
router.push(route.query.redirect || '/')
}, 500)
const res = await api.login(username.value.trim(), password.value, remember.value)
loading.value = false
if (!res.ok) {
ElMessage.error(res.message || '用户名或密码错误')
return
}
ElMessage.success('欢迎回来 👋')
router.push(route.query.redirect || '/')
}
</script>
@@ -61,7 +60,7 @@ function login() {
<button class="login-btn" :disabled="loading" @click="login">
{{ loading ? '登录中…' : '🚀 登 录' }}
</button>
<div class="login-tip">演示环境:任意用户名 / 密码即可登录</div>
<div class="login-tip">默认账号 admin · 密码 admin123(Docker 部署可通过环境变量修改)</div>
</div>
</div>
</template>