修改登录退出逻辑

This commit is contained in:
dxc
2026-02-04 14:29:59 +08:00
parent 13590b1fac
commit fd5600b65b
12 changed files with 411 additions and 235 deletions

View File

@ -1,6 +1,43 @@
<script setup lang="ts">
// 1. 引入需要的图标组件
import { InfoFilled } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { InfoFilled, SwitchButton, UserFilled } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
// --- 退出登录逻辑 Start ---
const handleLogout = () => {
ElMessageBox.confirm(
'确定要退出系统吗?',
'提示',
{
confirmButtonText: '确定退出',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
// 1. 调用 Store 的 logout 清除状态
userStore.logout()
// 2. 提示消息
ElMessage({
type: 'success',
message: '已安全退出',
})
// 3. [关键修改] 强制跳转回登录页
// 使用 replace这样用户点浏览器“返回”按钮不会又回到系统里
// 此时 store.token 已为空,路由守卫会放行 /login
await router.replace('/login')
})
.catch(() => {
// 取消操作
})
}
// --- 退出登录逻辑 End ---
</script>
<template>
@ -14,6 +51,22 @@ import { InfoFilled } from '@element-plus/icons-vue'
</div>
<div class="header-right">
<div class="user-profile">
<el-avatar :size="32" :icon="UserFilled" class="user-avatar" />
<span class="user-name">{{ userStore.username || '管理员' }}</span>
</div>
<el-divider direction="vertical" />
<el-button
type="danger"
link
@click="handleLogout"
class="logout-btn"
>
<el-icon style="margin-right: 4px; font-size: 16px"><SwitchButton /></el-icon>
退出
</el-button>
</div>
</header>
@ -31,35 +84,16 @@ import { InfoFilled } from '@element-plus/icons-vue'
</template>
<style>
/* 注意App.vue 中的 style 标签通常不加 scoped
或者将全局样式(html, body)单独放在一个 style 标签中,
以确保 html, body 的高度设置能生效
*/
/* --- 全局重置样式 Start --- */
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f7fa; /* 整体背景色 */
overflow: hidden; /* 防止最外层出现双滚动条 */
}
#app {
height: 100%;
}
/* --- 全局重置样式 End --- */
/* 保持原有的样式,不需要改动 */
.app-wrapper {
display: flex;
flex-direction: column;
height: 100vh; /* 强制占满视口高度 */
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: #f5f7fa;
}
/* 顶部栏样式 */
.app-header {
height: 60px;
background-color: #ffffff;
@ -68,56 +102,88 @@ html, body {
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
flex-shrink: 0; /* 禁止被压缩 */
z-index: 1000; /* 确保头部在最上层 */
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
flex-shrink: 0;
z-index: 1000;
}
.logo-container {
display: flex;
align-items: center;
height: 100%;
}
.home-link {
display: flex;
align-items: center;
gap: 15px;
gap: 12px;
text-decoration: none;
cursor: pointer;
height: 100%;
user-select: none;
}
.logo {
height: 36px; /* 稍微调整高度适配 */
height: 32px;
width: auto;
object-fit: contain;
}
.system-title {
font-size: 20px;
font-size: 18px;
font-weight: 600;
color: #303133;
letter-spacing: 1px;
letter-spacing: 0.5px;
white-space: nowrap;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.user-profile {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
}
.user-avatar {
background-color: #409eff;
}
.user-name {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.logout-btn {
font-weight: 400;
padding: 4px 8px;
}
.logout-btn:hover {
color: #f56c6c !important;
}
/* 内容区样式 */
.app-content {
flex: 1; /* 自动占据剩余空间 */
overflow: hidden; /* 这里设为 hidden让内部的 Layout 组件去处理滚动 */
flex: 1;
min-height: 0;
width: 100%;
position: relative;
/* 如果您希望整个页面有内边距,可以加 padding
但通常建议 padding 加在具体的业务页面里,保持 Layout 铺满 */
padding: 0;
overflow: hidden;
}
/* 底部栏样式 */
.app-footer {
height: 36px;
height: 30px;
background-color: #f0f2f5;
border-top: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0; /* 禁止被压缩 */
flex-shrink: 0;
font-size: 12px;
color: #909399;
z-index: 1000;
@ -126,10 +192,5 @@ html, body {
.version-tag {
display: flex;
align-items: center;
font-weight: 500;
color: #e6a23c; /* 橙色警告色 */
background: rgba(230, 162, 60, 0.1); /* 淡橙色背景 */
padding: 2px 8px;
border-radius: 4px;
}
</style>

View File

@ -1,19 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
// 使用 'type' 关键字导入 RouteRecordRaw
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
import { useUserStore } from '@/stores/user' // [新增] 引入 Store 用于权限判断
import { useUserStore } from '@/stores/user'
const routes: Array<RouteRecordRaw> = [
// [新增] 登录页 (不需要 Layout)
// 1. 登录页
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { hidden: true } // 不在侧边栏显示
meta: { hidden: true }
},
// 1. 首页 Dashboard
// 2. 首页 Dashboard
{
path: '/',
component: Layout,
@ -28,7 +27,7 @@ const routes: Array<RouteRecordRaw> = [
]
},
// 2. 基础信息 (对应 views/material/list.vue)
// 3. 基础信息
{
path: '/material',
component: Layout,
@ -37,14 +36,13 @@ const routes: Array<RouteRecordRaw> = [
{
path: 'index',
name: 'MaterialBase',
// 基础信息列表
component: () => import('@/views/material/list.vue'),
meta: { title: '基础信息', icon: 'Box' }
}
]
},
// 3. 库存管理 (采购/半成品/成品/权益)
// 4. 库存管理
{
path: '/inventory',
component: Layout,
@ -54,35 +52,31 @@ const routes: Array<RouteRecordRaw> = [
{
path: 'buy',
name: 'InventoryBuy',
// 采购入库页面
component: () => import('@/views/stock/inbound/buy.vue'),
meta: { title: '采购件' }
},
{
path: 'semi',
name: 'InventorySemi',
// 半成品页面
component: () => import('@/views/stock/inbound/semi.vue'),
meta: { title: '半成品' }
},
{
path: 'product',
name: 'InventoryProduct',
// 成品页面
component: () => import('@/views/stock/inbound/product.vue'),
meta: { title: '成品' }
},
{
path: 'service',
name: 'InventoryService',
// 服务权益页面
component: () => import('@/views/stock/inbound/service.vue'),
meta: { title: '服务权益' }
}
]
},
// 4. 业务操作 (借库/维修/报废)
// 5. 业务操作
{
path: '/operation',
component: Layout,
@ -92,70 +86,43 @@ const routes: Array<RouteRecordRaw> = [
{
path: 'borrow',
name: 'OpBorrow',
// 借库页面
component: () => import('@/views/transaction/borrow.vue'),
meta: { title: '借库' }
},
{
path: 'repair',
name: 'OpRepair',
// 维修页面 (指向 return.vue)
component: () => import('@/views/transaction/return.vue'),
meta: { title: '维修' }
},
{
path: 'scrap',
name: 'OpScrap',
// 报废页面
component: () => import('@/views/transaction/scrap.vue'),
meta: { title: '报废' }
}
]
},
// 5. [修改] 系统管理 (权限控制 + 用户创建)
// 6. 系统管理
{
path: '/system',
component: Layout,
meta: {
title: '系统管理',
icon: 'Setting',
// 只有超级管理员和主管能看到此菜单
roles: ['super_admin', 'supervisor']
},
children: [
{
path: 'user-create',
name: 'UserCreate',
// 指向我们之前创建的新增用户页面
component: () => import('@/views/system/UserCreate.vue'),
meta: { title: '账号开通', icon: 'User' }
},
// 原有的日志页面保留 (如果文件存在)
// {
// path: 'log',
// name: 'OpLog',
// component: () => import('@/views/system/log.vue'),
// meta: { title: '操作日志', icon: 'Document' }
// }
}
]
},
/* * 暂时屏蔽 BOM
*/
// {
// path: '/bom',
// component: Layout,
// children: [
// {
// path: 'index',
// name: 'BOM',
// component: () => import('@/views/bom/index.vue'),
// meta: { title: 'BOM管理', icon: 'List' }
// }
// ]
// },
// 404 路由
{
path: '/:pathMatch(.*)*',
@ -170,37 +137,45 @@ const router = createRouter({
})
// ==========================================
// [新增] 全局路由守卫:处理登录拦截与权限验证
// [关键修改] 全局路由守卫
// ==========================================
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const token = userStore.token
const userRole = userStore.role
// 1. 白名单:如果是去登录页,直接放行
// 1. 实时获取 Token (优先取 store防止 store 未初始化取 localStorage)
const token = userStore.token || localStorage.getItem('token')
const userRole = userStore.role || localStorage.getItem('role') || 'user'
// 2. 如果要去的是登录页
if (to.path === '/login') {
next()
// 如果有 Token说明已登录踢回首页 (防止重复登录)
if (token) {
next('/')
} else {
// 没有 Token允许访问登录页
next()
}
return
}
// 2. 无 Token强制跳转登录页
// 3. 如果去的不是登录页,但没有 Token
if (!token) {
next('/login')
// 强制重定向到登录页
// 使用 replace 防止用户点击浏览器“返回”按钮时进入死循环
next({ path: '/login', replace: true })
return
}
// 3. 权限判断:检查 meta.roles
// 4. 权限判断 (已有 Token)
if (to.meta.roles && Array.isArray(to.meta.roles)) {
// 如果当前用户角色在允许列表中,放行
if (to.meta.roles.includes(userRole)) {
next()
} else {
// 权限不足,重定向到首页或 403 页面 (这里简单跳回 dashboard)
// 可以在这里触发一个 Element Plus 的 Message 提示
// 权限不足,跳回首页
next('/dashboard')
}
} else {
// 没有定义权限要求的页面,默认放行
// 无特殊权限要求,放行
next()
}
})

View File

@ -3,44 +3,85 @@ import { login } from '@/api/auth'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
// 1. State: 初始化时优先从 localStorage 获取,防止刷新丢失
const token = ref(localStorage.getItem('token') || '')
const role = ref(localStorage.getItem('role') || '') // 持久化角色
const role = ref(localStorage.getItem('role') || '')
const username = ref(localStorage.getItem('username') || '')
// 2. Actions
// 登录逻辑
const handleLogin = async (loginForm: any) => {
try {
const res = await login(loginForm)
// res.data 结构: { access_token, user: { role, username, ... } }
const data = res.data
// [调试日志] 查看实际返回的数据结构 (调试完成后可删除)
console.log('Login API Response:', res)
// ============================================================
// [关键修复] 兼容 Axios 拦截器的不同处理方式
// 如果拦截器已经返回了 response.data那么 res 本身就是数据对象
// 如果拦截器返回的是原始 response那么数据在 res.data 中
// ============================================================
const data = res.data || res
// 安全检查:确保 data 存在且包含 access_token
if (!data || !data.access_token) {
console.error('Login Error: 响应数据中缺少 access_token', data)
return false
}
// 更新 Pinia 状态 (内存)
token.value = data.access_token
role.value = data.user.role
username.value = data.user.username
// 持久化存储 (简单处理生产环境建议加密或仅存Token)
// 处理用户信息 (确保后端返回结构中有 user 字段)
if (data.user) {
role.value = data.user.role || 'user' // 默认给个 user 角色防止空
username.value = data.user.username || '用户'
// 持久化存储用户信息
localStorage.setItem('role', role.value)
localStorage.setItem('username', username.value)
}
// 持久化存储 Token
localStorage.setItem('token', data.access_token)
localStorage.setItem('role', data.user.role)
localStorage.setItem('username', data.user.username)
return true
} catch (error) {
console.error(error)
console.error('Login failed:', error)
return false
}
}
// 退出逻辑
const logout = () => {
// 1. 清空 Pinia 状态 (内存)
token.value = ''
role.value = ''
username.value = ''
localStorage.clear()
window.location.reload()
// 2. 清空 LocalStorage (硬盘)
// 建议使用 removeItem 而不是 clear避免误删该域名下其他非登录数据
localStorage.removeItem('token')
localStorage.removeItem('role')
localStorage.removeItem('username')
// 注意:这里不再执行 window.location.reload()
// 而是把跳转控制权交给调用者 (如 App.vue 中的 router.push)
}
// 辅助函数:判断当前用户是否拥有某些角色
// 3. Getters / Helpers
// 判断当前用户是否拥有某些角色
const hasRole = (roles: string[]) => {
return roles.includes(role.value)
}
return { token, role, username, handleLogin, logout, hasRole }
return {
token,
role,
username,
handleLogin,
logout,
hasRole
}
})

View File

@ -1,18 +1,31 @@
/* inventory-web/src/style.css */
/* 1. 保留原有的字体定义,确保文字清晰好看 */
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
/* 颜色方案配置 */
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
/* 字体渲染优化 */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 2. 针对亮色模式的颜色适配 (保留) */
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
}
/* 3. 链接的基本样式 (保留,但通常 RouterLink 会覆盖) */
a {
font-weight: 500;
color: #646cff;
@ -22,58 +35,44 @@ a:hover {
color: #535bf2;
}
body {
/* -------------------------------------------------
【重要修改区域】
下面的代码是为了修复“无法铺满全屏”的问题
-------------------------------------------------
*/
/* 4. 全局盒模型修复:防止 padding 撑大元素 */
*, *::before, *::after {
box-sizing: border-box;
}
/* 5. 重置 body 和 html */
html, body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
padding: 0;
width: 100%;
height: 100%; /* 强制高度占满 */
/* !!! 删除了原有的 display: flex; place-items: center;
这是导致你页面缩在中间的罪魁祸首
*/
display: block;
overflow: hidden; /* 防止最外层出现双滚动条 */
}
/* 6. 重置 #app 挂载点 */
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
/* !!! 删除了 max-width: 1280px; padding: 2rem; text-align: center;
这是导致你页面两边留白、无法全屏的原因
*/
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
/* 注意:原文件中关于 button, .card 的样式已被删除,
因为你的项目中引入了 Element Plus
保留原生 button 样式会和 Element Plus 组件产生冲突。
*/

View File

@ -5,7 +5,7 @@
<div class="filter-container">
<el-input
v-model="queryParams.keyword"
placeholder="请输入名称或规格 (支持模糊搜索)"
placeholder="请输入名称、俗名或规格"
style="width: 240px; margin-right: 10px;"
clearable
@input="handleInputSearch"
@ -82,6 +82,7 @@
</div>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.name.visible" label="名称" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" />
<el-checkbox v-model="columns.category.visible" label="类别" />
<el-checkbox v-model="columns.type.visible" label="类型" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
@ -103,7 +104,15 @@
style="width: 100%; margin-top: 15px"
>
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.name.visible" prop="name" label="基础信息名称" min-width="180" show-overflow-tooltip />
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="120" align="center" show-overflow-tooltip>
<template #default="scope">{{ scope.row.category || '-' }}</template>
</el-table-column>
@ -162,9 +171,18 @@
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入基础信息名称" />
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="标准名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="俗名" prop="commonName">
<el-input v-model="form.commonName" placeholder="日常叫法/别名" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
@ -254,6 +272,7 @@ import {
interface MaterialBaseVO {
id: number;
name: string;
commonName?: string; // ✅ 新增类型定义
category: string;
type: string;
spec: string;
@ -284,6 +303,7 @@ const tableSize = ref<'large' | 'default' | 'small'>('large');
const columns = reactive({
id: { visible: true },
name: { visible: true },
commonName: { visible: true }, // ✅ 新增列控制
category: { visible: true },
type: { visible: true },
spec: { visible: true },
@ -316,6 +336,7 @@ const formRef = ref<FormInstance>();
const initForm = {
id: undefined,
name: '',
commonName: '', // ✅ 初始化新增字段
category: '',
type: '',
spec: '',
@ -350,13 +371,10 @@ const extractDynamicOptions = (items: MaterialBaseVO[]) => {
typeOptions.value = Array.from(newTypes);
};
// 【核心新增】Autocomplete 的建议查询方法
// 格式化数据以适配 el-autocomplete 的回调参数格式 [{ value: 'abc' }]
const querySearchCategory = (queryString: string, cb: any) => {
const results = queryString
? categoryOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: categoryOptions.value;
// el-autocomplete 默认只展示 value 属性
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};