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:
@@ -6,10 +6,13 @@
|
|||||||
* VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置)
|
* VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置)
|
||||||
* PORT 监听端口(默认 3000)
|
* PORT 监听端口(默认 3000)
|
||||||
* DATA_DIR SQLite 数据目录(默认 backend/data)
|
* DATA_DIR SQLite 数据目录(默认 backend/data)
|
||||||
|
* VCM_USERNAME 面板登录账号(默认 admin)
|
||||||
|
* VCM_PASSWORD 面板登录密码(默认 admin123,Docker 部署务必通过 compose 覆盖)
|
||||||
*/
|
*/
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import { execSync } from 'node:child_process'
|
import { execSync } from 'node:child_process'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import {
|
import {
|
||||||
@@ -163,6 +166,37 @@ function buildPackages(keyword = '') {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 登录鉴权:内置账号(Docker 部署通过 VCM_USERNAME/VCM_PASSWORD 环境变量覆盖) ——
|
||||||
|
const AUTH_USERNAME = process.env.VCM_USERNAME || 'admin'
|
||||||
|
const AUTH_PASSWORD = process.env.VCM_PASSWORD || 'admin123'
|
||||||
|
const sessions = new Map() // token -> { name, at };内存态,进程重启后需重新登录
|
||||||
|
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
const { username, password } = req.body || {}
|
||||||
|
if (String(username || '') !== AUTH_USERNAME || String(password || '') !== AUTH_PASSWORD) {
|
||||||
|
return res.status(401).json({ ok: false, message: '用户名或密码错误' })
|
||||||
|
}
|
||||||
|
const token = randomUUID()
|
||||||
|
sessions.set(token, { name: AUTH_USERNAME, at: Date.now() })
|
||||||
|
res.json({ ok: true, token, user: { name: AUTH_USERNAME } })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
if (auth.startsWith('Bearer ')) sessions.delete(auth.slice(7))
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保护全部 /api/*(/login、/logout 除外):未登录返回 401,前端据此跳转登录页
|
||||||
|
app.use('/api', (req, res, next) => {
|
||||||
|
if (req.path === '/login' || req.path === '/logout') return next()
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
if (!auth.startsWith('Bearer ') || !sessions.has(auth.slice(7))) {
|
||||||
|
return res.status(401).json({ ok: false, message: '未登录或会话已失效' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
// —— API:阈值 ——
|
// —— API:阈值 ——
|
||||||
// spec §4.2:GET/POST /api/threshold(版本阈值)
|
// spec §4.2:GET/POST /api/threshold(版本阈值)
|
||||||
app.get('/api/threshold', (req, res) => {
|
app.get('/api/threshold', (req, res) => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ services:
|
|||||||
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方挂载的右侧
|
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方挂载的右侧
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
|
- TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
|
||||||
|
- VCM_USERNAME=${VCM_USERNAME:-admin} # 面板登录账号(可在宿主机 .env 覆盖)
|
||||||
|
- VCM_PASSWORD=${VCM_PASSWORD:-admin123} # 面板登录密码(务必改为强密码)
|
||||||
volumes:
|
volumes:
|
||||||
- "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage" # Verdaccio 缓存目录(读写,清理需要)
|
- "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage" # Verdaccio 缓存目录(读写,清理需要)
|
||||||
- "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题)
|
- "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import AppTopbar from './components/AppTopbar.vue'
|
import AppTopbar from './components/AppTopbar.vue'
|
||||||
import MobileTabbar from './components/MobileTabbar.vue'
|
import MobileTabbar from './components/MobileTabbar.vue'
|
||||||
import SettingDrawer from './components/SettingDrawer.vue'
|
import SettingDrawer from './components/SettingDrawer.vue'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { state, refreshAll, scanNow } = useStore()
|
const { state, refreshAll, scanNow } = useStore()
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
const isLogin = () => route.path === '/login'
|
const isLogin = () => route.path === '/login'
|
||||||
@@ -22,9 +23,11 @@ function onFocusOut() {
|
|||||||
if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) kbOpen.value = false
|
if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) kbOpen.value = false
|
||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
document.addEventListener('focusin', onFocusIn)
|
document.addEventListener('focusin', onFocusIn)
|
||||||
document.addEventListener('focusout', onFocusOut)
|
document.addEventListener('focusout', onFocusOut)
|
||||||
|
// 等待初始路由导航完成后再判断页面,避免登录页上误触发数据加载(此时 route.path 仍是 /)
|
||||||
|
await router.isReady()
|
||||||
if (!isLogin()) refreshAll()
|
if (!isLogin()) refreshAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,10 +51,6 @@ const fmtScanBytes = computed(() => {
|
|||||||
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
|
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
|
||||||
return `${mb.toFixed(1)} MB`
|
return `${mb.toFixed(1)} MB`
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!isLogin()) refreshAll()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+71
-13
@@ -7,6 +7,31 @@ import * as mock from '../mock/data.js'
|
|||||||
|
|
||||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
|
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 内存态(模拟服务端持久化) ——
|
// —— mock 内存态(模拟服务端持久化) ——
|
||||||
const mockState = reactive({
|
const mockState = reactive({
|
||||||
versionThreshold: 15,
|
versionThreshold: 15,
|
||||||
@@ -43,12 +68,45 @@ function computeStatus(p, threshold) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
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() {
|
async getSummary() {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
await delay()
|
await delay()
|
||||||
return mock.summary()
|
return mock.summary()
|
||||||
}
|
}
|
||||||
const r = await fetch('/api/packages')
|
const r = await authFetch('/api/packages')
|
||||||
const { packages } = await r.json()
|
const { packages } = await r.json()
|
||||||
const valid = packages.filter(p => p.status !== 'broken')
|
const valid = packages.filter(p => p.status !== 'broken')
|
||||||
const warnCount = valid.filter(p => p.status === 'warn').length
|
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))
|
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
|
||||||
return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false }
|
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()
|
const data = await r.json()
|
||||||
return {
|
return {
|
||||||
packages: data.packages || [],
|
packages: data.packages || [],
|
||||||
@@ -88,7 +146,7 @@ const api = {
|
|||||||
const p = clone(mockState.packages).find(x => x.name === name) || null
|
const p = clone(mockState.packages).find(x => x.name === name) || null
|
||||||
return { ok: !!p, pkg: p }
|
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 }
|
if (!r.ok) return { ok: false, pkg: null }
|
||||||
return r.json()
|
return r.json()
|
||||||
},
|
},
|
||||||
@@ -99,7 +157,7 @@ const api = {
|
|||||||
await delay(300)
|
await delay(300)
|
||||||
return { ok: true, scanning: false, scannedAt: new Date().toISOString() }
|
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()
|
return r.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -108,7 +166,7 @@ const api = {
|
|||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
return { scanning: false, done: true, packages: 33, bytes: 0 }
|
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()
|
return r.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -121,7 +179,7 @@ const api = {
|
|||||||
exceptions: clone(mockState.exceptions),
|
exceptions: clone(mockState.exceptions),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const r = await fetch('/api/thresholds')
|
const r = await authFetch('/api/thresholds')
|
||||||
return r.json()
|
return r.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,7 +191,7 @@ const api = {
|
|||||||
mockState.exceptions = clone(payload.exceptions || [])
|
mockState.exceptions = clone(payload.exceptions || [])
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
const r = await fetch('/api/thresholds', {
|
const r = await authFetch('/api/thresholds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -146,7 +204,7 @@ const api = {
|
|||||||
await delay(80)
|
await delay(80)
|
||||||
return [...mockState.ignored]
|
return [...mockState.ignored]
|
||||||
}
|
}
|
||||||
const r = await fetch('/api/ignored')
|
const r = await authFetch('/api/ignored')
|
||||||
return r.json()
|
return r.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -164,7 +222,7 @@ const api = {
|
|||||||
})
|
})
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
const r = await fetch('/api/ignore', {
|
const r = await authFetch('/api/ignore', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pkgName }),
|
body: JSON.stringify({ pkgName }),
|
||||||
@@ -189,7 +247,7 @@ const api = {
|
|||||||
})
|
})
|
||||||
return { ok: true }
|
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()
|
return r.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -221,7 +279,7 @@ const api = {
|
|||||||
mockState.logs.unshift(log)
|
mockState.logs.unshift(log)
|
||||||
return { ok: true, deletedCount: toDel.length, freedSpace: `${freed.toFixed(1)} MB`, deletedVersions: log.deletedVersions }
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pkgName, keep }),
|
body: JSON.stringify({ pkgName, keep }),
|
||||||
@@ -242,7 +300,7 @@ const api = {
|
|||||||
p.status = computeStatus(p, mockState.versionThreshold)
|
p.status = computeStatus(p, mockState.versionThreshold)
|
||||||
return { ok: true, freedSpace: `${v.size.toFixed(1)} MB` }
|
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',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
return r.json()
|
return r.json()
|
||||||
@@ -253,7 +311,7 @@ const api = {
|
|||||||
await delay(80)
|
await delay(80)
|
||||||
return clone(mockState.logs)
|
return clone(mockState.logs)
|
||||||
}
|
}
|
||||||
const r = await fetch('/api/logs')
|
const r = await authFetch('/api/logs')
|
||||||
return r.json()
|
return r.json()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -25,7 +26,7 @@ function go(path) {
|
|||||||
if (route.path !== path) router.push(path)
|
if (route.path !== path) router.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(action) {
|
async function pick(action) {
|
||||||
if (action === 'theme') {
|
if (action === 'theme') {
|
||||||
// 主题切换即时生效,保持菜单打开以便反复对比
|
// 主题切换即时生效,保持菜单打开以便反复对比
|
||||||
toggle()
|
toggle()
|
||||||
@@ -35,7 +36,7 @@ function pick(action) {
|
|||||||
if (action === 'rescan') emit('rescan')
|
if (action === 'rescan') emit('rescan')
|
||||||
else if (action === 'thresholds') router.push('/thresholds')
|
else if (action === 'thresholds') router.push('/thresholds')
|
||||||
else if (action === 'logout') {
|
else if (action === 'logout') {
|
||||||
localStorage.removeItem('vcm-user')
|
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页(否则守卫会因 token 仍在而重定向回首页)
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
const props = defineProps({ modelValue: Boolean })
|
const props = defineProps({ modelValue: Boolean })
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
@@ -18,8 +19,8 @@ function goThresholds() {
|
|||||||
router.push('/thresholds')
|
router.push('/thresholds')
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
localStorage.removeItem('vcm-user')
|
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页
|
||||||
close()
|
close()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
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) {
|
if (!to.meta.public && !logged) {
|
||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -12,22 +13,20 @@ const password = ref('')
|
|||||||
const remember = ref(true)
|
const remember = ref(true)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
function login() {
|
async function login() {
|
||||||
if (!username.value.trim() || !password.value) {
|
if (!username.value.trim() || !password.value) {
|
||||||
ElMessage.warning('请输入用户名和密码')
|
ElMessage.warning('请输入用户名和密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
// 演示账号:任意非空即可登录(真实环境对接后端鉴权)
|
const res = await api.login(username.value.trim(), password.value, remember.value)
|
||||||
setTimeout(() => {
|
loading.value = false
|
||||||
localStorage.setItem('vcm-user', JSON.stringify({
|
if (!res.ok) {
|
||||||
name: username.value.trim(),
|
ElMessage.error(res.message || '用户名或密码错误')
|
||||||
email: `${username.value.trim()}@verdaccio.local`,
|
return
|
||||||
}))
|
}
|
||||||
loading.value = false
|
ElMessage.success('欢迎回来 👋')
|
||||||
ElMessage.success('欢迎回来 👋')
|
router.push(route.query.redirect || '/')
|
||||||
router.push(route.query.redirect || '/')
|
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ function login() {
|
|||||||
<button class="login-btn" :disabled="loading" @click="login">
|
<button class="login-btn" :disabled="loading" @click="login">
|
||||||
{{ loading ? '登录中…' : '🚀 登 录' }}
|
{{ loading ? '登录中…' : '🚀 登 录' }}
|
||||||
</button>
|
</button>
|
||||||
<div class="login-tip">演示环境:任意用户名 / 密码即可登录</div>
|
<div class="login-tip">默认账号 admin · 密码 admin123(Docker 部署可通过环境变量修改)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user