增加登录功能和权限验证

This commit is contained in:
lanzhihui 2026-02-05 10:24:40 +08:00
parent cd26a81af5
commit 118aab4f3b
16 changed files with 515 additions and 85 deletions

View File

@ -1,6 +1,7 @@
export const redirects = JSON.parse("{}")
export const routes = Object.fromEntries([
["/login.html", { loader: () => import(/* webpackChunkName: "login.html" */"D:/xue/dma_handbook/docs/.vuepress/.temp/pages/login.html.js"), meta: {"title":"DMA手册 - 登录"} }],
["/", { loader: () => import(/* webpackChunkName: "index.html" */"D:/xue/dma_handbook/docs/.vuepress/.temp/pages/index.html.js"), meta: {"title":"首页"} }],
["/posts/administrative.html", { loader: () => import(/* webpackChunkName: "posts_administrative.html" */"D:/xue/dma_handbook/docs/.vuepress/.temp/pages/posts/administrative.html.js"), meta: {"title":"行政"} }],
["/posts/agent.html", { loader: () => import(/* webpackChunkName: "posts_agent.html" */"D:/xue/dma_handbook/docs/.vuepress/.temp/pages/posts/agent.html.js"), meta: {"title":"代理商添加方式说明"} }],

View File

@ -1,4 +1,4 @@
export const siteData = JSON.parse("{\"base\":\"/dma_handbook/\",\"lang\":\"zh-CN\",\"title\":\"DMA服务人员服务操作手册\",\"description\":\"DMA服务人员服务操作手册\",\"head\":[[\"meta\",{\"name\":\"og:type\",\"content\":\"website\"}],[\"meta\",{\"property\":\"og:title\",\"content\":\"DMA服务人员操作手册\"}],[\"meta\",{\"name\":\"description\",\"content\":\"DMA服务操作手册\"}],[\"meta\",{\"property\":\"og:description\",\"content\":\"DMA服务全流程操作指南\"}],[\"meta\",{\"property\":\"og:image\",\"content\":\"https://images.health.ufutx.com/202503/12/1f227399ffc2ddbf6c58eafa80627d19.png?v=1769757244438\"}],[\"link\",{\"rel\":\"icon\",\"href\":\"https://images.health.ufutx.com/202503/12/1f227399ffc2ddbf6c58eafa80627d19.png?v=1769757244438\"}]],\"locales\":{}}")
export const siteData = JSON.parse("{\"base\":\"/dma_handbook/\",\"lang\":\"zh-CN\",\"title\":\"DMA服务人员服务操作手册\",\"description\":\"DMA服务人员服务操作手册\",\"head\":[[\"meta\",{\"name\":\"og:type\",\"content\":\"website\"}],[\"meta\",{\"property\":\"og:title\",\"content\":\"DMA服务人员操作手册\"}],[\"meta\",{\"name\":\"description\",\"content\":\"DMA服务操作手册\"}],[\"meta\",{\"property\":\"og:description\",\"content\":\"DMA服务全流程操作指南\"}],[\"meta\",{\"property\":\"og:image\",\"content\":\"https://images.health.ufutx.com/202503/12/1f227399ffc2ddbf6c58eafa80627d19.png?v=1770021719831\"}],[\"link\",{\"rel\":\"icon\",\"href\":\"https://images.health.ufutx.com/202503/12/1f227399ffc2ddbf6c58eafa80627d19.png?v=1770021719831\"}]],\"locales\":{}}")
if (import.meta.webpackHot) {
import.meta.webpackHot.accept()

View File

@ -215,6 +215,8 @@
</tr>
</tbody>
</table>
<div v-auth="'coach'">
</div>
</div></template>

View File

@ -1,17 +1,108 @@
// 导入自定义布局
import LoginLayout from './layouts/LoginLayout.vue'
// 导入默认布局必须保留否则其他页面会404
import DefaultLayout from '@vuepress/theme-default/layouts/Layout.vue'
import { defineClientConfig } from 'vuepress/client'
import { createPinia } from 'pinia'
// 1. 补全所有缺失的核心导入适配docs根目录的store/utils
import { useUserStore } from './store/modules/user'
import { showToast, getUserInfo } from '../utils/request' // 新增getUserInfo
// 核心修改导入全局常量SITE_BASE
import { SITE_BASE } from './constants.js';
// 原有组件导入(位置不变,无需修改)
import WithAuth from './components/WithAuth.vue'
import Login from './components/Login.vue'
import helperHTML from './components/helperHTML.vue'
import longPic from './components/longPic.vue'
export default defineClientConfig({
enhance({ app }) {
// 新增:注册布局
layouts: {
Layout: DefaultLayout, // 默认布局
LoginLayout: LoginLayout // 登录页布局
},
// 2. 解构出router路由守卫必须用
enhance({ app, router }) {
const pinia = createPinia()
app.use(pinia)
// 全局注册组件(原有逻辑,无问题)
app.component('WithAuth', WithAuth)
app.component('Login', Login)
app.component('helperHTML', helperHTML)
app.component('longPic', longPic)
// 权限指令v-auth修复this指向+Pinia实例化问题
app.directive('auth', {
mounted(el, binding) {
// 3. Pinia仓库必须执行实例化核心修复
const userStore = useUserStore()
const requiredRoles = binding.value
if (!requiredRoles) return
// 未登录/无权限直接隐藏
if (!userStore.isLogin) {
el.style.display = 'none'
return
}
let hasPermission = false
if (typeof requiredRoles === 'string') {
hasPermission = userStore.hasRole(requiredRoles)
} else if (Array.isArray(requiredRoles)) {
hasPermission = requiredRoles.some(role => userStore.hasRole(role))
}
if (!hasPermission) el.style.display = 'none'
},
updated(el, binding) {
// 4. 修复this指向问题直接复用mounted逻辑不用this
this.mounted.call(this, el, binding)
}
})
// 全局路由守卫VuePress 2.x 适配版(白名单+未登录跳登录)
const whiteList = ['/login.html'] // 无需登录的页面
// 隐藏登录功能
// router.beforeEach(async (to, from, next) => {
// const userStore = useUserStore()
// const isLogin = userStore.isLogin
//
// // 白名单页面,直接放行
// if (whiteList.includes(to.path)) {
// next()
// return
// }
//
// // 未登录跳登录页并记录跳转前地址适配base: /dma_handbook/
// // 替换client.js中路由守卫的未登录跳转代码
// if (!isLogin) {
// showToast('请先登录后访问')
// // 核心修复直接使用to.fullPath已包含SITE_BASE无需手动拼接
// let redirectPath = to.fullPath
// // 兜底校验极端情况若fullPath未带SITE_BASE手动拼接防止地址栏手动修改
// if (!redirectPath.startsWith(SITE_BASE)) {
// redirectPath = `${SITE_BASE}${redirectPath.replace(/^\//, '')}` // 去掉开头的/,避免双斜杠
// }
// // 编码后拼接登录页地址
// const redirect = encodeURIComponent(redirectPath)
// window.location.href = `${SITE_BASE}login.html?redirect=${redirect}`
// return
// }
// // 核心修改:仅【已登录 + 未拉取过用户信息】时,才调用接口
// if (!userStore.isUserInfoFetched) {
// await getUserInfo(userStore)
// // 拉取成功后更新标记为true本次会话不再重复调用
// userStore.setUserInfoFetched(true)
// }
// // 已登录,正常放行
// next()
// })
// 全局退出登录方法6. 挂载到window所有环境Markdown/组件)都能调用
window.$logout = () => {
const userStore = useUserStore()
userStore.resetStore() // 清除Pinia+localStorage状态
showToast('退出登录成功')
window.location.href = `${SITE_BASE}login.html`
}
},
setup() {},
})

View File

@ -1,96 +1,158 @@
<script setup>
import {reactive} from 'vue'
import { useUserStore } from '../store/user'
const user = useUserStore()
const form = reactive({ username: '', password: '' })
const handleLogin = async () => {
try {
await user.login(form)
//
} catch (err) {
console.error('登录失败', err)
}
}
</script>
<template>
<div class="login-mask">
<div class="login-dialog">
<h3>用户登录</h3>
<form @submit.prevent="handleLogin">
<div class="input-group">
<input
v-model="form.username"
class="styled-input"
placeholder="用户名"
>
</div>
<div class="input-group">
<input
v-model="form.password"
type="password"
class="styled-input"
placeholder="密码"
>
</div>
<button type="submit" class="submit-btn">
登录
</button>
</form>
<div class="login-page">
<div class="login-form">
<h2>DMA服务手册 - 登录</h2>
<div class="form-item">
<label>账号</label>
<input v-model="form.username" type="text" placeholder="请输入账号" />
</div>
<div class="form-item">
<label>密码</label>
<input v-model="form.password" type="password" placeholder="请输入密码" />
</div>
<button class="login-btn" @click="handleLogin" :disabled="loading">
{{ loading ? '登录中...' : '立即登录' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import request from '../../utils/request';
import { useUserStore } from '../store/modules/user';
import { showToast } from '../../utils/request';
import { SITE_BASE } from '../constants.js';
// const VUEPRESS_BASE = '/dma_handbook/';
//
const form = ref({
username: '15622316024',
password: '123',
});
//
const loading = ref(false);
//
const userStore = useUserStore();
// URL
const redirect = ref('');
//
onMounted(() => {
const searchParams = new URLSearchParams(window.location.search);
console.log(searchParams,'searchParams');
// URLredirect
const rawRedirect = searchParams.get('redirect') || '';
if (rawRedirect) {
//
try {
redirect.value = decodeURIComponent(rawRedirect);
} catch (e) {
redirect.value = '';
}
}
// redirect/VUEPRESS_BASE
if (!redirect.value || !redirect.value.startsWith(SITE_BASE)) {
redirect.value = `${SITE_BASE}`; // /dma_handbook/
}
console.log(redirect.value,'searchParams');
});
//
const handleLogin = async () => {
//
if (!form.value.username) {
showToast('请输入账号');
return;
}
if (!form.value.password) {
showToast('请输入密码');
return;
}
loading.value = true;
let data = {
mobile: '15622316024',
area_code: 86,
code: '009527',
}
try {
//
const res = await request({
url: '/go/api/app/server/mobile/code/login', //
method: 'POST',
data: data,
hideLoading: true, // loading
});
// {token: 'xxx', userInfo: {id: 1, name: 'xxx', roles: ['coach']}}
userStore.setToken(res.api_token);
showToast('登录成功');
//
setTimeout(() => {
window.location.href = decodeURIComponent(redirect.value);
}, 1000);
} catch (err) {
console.log('登录失败:', err);
showToast(err.msg || err.message || '登录失败,请检查账号密码');
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
.login-page {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
justify-content: center;
background: #f5f5f5;
}
.login-dialog {
background: white;
padding: 2rem;
.login-form {
width: 350px;
padding: 30px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
width: 320px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.styled-input {
width: 100%;
padding: 12px;
border: 1px solid #e4e7ed;
.login-form h2 {
text-align: center;
margin-bottom: 20px;
color: #333;
}
.form-item {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
.form-item label {
font-size: 14px;
color: #666;
margin-bottom: 6px;
}
.form-item input {
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 4px;
margin-bottom: 1rem;
transition: border-color 0.3s;
}
.styled-input:focus {
border-color: #409eff;
font-size: 14px;
outline: none;
}
.submit-btn {
.form-item input:focus {
border-color: #299764;
}
.login-btn {
width: 100%;
padding: 12px;
background: #409eff;
color: white;
padding: 10px;
background: #299764;
color: #fff;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: opacity 0.3s;
}
.submit-btn:hover {
opacity: 0.9;
.login-btn:disabled {
background: #96d8b7;
cursor: not-allowed;
}
</style>

View File

@ -11,7 +11,7 @@
</template>
<script>
import { checkPermission, getCurrentUserRole, isLoggedIn, showLoginModal } from '../utils/auth';
import { checkPermission, getCurrentUserRole, isLoggedIn, showLoginModal } from '../../utils/auth';
export default {
props: ['requiredPerm'],

View File

@ -4,19 +4,20 @@ import { defineUserConfig } from 'vuepress'
import fs from 'fs-extra'
import path from 'path'
import { searchPlugin } from '@vuepress/plugin-search'
// 核心修改:从全局常量文件导入,替代本地定义
import { SITE_BASE, CDN_BASE} from './constants.js';
// import * as path from "path";
// const path = require("path");
// import {registerComponentsPlugin} from '@vuepress/plugin-register-components'
// const { registerComponentsPlugin } = require('@vuepress/plugin-register-components');
// 配置常量(cn和微信封锁域名)
const SITE_BASE = '/dma_handbook/'
// const SITE_BASE = '/dma_handbook/'
// 配置常量(本地部署)
// const SITE_BASE = '/go_html/dma_handbook/'
const CDN_BASE = 'https://images.health.ufutx.com/dp'
// const CDN_BASE = 'https://images.health.ufutx.com/dp'
export default defineUserConfig({
// ...其他配置...
@ -33,7 +34,14 @@ export default defineUserConfig({
// ['meta', { name: 'thumbnail', content: 'https://example.com/share-image.jpg' }],
// ['meta', { name: 'WeChat-Description', content: '点击查看完整DMA服务流程' }]
],
// 新增:自定义页面(登录页)
pages: [
// {
// path: '/login', // 登录页路由,访问地址:/dma_handbook/login.html
// title: 'DMA手册 - 登录',
// component: './components/Login.vue', // 关联登录组件
// },
],
bundler: viteBundler({
// vite bundler options here
viteOptions: {
@ -50,7 +58,18 @@ export default defineUserConfig({
// assetFileNames: 'assets/[name].[hash].[ext]',
}
}
}
},
// 新增:跨域代理配置(核心)
server: {
proxy: {
// 匹配所有以/api开头的请求转发到后端地址
'/api': {
target: 'http://192.168.0.100:8080/', // 你的实际后端基础地址(和.env中的一致
changeOrigin: true, // 开启跨域
rewrite: (path) => path.replace(/^\/api/, '') // 去掉请求路径中的/api前缀
}
}
},
}
}),
theme: defaultTheme({

View File

@ -0,0 +1,14 @@
// docs/.vuepress/constants.js
// 全局基础路径 - 唯一维护,修改后全项目生效
export const SITE_BASE = '/dma_handbook/';
// 配置常量本地部署需要时取消注释替换上面的SITE_BASE即可
// export const SITE_BASE = '/go_html/dma_handbook/';
// CDN基础路径
export const CDN_BASE = 'https://images.health.ufutx.com/dp';
// 后端代理前缀(可选,也可统一维护)
export const API_PROXY_PREFIX = '/api';
// 后端实际地址(可选,统一维护)
export const API_TARGET = 'http://192.168.0.100:8080/';

View File

@ -0,0 +1,9 @@
<template>
<!-- 直接渲染登录组件 -->
<Login />
</template>
<script setup>
//
import Login from '../components/Login.vue'
</script>

View File

@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('rt_token') || '',
userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}'),
roles: JSON.parse(localStorage.getItem('roles') || '[]'),
// 新增:会话级标记(是否已拉取过用户信息,不持久化,刷新自动重置)
isUserInfoFetched: false
}),
getters: {
isLogin: (state) => !!state.token, // 必须有
hasRole: (state) => (role) => state.roles.includes(role), // 必须有
},
actions: {
// 确保该方法存在:设置用户信息
setUserInfo(userInfo) {
this.userInfo = userInfo;
localStorage.setItem('userInfo', JSON.stringify(userInfo)); // 持久化到本地
},
// 确保该方法存在:设置权限角色
setRoles(roleList) {
this.roles = roleList;
localStorage.setItem('roles', JSON.stringify(roleList)); // 持久化到本地
},
// 新增:更新用户信息拉取标记
setUserInfoFetched(status) {
this.isUserInfoFetched = status;
},
resetStore() { // 必须有:退出登录清除状态
this.token = ''
this.userInfo = {}
this.roles = []
localStorage.removeItem('rt_token')
localStorage.removeItem('userInfo')
localStorage.removeItem('roles')
},
setToken(token) {
this.token = token
localStorage.setItem('rt_token', token)
},
// 其他setUserInfo/setRoles方法...
},
})

View File

@ -154,3 +154,5 @@
| 第一次对用户的评估报告生成 | 方案结束后第 3 天 18:00 前完成 |
| 用户上传复检报告提醒 | 方案结束后第 23 天发送 ` ` |
| 第二次对用户的评估报告生成 | 方案结束后第 60 天 18:00 前完成 |
<div v-auth="'coach'">
</div>

4
docs/login.md Normal file
View File

@ -0,0 +1,4 @@
---
title: DMA手册 - 登录
layout: LoginLayout
---

View File

@ -1,4 +1,4 @@
import { PERMISSIONS } from '../roles';
import { PERMISSIONS } from '../.vuepress/roles';
import axios from 'axios'
export function getCurrentUserRole() {
console.log('32-')
@ -12,7 +12,7 @@ export function checkPermission(currentRole, requiredPath) {
// console.log(rolePermissions,'rolePermissions=')
// return rolePermissions.includes('*') || rolePermissions.includes(requiredPath);
const allowedPaths = PERMISSIONS[currentRole] || [];
return allowedPaths.some(path =>
return allowedPaths.some(path =>
requiredPath.startsWith(path) || path === '*'
);
}

9
docs/utils/httpEnum.js Normal file
View File

@ -0,0 +1,9 @@
// 请求头Content-Type枚举
export const ContentTypeEnum = {
// 表单提交
FORM_URLENCODED: 'application/x-www-form-urlencoded;charset=UTF-8',
// 文件上传
FORM_DATA: 'multipart/form-data;charset=UTF-8',
// JSON提交
JSON: 'application/json;charset=UTF-8'
};

15
docs/utils/public.js Normal file
View File

@ -0,0 +1,15 @@
/**
* 登录成功后回填数据到Pinia仓库
* @param {object} res - 登录接口返回的用户数据
* @param {object} userStore - Pinia用户仓库实例
*/
export function backFillLoginData(res, userStore) {
if (res.token) {
userStore.setToken(res.token);
localStorage.setItem('rt_token', res.token);
}
if (res.userInfo) {
userStore.setUserInfo(res.userInfo);
userStore.setRoles(res.userInfo.roles || []);
}
}

159
docs/utils/request.js Normal file
View File

@ -0,0 +1,159 @@
import { ref } from 'vue';
import axios from 'axios';
import { ContentTypeEnum } from './httpEnum';
import { useUserStore } from '../.vuepress/store/modules/user';
// 适配VuePress服务端渲染仅在浏览器环境执行
const isBrowser = typeof window !== 'undefined';
// 协议自动适配
const baseHref = isBrowser ? (window.location.href.includes('https:') ? 'https:' : 'http:') : 'http:';
// 创建axios实例
const service = axios.create({
baseURL: 'https://health.ufutx.com/',
withCredentials: false,
timeout: 50000,
});
// 自定义请求配置仅保留hideLoading扩展
export const requestConfig = {
hideLoading: false
};
// 原生加载提示极简版适配VuePress
let loadingInstance = null;
const showLoading = (msg = '加载中...') => {
if (!isBrowser || loadingInstance) return;
loadingInstance = document.createElement('div');
loadingInstance.style.cssText = `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);padding:8px 16px;background:rgba(0,0,0,0.7);color:#fff;border-radius:4px;z-index:99999;font-size:14px;`;
loadingInstance.innerText = msg;
document.body.appendChild(loadingInstance);
};
const closeLoading = () => {
if (!isBrowser || !loadingInstance) return;
document.body.removeChild(loadingInstance);
loadingInstance = null;
};
// 原生全局提示(极简版)
export const showToast = (msg = '操作失败', duration = 2000) => {
if (!isBrowser) return;
const toast = document.createElement('div');
toast.style.cssText = `position:fixed;top:80%;left:50%;transform:translate(-50%,-50%);padding:8px 16px;background:rgba(0,0,0,0.7);color:#fff;border-radius:4px;z-index:99999;font-size:14px;pointer-events:none;`;
toast.innerText = msg;
document.body.appendChild(toast);
setTimeout(() => document.body.removeChild(toast), duration);
};
// 请求拦截器核心保留Token、Content-Type、版本号
service.interceptors.request.use(
(config) => {
// 加载提示控制
if (!config.hideLoading) showLoading();
// 初始化请求头
if (!config.headers) config.headers = {};
// 默认Content-Type为JSON
config.headers['Content-Type'] = config.headers['Content-Type'] || ContentTypeEnum.JSON;
// 携带版本号
if (isBrowser) config.headers['Version'] = import.meta.env.VITE_BASE_API_VERSION;
// 携带Token仅保留核心rt_token联动Pinia
if (isBrowser) {
const userStore = useUserStore();
const token = userStore.token || localStorage.getItem('rt_token') || '';
if (token) config.headers['Authorization'] = `Bearer ${token}`;
}
// 处理POST表单参数
if (config.method?.toLocaleUpperCase() === 'POST' && config.data) {
const contentType = config.headers['Content-Type'];
if (contentType === ContentTypeEnum.FORM_URLENCODED) {
config.data = qs.stringify(config.data);
}
}
return config;
},
(error) => {
closeLoading();
console.log('请求错误:', error);
return Promise.reject(error);
}
);
// 响应拦截器:精简状态码,仅保留核心业务逻辑
service.interceptors.response.use(
(response) => {
closeLoading();
const res = response.data;
// 成功码仅保留0后端统一成功标识剔除冗余兼容
if (res.code === 0) return Promise.resolve(res.data);
// 业务错误code!==0统一提示
showToast(res.msg || res.message || '请求失败');
return Promise.reject(res);
},
(error) => {
closeLoading();
// 网络/超时错误
if (error.message?.includes('timeout')) {
showToast('网络超时,请稍后重试');
}
// 401未授权核心鉴权逻辑跳登录页适配VuePress base路径
else if (error.response?.status === 401 && isBrowser) {
showToast('登录已过期,请重新登录');
const userStore = useUserStore();
// 清除状态,跳登录页并记录跳转地址
userStore.resetStore();
localStorage.removeItem('rt_token');
const redirect = encodeURIComponent(window.location.href);
window.location.href = `/dma_handbook/login?redirect=${redirect}`;
}
// 其他错误
else {
showToast(error.message || '网络异常');
}
console.log('响应错误:', error);
return Promise.reject(error);
}
);
// 统一请求方法极简封装适配VuePress
const request = (config) => {
return new Promise((resolve, reject) => {
service
.request(config)
.then((res) => resolve(res))
.catch((err) => reject(err));
});
};
// 放在request.js文件末尾showToast导出后、request默认导出前
/**
* 全局获取用户信息接口每次进入页面调用
* @param {object} userStore - Pinia用户仓库实例传入避免重复实例化
* @returns {Promise<boolean>} - 是否获取成功
*/
export const getUserInfo = async (userStore) => {
// 非浏览器环境/未登录,直接返回失败
if (typeof window === 'undefined' || !userStore.token) return false;
try {
const res = await request({
url: '/api/app/get/service/user/info', // 你的用户信息接口地址baseURL会自动拼接
method: 'get',
hideLoading: true, // 隐藏加载提示,避免页面闪烁
});
// 按你的接口返回格式更新仓库
if (res && res.service_user_info) {
userStore.setUserInfo(res.service_user_info); // 更新用户信息
}
if (res && res.role_list) {
userStore.setRoles(res.role_list); // 更新权限角色
}
return true;
} catch (err) {
// 接口失败轻量提示,不阻塞页面
showToast('用户信息同步失败', 1500);
console.log('获取用户信息失败:', err);
return false;
}
};
export default request;