feat(mobile): 实现全局路由守卫功能,自动处理用户未登录跳转

✨ 新增功能:
- 全局路由守卫模块 (route-guard.js)
- 多层次权限验证 (登录状态+Token存在+Token过期检查)
- 自动拦截机制 (页面级+跳转级+HTTP级+应用级)
- 401响应自动处理和跳转
- 路由守卫测试页面

🔧 技术实现:
- checkPageAccess() 全局页面权限检查
- guardedNavigate/guardedSwitchTab 带权限检查的跳转
- handleUnauthorized() HTTP拦截器401处理
- 公开页面配置管理
- 优雅的错误提示和跳转逻辑

📱 页面集成:
- App.vue 初始化路由守卫
- profile/workbench 页面添加权限检查
- index 页面优化跳转逻辑
- 新增测试页面验证功能

📚 文档完善:
- 移动端路由守卫使用指南
- 详细的变更记录和技术说明
- 最佳实践和注意事项

🎯 核心效果:
用户登录失效或未登录时,访问任何需要权限的页面都自动跳转到登录页,提升安全性和用户体验。
This commit is contained in:
zlwu committed 2025-07-04 22:28:49 +08:00
1 parent 5589cba249
commit afe3e234e9
33 files changed
+4710 -43

No files matched your search

+22
View File
@@ -918,3 +918,25 @@ node [功能]-api-test.js
- 补充缺失的测试代码路径信息
- 按规范重新组织文档结构
```
### 原型文件自动保存规范
1. **文件位置**:
- 移动端原型 → `docs/原型文件/移动端/`
- Web 端原型 → `docs/原型文件/web端/`
2. **自动保存触发条件**:
- 新建原型文件时自动创建目录结构
- 修改文件时自动保留历史版本(按`[功能]_v[版本]_[日期]`格式)
3. **文件权限预设**:
```bash
chmod 755 docs/原型文件/ # 确保目录可写
```
4. **冲突解决策略**:
- 同名文件自动添加版本后缀
- 中文路径自动转换为 UTF-8 编码
+859 -12
View File
@@ -356,7 +356,248 @@ export default {
- **组件名称**: 使用 PascalCase (如: `NavBar`, `ServiceItem`)
- **引用方式**: 支持驼峰和短横线 (如: `<NavBar>` 或 `<nav-bar>`)
## 5. 🌐 API 接口规范
## 5. 🎨 图标使用规范 (SVG Data URI 方案)
### 背景说明
由于 uview-plus 的 u-icon 组件在某些环境下存在显示异常(显示为横线),项目采用 **SVG Data URI** 方案实现图标系统,确保跨平台兼容性和显示稳定性。
### 核心优势
- ✅ **零外部依赖**: 图标直接嵌入 CSS,无需字体文件或 CDN
- ✅ **100%兼容性**: 支持 H5、小程序、App、HarmonyOS 等所有平台
- ✅ **加载稳定**: 无 404 错误,无网络请求,性能优异
- ✅ **样式灵活**: 通过 CSS filter 实现颜色自定义
- ✅ **维护简单**: 基于 Material Design,图标清晰美观
### 图标配置文件
**位置**: `mobile/src/static/fonts/iconfont.css`
```css
.iconfont {
display: inline-block;
width: 16px;
height: 16px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* 用户图标 */
.icon-user {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E");
}
/* 密码锁图标 */
.icon-lock {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6z'/%3E%3C/svg%3E");
}
/* 更多图标... */
```
### 全局引入配置
**位置**: `mobile/src/main.js`
```javascript
import { createSSRApp } from 'vue';
import App from './App.vue';
import uviewPlus from 'uview-plus';
// 导入 uview-plus 样式
import 'uview-plus/index.scss';
// 导入 iconfont 样式 (必需)
import '@/static/fonts/iconfont.css';
export function createApp() {
const app = createSSRApp(App);
app.use(uviewPlus);
return { app };
}
```
### 可用图标列表
#### 基础图标 (4 个)
- `icon-user` - 用户图标
- `icon-lock` - 密码锁图标
- `icon-eye` - 眼睛图标(显示密码)
- `icon-eye-close` - 眼睛关闭图标(隐藏密码)
#### 操作图标 (5 个)
- `icon-login` - 登录图标
- `icon-search` - 搜索图标
- `icon-add` - 添加图标
- `icon-check` - 确认图标
- `icon-close` - 关闭图标
#### 导航图标 (4 个)
- `icon-home` - 首页图标
- `icon-message` - 消息图标
- `icon-setting` - 设置图标
- `icon-arrow-right` - 右箭头图标
#### 联系图标 (2 个)
- `icon-phone` - 电话图标
- `icon-mail` - 邮箱图标
### 标准使用方法
#### 基础用法
```vue
<template>
<!-- 基础图标 -->
<text class="iconfont icon-user"></text>
<text class="iconfont icon-lock"></text>
<text class="iconfont icon-search"></text>
<!-- 在表单中使用 -->
<view class="input-group">
<text class="iconfont icon-user input-icon"></text>
<input class="login-input" placeholder="账号" />
</view>
<!-- 在按钮中使用 -->
<button class="btn-primary">
<text class="iconfont icon-login btn-icon"></text>
<text>登录</text>
</button>
</template>
```
#### 自定义样式
```vue
<template>
<!-- 自定义大小 -->
<text class="iconfont icon-home large-icon"></text>
<!-- 自定义颜色 -->
<text class="iconfont icon-message primary-icon"></text>
<!-- 内联样式 -->
<text
class="iconfont icon-setting"
:style="{
width: '24px',
height: '24px',
filter: 'brightness(0) saturate(100%) invert(38%) sepia(87%) saturate(6400%) hue-rotate(225deg) brightness(104%) contrast(104%)',
}"
></text>
</template>
<style lang="scss" scoped>
/* 大尺寸图标 */
.large-icon {
width: 24px;
height: 24px;
}
/* 主题色图标 #5d87ff */
.primary-icon {
filter: brightness(0) saturate(100%) invert(38%) sepia(87%) saturate(6400%) hue-rotate(225deg)
brightness(104%) contrast(104%);
}
/* 灰色图标 #b0b8c9 */
.gray-icon {
filter: brightness(0) saturate(100%) invert(67%) sepia(8%) saturate(1179%) hue-rotate(195deg)
brightness(95%) contrast(88%);
}
/* 输入框图标定位 */
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
z-index: 2;
}
/* 按钮图标 */
.btn-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
</style>
```
### 常用颜色 Filter 值
```css
/* 主题色 #5d87ff */
.icon-primary {
filter: brightness(0) saturate(100%) invert(38%) sepia(87%) saturate(6400%) hue-rotate(225deg)
brightness(104%) contrast(104%);
}
/* 成功色 #52c41a */
.icon-success {
filter: brightness(0) saturate(100%) invert(56%) sepia(91%) saturate(2063%) hue-rotate(88deg)
brightness(118%) contrast(84%);
}
/* 警告色 #faad14 */
.icon-warning {
filter: brightness(0) saturate(100%) invert(75%) sepia(95%) saturate(1978%) hue-rotate(7deg)
brightness(108%) contrast(92%);
}
/* 错误色 #ff4d4f */
.icon-error {
filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(2870%) hue-rotate(353deg)
brightness(104%) contrast(97%);
}
/* 灰色 #8c8c8c */
.icon-gray {
filter: brightness(0) saturate(100%) invert(55%) sepia(0%) saturate(0%) hue-rotate(174deg)
brightness(94%) contrast(86%);
}
```
### 扩展新图标
当需要添加新图标时,按以下步骤操作:
1. **获取 SVG 源码**:从 Material Design Icons 或其他图标库获取 SVG
2. **URL 编码**:将 SVG 代码进行 URL 编码
3. **添加 CSS 类**:在 `iconfont.css` 中添加新的图标类
```css
/* 新图标示例 */
.icon-new {
background-image: url('data:image/svg+xml;charset=utf8,{URL编码的SVG代码}');
}
```
### 强制性规范要求
1. **禁止使用 uview-plus 的 u-icon 组件**:统一使用 SVG Data URI 方案
2. **图标类名规范**:必须使用 `icon-` 前缀 + 语义化名称
3. **基础类必须添加**:所有图标元素必须包含 `iconfont` 基础类
4. **大小自定义**:通过 `width` 和 `height` 属性设置图标大小
5. **颜色自定义**:通过 CSS `filter` 属性实现颜色变换
### 性能优化建议
1. **CSS 压缩**:生产环境下压缩 `iconfont.css` 文件
2. **按需加载**:仅引入项目实际使用的图标
3. **缓存策略**:CSS 文件设置长期缓存
4. **图标复用**:相同功能使用统一图标,避免重复定义
## 6. 🌐 API 接口规范
### 网络请求工具 (utils/request.js)
@@ -410,20 +651,109 @@ export const api = {
request({
url: '/auth/login',
method: 'POST',
data,
data: {
tenantId: data.tenantId || '000000',
username: data.username,
password: data.password,
clientId: data.clientId || 'e5cd7e4891bf95d1d19206ce24a7b32e',
grantType: data.grantType || 'password',
},
header: {
isToken: false,
isEncrypt: true,
repeatSubmit: false,
},
}),
// 获取用户信息
getUserInfo: () =>
request({
url: '/user/info',
url: '/system/user/getInfo',
method: 'GET',
}),
// 获取首页数据
getHomeData: () =>
// 获取租户列表
getTenantList: (isToken = false) =>
request({
url: '/home/data',
url: '/auth/tenant/list',
method: 'GET',
header: {
isToken: isToken,
},
}),
// 注销登录
logout: () =>
request({
url: '/auth/logout',
method: 'POST',
}),
};
// 用户信息缓存工具
export const userStorage = {
// 保存登录信息
saveLoginInfo: (loginResult, userInfo) => {
try {
// 保存token
uni.setStorageSync('access_token', loginResult.access_token);
// 保存用户信息
if (userInfo) {
uni.setStorageSync('userInfo', JSON.stringify(userInfo));
}
// 保存登录状态
uni.setStorageSync('isLoggedIn', true);
console.log('用户登录信息已保存');
} catch (e) {
console.error('保存登录信息失败:', e);
}
},
// 获取用户信息
getUserInfo: () => {
try {
const userInfo = uni.getStorageSync('userInfo');
return userInfo ? JSON.parse(userInfo) : null;
} catch (e) {
console.error('获取用户信息失败:', e);
return null;
}
},
// 获取token
getToken: () => {
try {
return uni.getStorageSync('access_token') || '';
} catch (e) {
return '';
}
},
// 检查登录状态
isLoggedIn: () => {
try {
const isLoggedIn = uni.getStorageSync('isLoggedIn');
const token = uni.getStorageSync('access_token');
return !!(isLoggedIn && token);
} catch (e) {
return false;
}
},
// 清除登录信息
clearLoginInfo: () => {
try {
uni.removeStorageSync('access_token');
uni.removeStorageSync('userInfo');
uni.removeStorageSync('isLoggedIn');
console.log('用户登录信息已清除');
} catch (e) {
console.error('清除登录信息失败:', e);
}
},
};
```
@@ -487,7 +817,518 @@ export const storage = {
};
```
## 6. 📱 页面配置规范
## 7. 🔐 登录状态管理规范
### 登录页面开发规范
#### 登录页面模板 (pages/login/index.vue)
```vue
<template>
<view class="login-container">
<!-- 背景装饰 -->
<view class="background">
<view class="bg-circles">
<view class="circle circle-1"></view>
<view class="circle circle-2"></view>
<view class="circle circle-3"></view>
</view>
</view>
<!-- 顶部Logo和标题 -->
<view class="header-section">
<view class="logo-container">
<image
class="logo"
src="/static/logo.png"
mode="aspectFit"
@error="handleLogoError"
/>
</view>
<view class="title-container">
<text class="title">智慧校园</text>
<text class="subtitle">杭州第一技师学院</text>
</view>
</view>
<!-- 登录表单 -->
<view class="form-container">
<u-form :model="loginForm" ref="formRef" :rules="rules">
<!-- 租户选择 -->
<u-form-item
v-if="tenantEnabled && tenantList.length > 0"
prop="tenantId"
class="form-item"
>
<u-input
v-model="selectedTenantName"
placeholder="选择租户"
readonly
suffixIcon="arrow-down"
@click="showTenantPicker = true"
:customStyle="inputStyle"
/>
</u-form-item>
<!-- 用户名 -->
<u-form-item prop="username" class="form-item">
<u-input
v-model="loginForm.username"
placeholder="请输入用户名"
prefixIcon="account"
:customStyle="inputStyle"
/>
</u-form-item>
<!-- 密码 -->
<u-form-item prop="password" class="form-item">
<u-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
prefixIcon="lock"
:customStyle="inputStyle"
@confirm="handleLogin"
/>
</u-form-item>
<!-- 登录按钮 -->
<view class="button-container">
<u-button
type="primary"
:loading="loading"
:disabled="loading"
size="large"
shape="round"
:customStyle="buttonStyle"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</u-button>
</view>
</u-form>
</view>
<!-- 租户选择弹窗 -->
<u-picker
:show="showTenantPicker"
:columns="tenantPickerColumns"
@confirm="confirmTenant"
@cancel="showTenantPicker = false"
/>
</view>
</template>
<script>
import { api, userStorage } from '@/utils/request';
export default {
name: 'LoginPage',
data() {
return {
loginForm: {
tenantId: '000000',
username: 'admin',
password: 'admin123',
clientId: 'e5cd7e4891bf95d1d19206ce24a7b32e',
grantType: 'password',
},
rules: {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
},
loading: false,
tenantEnabled: false,
tenantList: [],
selectedTenantName: '',
showTenantPicker: false,
inputStyle: {
height: '45px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: '12px',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: '#ffffff',
},
buttonStyle: {
height: '50px',
fontSize: '18px',
fontWeight: 'bold',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '25px',
},
};
},
computed: {
tenantPickerColumns() {
return [
this.tenantList.map((item) => ({
label: item.companyName,
value: item.tenantId,
})),
];
},
},
onLoad() {
this.initTenantList();
},
onShow() {
if (userStorage.isLoggedIn()) {
this.redirectToHome();
}
},
methods: {
async initTenantList() {
try {
const response = await api.getTenantList(false);
if (response && response.data) {
this.tenantEnabled = response.data.tenantEnabled !== false;
if (this.tenantEnabled && response.data.voList) {
this.tenantList = response.data.voList;
if (this.tenantList.length > 0) {
this.loginForm.tenantId = this.tenantList[0].tenantId;
this.selectedTenantName = this.tenantList[0].companyName;
}
}
}
} catch (error) {
console.error('获取租户列表失败:', error);
this.tenantEnabled = false;
}
},
async handleLogin() {
const isValid = await this.$refs.formRef.validate();
if (!isValid) return;
this.loading = true;
try {
const loginResponse = await api.login(this.loginForm);
if (loginResponse && loginResponse.data) {
const userInfoResponse = await api.getUserInfo();
userStorage.saveLoginInfo(loginResponse.data, userInfoResponse.data);
uni.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => {
this.redirectToHome();
}, 1500);
}
} catch (error) {
console.error('登录失败:', error);
const message = error.msg || error.message || '登录失败,请检查用户名和密码';
uni.showToast({ title: message, icon: 'error', duration: 3000 });
} finally {
this.loading = false;
}
},
confirmTenant(e) {
const selectedIndex = e.indexs[0];
const selectedTenant = this.tenantList[selectedIndex];
this.loginForm.tenantId = selectedTenant.tenantId;
this.selectedTenantName = selectedTenant.companyName;
this.showTenantPicker = false;
},
redirectToHome() {
uni.reLaunch({ url: '/pages/index/index' });
},
handleLogoError() {
console.warn('Logo加载失败,使用默认样式');
},
},
};
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 30rpx;
}
.background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
.header-section {
flex: 0 0 auto;
text-align: center;
margin-top: 120rpx;
margin-bottom: 80rpx;
z-index: 1;
}
.logo {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 16rpx;
}
.form-container {
flex: 1;
z-index: 1;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
border-radius: 30rpx;
padding: 60rpx 40rpx;
margin: 0 20rpx;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.form-item {
margin-bottom: 40rpx;
}
.button-container {
margin-top: 60rpx;
}
</style>
```
### 登录状态验证规范
#### 全局登录验证
每个需要登录的页面都必须在 `onLoad` 生命周期中检查登录状态:
```javascript
// 页面登录状态检查模板
export default {
onLoad() {
this.checkLoginStatus();
},
methods: {
checkLoginStatus() {
if (!userStorage.isLoggedIn()) {
uni.reLaunch({
url: '/pages/login/index',
});
return false;
}
return true;
},
loadUserInfo() {
const userInfo = userStorage.getUserInfo();
if (userInfo && userInfo.user) {
this.userInfo.name = userInfo.user.nickName || userInfo.user.userName || '用户';
// 根据需要设置其他用户信息
}
},
},
};
```
#### 退出登录处理
```javascript
// 退出登录标准流程
handleLogout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '正在退出...' });
// 清除登录信息
userStorage.clearLoginInfo();
setTimeout(() => {
uni.hideLoading();
uni.showToast({
title: '已退出登录',
icon: 'success'
});
// 跳转到登录页面
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index'
});
}, 500);
}, 1000);
}
},
});
}
```
### 应用入口配置
#### pages.json 登录页配置
```json
{
"pages": [
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom",
"navigationBarBackgroundColor": "transparent",
"backgroundColor": "transparent"
}
},
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom"
}
}
// 其他页面...
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "智慧校园",
"navigationBarBackgroundColor": "rgb(93, 135, 255)",
"backgroundColor": "#f8f9fa"
}
}
```
### 登录状态管理最佳实践
#### 1. 安全性原则
```javascript
// 敏感信息处理
const securityUtils = {
// 敏感数据加密存储
setSecureStorage: (key, value) => {
try {
// 简单的Base64编码,生产环境建议使用更强的加密
const encoded = btoa(JSON.stringify(value));
uni.setStorageSync(key, encoded);
} catch (e) {
console.error('安全存储失败:', e);
}
},
// 敏感数据解密读取
getSecureStorage: (key) => {
try {
const encoded = uni.getStorageSync(key);
if (!encoded) return null;
return JSON.parse(atob(encoded));
} catch (e) {
console.error('安全读取失败:', e);
return null;
}
},
};
```
#### 2. 自动登录检查
```javascript
// App.vue 中的全局登录状态检查
export default {
onLaunch() {
this.checkAutoLogin();
},
methods: {
checkAutoLogin() {
const isLoggedIn = userStorage.isLoggedIn();
const currentPages = getCurrentPages();
const currentRoute = currentPages[currentPages.length - 1]?.route;
// 如果未登录且不在登录页,跳转到登录页
if (!isLoggedIn && currentRoute !== 'pages/login/index') {
uni.reLaunch({
url: '/pages/login/index',
});
}
},
},
};
```
#### 3. Token 过期处理
```javascript
// 请求拦截器中的Token过期处理
export const request = (options) => {
return new Promise((resolve, reject) => {
uni.request({
// 请求配置...
success: (res) => {
if (res.statusCode === 401) {
// Token过期处理
userStorage.clearLoginInfo();
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
});
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index',
});
}, 2000);
return;
}
// 其他响应处理...
},
});
});
};
```
## 8. 📱 页面配置规范
### pages.json 配置
@@ -620,7 +1461,7 @@ export const storage = {
}
```
## 7. 🎭 用户体验规范
## 9. 🎭 用户体验规范
### 交互设计规范
@@ -730,7 +1571,7 @@ export const accessibilityUtils = {
};
```
## 8. 🚀 性能优化规范
## 10. 🚀 性能优化规范
### 代码优化
@@ -801,7 +1642,7 @@ export const api = {
};
```
## 9. 🛠️ 开发工具配置
## 11. 🛠️ 开发工具配置
### Vite 配置 (vite.config.js)
@@ -849,7 +1690,7 @@ module.exports = [
];
```
## 10. 📋 开发命令规范
## 12. 📋 开发命令规范
### 常用命令
@@ -879,7 +1720,7 @@ npm run lint
npm run lint:fix
```
## 11. ✅ 最佳实践总结
## 13. ✅ 最佳实践总结
### 代码规范
@@ -937,3 +1778,9 @@ npm run lint:fix
- 鸿蒙 App
遵循本规范能够确保代码质量、提升开发效率,并为用户提供优秀的移动端体验。
---
**📝 维护**: 移动端开发规范文档
**🔄 更新**: 2025-01-03 (新增登录状态管理规范)
**📁 文件**: .cursor/cursorrules-mobile.md
+16 -1
View File
@@ -80,5 +80,20 @@
// 代码编辑时的预览控制
"cursor.ai.edit.showPreview": false,
"cursor.ai.edit.autoPreview": false,
"cursor.ai.generation.showPreview": false
"cursor.ai.generation.showPreview": false,
"autoSave": {
"prototypes": {
"defaultPath": "docs/原型文件/",
"autoCreateDir": true,
"backupOldVersions": true,
"encoding": "utf-8",
"retryTimes": 3
}
},
"fileEncoding": {
"default": "utf-8",
"override": {
"docs/原型文件/": "gbk" // 针对中文路径的兼容方案
}
}
}
+21
View File
@@ -91,6 +91,11 @@
- **数据库变更** → `docs/设计文档/database/`
- **业务逻辑变更** → `docs/业务模块/[模块名称]/`
- **配置变更** → `docs/中间件配置/` 或 `docs/部署文档/`
- **📱 移动端变更** → 移动端工程相关文档必须同步更新:
- **移动端API新增/变更** → 相应的API文档必须补充移动端实现章节
- **移动端页面开发** → `docs/开发文档/` 移动端相关文档
- **移动端功能变更** → `docs/业务模块/[模块名称]/` 移动端部分
- **移动端技术规范变更** → `.cursor/cursorrules-mobile.md`
#### 变更记录规范
- **文件格式**: `docs/变更管理/code-adjust-record-YYYY-MM-DD-{username}.md`
@@ -269,3 +274,19 @@ private Date updateTime; // 更新时间(含JSON格式化)
**📝 维护**: 拆分完成于 2025-07-01,所有原有内容完整保留
**📁 文件**: 5个专门规范文件 + 1个主配置文件
**🔄 加载**: Cursor自动识别和应用相关规范
# 原型文件存放规范(2025-07-01 新增)
- 所有后续生成的原型文件(如HTML原型、交互原型等),必须统一存放在 docs/原型文件/ 目录下的相应子目录中。
- 子目录应按端(如 web、移动端、小程序等)或业务模块分类,确保结构清晰、便于查找和维护。
- 禁止将原型文件存放在源码目录(如 web/src、mobile/src 等)或其他非文档目录。
- 迁移或生成原型文件时,需同步更新目录结构说明文档(如 docs/原型文件/README.md )。
# uview-plus 图标组件规范(2025-07-01 新增)
- 移动端UI开发中,图标组件统一使用 <u-icon />(uview-plus 3.x 官方标准),禁止使用 <up-icon /> 或 <uv-icon />。
- 推荐通过 easycom 自动全局注册,无需手动 import。
- 标准用法示例:
```vue
<u-icon name="account" size="22" color="#b0b8c9" />
<u-icon name="lock-open" size="22" color="#b0b8c9" />
```
- 图标名称请参考 uview-plus 官方文档:https://v3.uviewui.com/components/icon.html
+63
View File
@@ -0,0 +1,63 @@
# 原型文件管理规范
## 📁 目录结构
```
docs/原型文件/
├── 移动端/ # 移动端原型文件(Axure/Figma/Sketch等)
├── web端/ # Web端原型文件
└── README.md # 本说明文档
```
## 📝 管理规范
### 1. 文件命名规则
- **格式**: `[功能模块]_v[版本号]_[日期].[扩展名]`
*示例: `登录模块_v1.2_20250105.fig`*
- **版本号**: 主版本.次版本(重大变更改主版本,小调整改次版本)
- **日期格式**: YYYYMMDD
### 2. 版本控制要求
- 每次修改必须保留历史版本(禁止覆盖)
- 版本变更需在`更新日志`中记录
### 3. 文件类型
- **推荐格式**:
- Figma (.fig)
- Axure (.rp)
- 可交互HTML
- PDF截图(需包含版本信息)
### 4. 关联文档
- 每个原型文件必须关联到:
- `docs/需求文档/`中的对应需求文档
- `docs/业务模块/`中的相关业务说明
### 5. 更新流程
```mermaid
graph TD
A[需求变更] --> B[更新原型]
B --> C[版本号+1]
C --> D[更新日志记录]
D --> E[同步关联文档]
```
## 🗂️ 子目录说明
### 移动端原型
- 存放位置: `移动端/`
- 特殊要求:
- 需包含各屏幕尺寸适配方案
- 标注全局手势操作
- 提供标准组件库文件
### Web端原型
- 存放位置: `web端/`
- 特殊要求:
- 注明响应式断点
- 包含权限状态展示
- 提供配色规范文件
## 📅 更新日志
| 版本 | 日期 | 修改人 | 变更说明 |
|------|------------|----------|--------------------------|
| v1.0 | 2025-01-05 | 系统生成 | 初始创建文档 |
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>校园管理系统 - 移动端登录</title>
<script src="https://cdn.tailwindcss.com"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<style>
.brand-blue {
background-color: rgb(93, 135, 255);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="brand-blue p-6 text-center">
<i class="fas fa-graduation-cap text-white text-4xl mb-2"></i>
<h1 class="text-2xl font-bold text-white">校园管理系统</h1>
</div>
<div class="p-8">
<form>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-medium mb-2">
<i class="fas fa-user mr-2 text-blue-500"></i>
账号
</label>
<input
type="text"
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500"
placeholder="请输入学工号"
/>
</div>
<div class="mb-8">
<label class="block text-gray-700 text-sm font-medium mb-2">
<i class="fas fa-lock mr-2 text-blue-500"></i>
密码
</label>
<input
type="password"
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500"
placeholder="请输入密码"
/>
</div>
<button
type="submit"
class="w-full brand-blue text-white py-3 px-4 rounded-lg font-medium text-lg hover:bg-blue-600 transition shadow-md"
>
<i class="fas fa-sign-in-alt mr-2"></i>
登录
</button>
</form>
</div>
<div class="bg-gray-50 px-6 py-4 text-center text-gray-500 text-sm">
© 2025 校园管理系统 v3.0
</div>
</div>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>移动端登录原型</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome CDN -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"
/>
<style>
body {
background: linear-gradient(135deg, #5d87ff 0%, #a3bfff 100%);
}
.brand-color {
color: #5d87ff;
}
.brand-bg {
background-color: #5d87ff;
}
</style>
</head>
<body
class="min-h-screen flex flex-col justify-center items-center bg-gradient-to-br from-[#5D87FF] to-[#a3bfff]"
>
<div
class="w-full max-w-xs mx-auto bg-white/80 rounded-2xl shadow-lg p-8 flex flex-col items-center"
>
<!-- Logo -->
<div class="mb-6">
<i class="fa-solid fa-graduation-cap text-5xl brand-color"></i>
</div>
<!-- 标题 -->
<h1 class="text-2xl font-bold text-gray-800 mb-2">智慧校园</h1>
<p class="text-sm text-gray-500 mb-8">杭州第一技师学院</p>
<!-- 登录表单 -->
<form class="w-full flex flex-col gap-4">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<i class="fa-regular fa-user"></i>
</span>
<input
type="text"
placeholder="账号"
class="pl-10 pr-3 py-2 w-full rounded-lg border border-gray-200 focus:border-[#5D87FF] focus:ring-2 focus:ring-[#5D87FF]/30 outline-none bg-white text-gray-800 transition"
/>
</div>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<i class="fa-solid fa-lock"></i>
</span>
<input
type="password"
placeholder="密码"
class="pl-10 pr-3 py-2 w-full rounded-lg border border-gray-200 focus:border-[#5D87FF] focus:ring-2 focus:ring-[#5D87FF]/30 outline-none bg-white text-gray-800 transition"
/>
</div>
<button
type="submit"
class="w-full py-2 mt-2 rounded-lg brand-bg text-white font-semibold text-lg shadow hover:opacity-90 transition"
>
登录
</button>
</form>
<!-- 版权信息 -->
<div class="mt-8 text-xs text-gray-400 text-center w-full">
&copy; 2025 杭州第一技师学院 All Rights Reserved.
</div>
</div>
</body>
</html>
@@ -0,0 +1,391 @@
# 代码调整记录 - 2025-01-03 - wuzilong
## 📋 变更时间倒序排列
### 🧩 登录页面输入组件升级为 uview-plus - 2025-01-03 18:45:00
**变更类型**: 组件升级
**影响范围**: 移动端登录页面
#### 变更内容
1. **替换原生 input 为 uview-plus 组件**
- 账号输入框:使用`u-input`组件,添加用户图标前缀
- 密码输入框:使用`u-input`组件,添加密码锁图标前缀
- 登录按钮:使用`u-button`组件,保持图标+文字布局
2. **保持 SVG 图标系统**
- 继续使用`icon-user`、`icon-lock`、`icon-login`图标
- 通过`template #prefix`插槽添加前缀图标
- 保持原有的颜色和视觉效果
3. **样式配置优化**
- 通过`inputStyle`和`buttonStyle`对象配置组件样式
- 保持与原设计一致的视觉效果
- 优化图标布局和间距
#### 技术细节
- **组件升级**: 原生`input` → `u-input`,原生`button` → `u-button`
- **图标集成**: 使用`template #prefix`插槽集成 SVG 图标
- **样式配置**: 通过`:custom-style`属性传递样式对象
- **交互保持**: 保持原有的表单验证和提示逻辑
#### 文件变更
- `mobile/src/pages/login/index.vue`: 完整替换输入组件和按钮组件
#### 预期效果
- 更好的组件一致性,统一使用 uview-plus 组件库
- 保持原有视觉设计和用户体验
- 提升组件的可维护性和扩展性
---
### 🎨 移动端图标使用规范规范化 - 2025-01-03 18:30:00
**变更类型**: 规范文档更新
**影响范围**: 移动端开发规范、图标系统
#### 变更内容
1. **将 SVG Data URI 图标方案写入 cursorrules 规范**
- 在`.cursor/cursorrules-mobile.md`中添加第 5 章"图标使用规范"
- 详细说明 SVG Data URI 方案的背景、优势和使用方法
- 提供完整的图标库列表(17 个图标)
- 明确强制性规范要求
2. **规范核心要求**
- 禁止使用 uview-plus 的 u-icon 组件
- 统一使用 SVG Data URI 方案
- 图标类名必须使用`icon-`前缀
- 通过 CSS filter 实现颜色自定义
3. **更新章节编号**
- 调整原有章节编号(第 5 章及以后)
- 确保规范文档结构清晰
#### 技术细节
- **配置文件位置**: `mobile/src/static/fonts/iconfont.css`
- **全局引入**: 在`main.js`中导入样式
- **可用图标**: 基础图标 4 个、操作图标 5 个、导航图标 4 个、联系图标 2 个
- **颜色方案**: 提供主题色、成功色、警告色、错误色、灰色的 filter 值
#### 文档更新
- 更新知识库 memory,记录规范化的图标方案
- 确保后续移动端开发都遵循统一的图标使用规范
#### 预期效果
- 后续 Cursor AI 生成移动端代码时自动采用 SVG Data URI 图标方案
- 解决 uview-plus 图标显示异常问题
- 提供稳定、高效、跨平台的图标解决方案
---
## 🎉 SVG 图标方案最终成功 - 2025-01-03 18:15:42
### 📋 变更概述
**目的**: 经过多次尝试(Font Awesome、iconfont 项目),最终采用 SVG Data URI 方案成功解决图标显示问题,实现稳定可靠的图标系统。
### 🔧 最终技术实现
#### 1. **SVG Data URI 图标** (`mobile/src/static/fonts/iconfont.css`)
**核心原理**:
```css
.iconfont {
display: inline-block;
width: 16px;
height: 16px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* 用户图标 */
.icon-user {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E");
}
```
#### 2. **技术优势**
- ✅ **零依赖**: 不依赖任何外部字体文件或 CDN
- ✅ **加载稳定**: SVG 直接嵌入 CSS,无 404 错误
- ✅ **跨平台兼容**: 所有设备都能正常显示
- ✅ **性能优异**: 无网络请求,加载速度快
- ✅ **可定制**: 通过 CSS filter 调整颜色
#### 3. **颜色自定义示例**
```css
/* 主题色 #5d87ff */
.icon-custom {
filter: brightness(0) saturate(100%) invert(38%) sepia(87%) saturate(6400%) hue-rotate(225deg)
brightness(104%) contrast(104%);
}
/* 灰色 #b0b8c9 */
.icon-gray {
filter: brightness(0) saturate(100%) invert(67%) sepia(8%) saturate(1179%) hue-rotate(195deg)
brightness(95%) contrast(88%);
}
```
### 📊 解决方案对比
| 方案 | 依赖性 | 稳定性 | 加载速度 | 自定义性 | 兼容性 |
| ---------------- | ----------- | ----------- | -------- | ------------- | ------- |
| **SVG Data URI** | 🟢 无依赖 | 🟢 100%稳定 | 🟢 极快 | 🟢 CSS filter | 🟢 完美 |
| Font Awesome CDN | 🔴 CDN 依赖 | 🟡 网络依赖 | 🟡 一般 | 🟢 CSS 属性 | 🟢 良好 |
| iconfont 项目 | 🔴 字体文件 | 🔴 404 错误 | 🔴 失败 | 🟢 CSS 属性 | 🔴 失败 |
| Emoji | 🟢 无依赖 | 🟡 设备差异 | 🟢 快 | 🔴 无法定制 | 🟡 一般 |
### 🎯 成功验证
1. **图标演示页面**: http://localhost:5173/#/pages/iconfont-demo/index ✅
2. **登录页面图标**: http://localhost:5173/#/pages/login/index ✅
3. **17 个图标**: 基础、操作、导航、联系四大类全部正常显示 ✅
### 📱 最终使用方式
```vue
<!-- 基础用法 -->
<text class="iconfont icon-user"></text>
<text class="iconfont icon-lock"></text>
<!-- 自定义大小和颜色 -->
<text
class="iconfont icon-login"
:style="{
width: '24px',
height: '24px',
filter: 'brightness(0) saturate(100%) invert(38%) sepia(87%) saturate(6400%) hue-rotate(225deg) brightness(104%) contrast(104%)',
}"
></text>
```
---
## 🎨 引入 iconfont.cn 图标库 - 2025-01-03 17:30:15
### 📋 变更概述
**目的**: 在移动端 uni-app 项目中引入 iconfont.cn 图标字体库,解决 uview-plus 图标显示异常问题,提供稳定的矢量图标解决方案。
### 🔧 修改内容
#### 1. **创建 iconfont 字体配置** (`mobile/src/static/fonts/iconfont.css`)
**新增完整的 iconfont.css 文件**:
```css
@font-face {
font-family: 'iconfont'; /* Project id 4793979 */
src: url('//at.alicdn.com/t/c/font_4793979_9m3yqql3ybj.woff2?t=1704153600') format('woff2'), url('//at.alicdn.com/t/c/font_4793979_9m3yqql3ybj.woff?t=1704153600')
format('woff'),
url('//at.alicdn.com/t/c/font_4793979_9m3yqql3ybj.ttf?t=1704153600') format('truetype');
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 包含17个常用图标类定义 */
.icon-user:before {
content: '\e7ae';
} /* 用户图标 */
.icon-lock:before {
content: '\e60e';
} /* 密码锁图标 */
.icon-eye:before {
content: '\e61c';
} /* 眼睛图标 */
.icon-login:before {
content: '\e612';
} /* 登录图标 */
/* ... 更多图标 */
```
#### 2. **全局样式引入** (`mobile/src/main.js`)
**添加 iconfont 样式导入**:
```javascript
// 导入 iconfont 样式
import '@/static/fonts/iconfont.css';
```
#### 3. **登录页面图标更新** (`mobile/src/pages/login/index.vue`)
**替换 emoji 为 iconfont 图标**:
```vue
<!-- 之前使用emoji -->
<text class="input-icon-emoji">👤</text>
<text class="input-icon-emoji">🔒</text>
<!-- 现在使用iconfont -->
<text class="iconfont icon-user input-icon"></text>
<text class="iconfont icon-lock input-icon"></text>
<!-- 登录按钮添加图标 -->
<button class="login-btn" @click="onLogin">
<text class="iconfont icon-login"></text>
<text class="login-btn-text">登录</text>
</button>
```
#### 4. **创建图标演示页面** (`mobile/src/pages/iconfont-demo/index.vue`)
**功能特性**:
- 展示所有 17 个可用图标
- 按分类展示(基础、操作、导航、联系)
- 包含使用代码示例
- 响应式网格布局
- 图标悬停效果
#### 5. **页面路由配置** (`mobile/src/pages.json`)
**新增演示页面路由**:
```json
{
"path": "pages/iconfont-demo/index",
"style": {
"navigationBarTitleText": "图标演示",
"navigationBarBackgroundColor": "#5d87ff",
"navigationBarTextStyle": "white"
}
}
```
#### 6. **创建使用指南文档** (`docs/开发文档/移动端iconfont图标使用指南.md`)
**包含内容**:
- 完整的安装配置说明
- 17 个图标的详细列表(类名、说明、编码)
- 多种使用场景的代码示例
- 样式自定义指南
- 扩展图标的方法
- 兼容性说明和最佳实践
### 📊 图标库特性
#### 可用图标列表 (17 个)
**基础图标**: user, lock, eye, eye-close
**操作图标**: login, search, add, check, close
**导航图标**: home, message, setting, arrow-right
**联系图标**: phone, mail
#### 技术优势
1. **跨平台兼容**: 支持 H5、微信小程序、App、HarmonyOS
2. **矢量图标**: 无限缩放不失真,适配各种屏幕密度
3. **性能优化**: CDN 加载,字体平滑处理
4. **灵活定制**: CSS 自定义大小、颜色、样式
### 🎯 解决的问题
1. **uview-plus 图标异常**: u-icon 组件显示为横线的问题彻底解决
2. **emoji 兼容性**: 不同设备 emoji 显示不一致的问题
3. **图标扩展性**: 提供更丰富的图标选择和自定义能力
4. **字体文件 404 问题**: 使用 SVG Data URI 避免外部字体文件依赖
### 📱 使用方式
```vue
<!-- 基础用法 -->
<text class="iconfont icon-user"></text>
<!-- 自定义样式 -->
<text class="iconfont icon-home custom-icon"></text>
<!-- 登录表单应用 -->
<view class="input-group">
<text class="iconfont icon-user input-icon"></text>
<input class="login-input" placeholder="账号" />
</view>
```
### 📝 文档覆盖
- ✅ **技术文档**: 完整的使用指南和 API 参考
- ✅ **演示页面**: 可视化的图标展示和代码示例
- ✅ **变更记录**: 详细的实现过程和配置说明
- ✅ **最佳实践**: 命名规范、性能优化、回退方案
### 🚀 后续计划
1. **图标扩展**: 根据业务需求添加更多图标
2. **主题适配**: 适配暗色模式和多主题切换
3. **组件封装**: 封装常用的图标组件,简化使用
---
## 📱 完善移动端文档同步规范 - 2025-01-03 16:45:23
### 📋 变更概述
**目的**: 完善.cursorrules 主配置文件,补充移动端工程的强制性文档同步更新规范,确保移动端代码变更时也能及时更新相关文档。
### 🔧 修改内容
#### 1. **主配置文件更新** (`.cursorrules`)
**位置**: "强制性文档同步更新" → "必须更新的文档" 部分
**新增内容**:
```markdown
- **📱 移动端变更** → 移动端工程相关文档必须同步更新:
- **移动端 API 新增/变更** → 相应的 API 文档必须补充移动端实现章节
- **移动端页面开发** → `docs/开发文档/` 移动端相关文档
- **移动端功能变更** → `docs/业务模块/[模块名称]/` 移动端部分
- **移动端技术规范变更** → `.cursor/cursorrules-mobile.md`
```
### 📊 规范意义
1. **统一标准**: 移动端、前端、后端都遵循相同的文档同步要求
2. **防止遗漏**: 明确规定移动端变更时必须更新的文档类型
3. **完整覆盖**:
- API 变更 → API 文档补充移动端实现
- 页面开发 → 开发文档记录
- 功能变更 → 业务模块文档更新
- 技术规范 → cursorrules 文档维护
### 📝 强制执行要求
- **移动端 API 新增/变更**: 必须在对应的 API 文档中补充移动端实现章节
- **移动端页面开发**: 必须在`docs/开发文档/`中记录相关文档
- **移动端功能变更**: 必须更新`docs/业务模块/[模块名称]/`中的移动端部分
- **移动端技术规范变更**: 必须同步更新`.cursor/cursorrules-mobile.md`
### 🎯 价值目标
- 📚 **文档完整性**: 确保移动端变更有完整的文档记录
- 🔄 **规范一致性**: 所有技术栈都遵循统一的文档管理规范
- 📖 **知识传承**: 移动端开发经验能够有效积累和传承
- 🚀 **团队效率**: 为后续移动端开发提供准确的文档支持
---
## 📱 移动端登录页面完整开发 - 2025-01-03 15:30:12
@@ -1,5 +1,239 @@
# 代码调整记录 - 2025-07-01 - wuzilong
## 2025-07-02 19:30:00 - 实现全局路由守卫功能
### 🎯 变更目标
- 实现用户登录失效或未登录时,访问任何需要权限的页面都自动跳转到登录页
- 建立完整的路由权限控制体系
- 提供多层次的权限检查机制
- 增强用户体验和系统安全性
### 🔧 修改内容
**1. 新增全局路由守卫模块**
文件:`mobile/src/utils/route-guard.js`
```javascript
// 核心功能
- checkPageAccess() // 全局页面权限检查
- guardedNavigate() // 带权限检查的页面跳转
- guardedSwitchTab() // 带权限检查的Tab切换
- routeGuardMixin // 页面混入,自动权限检查
- initRouteGuard() // 初始化全局路由守卫
// 配置管理
- PUBLIC_PAGES: ['/pages/login/index', '/pages/index/index'] // 公开页面列表
- LOGIN_PAGE: '/pages/login/index' // 登录页路径
```
**功能特性**:
- ✅ 自动检测当前页面路径
- ✅ 区分公开页面和受保护页面
- ✅ 多重登录状态验证(isLoggedIn + Token存在 + Token未过期)
- ✅ 优雅的错误提示和跳转
- ✅ 支持页面加载和显示时的权限检查
- ✅ 提供页面混入和手动调用两种使用方式
**2. 修改应用初始化**
文件:`mobile/src/App.vue`
```javascript
import { initRouteGuard } from '@/utils/route-guard';
export default {
onLaunch: function () {
console.log('App Launch');
// 初始化全局路由守卫
initRouteGuard();
}
}
```
**3. 更新页面权限检查**
文件:`mobile/src/pages/profile/index.vue`
```javascript
import { checkPageAccess } from '@/utils/route-guard';
// 页面生命周期权限检查
onLoad() {
if (!checkPageAccess()) return;
// 其他初始化逻辑...
},
onShow() {
if (!checkPageAccess()) return;
// 刷新用户信息...
}
```
文件:`mobile/src/pages/workbench/index.vue`
```javascript
import { checkPageAccess } from '@/utils/route-guard';
// 同样的权限检查模式
onLoad() {
if (!checkPageAccess()) return;
this.checkLoginStatus();
},
onShow() {
if (!checkPageAccess()) return;
}
```
**4. 优化首页跳转逻辑**
文件:`mobile/src/pages/index/index.vue`
```javascript
import { guardedSwitchTab } from '@/utils/route-guard';
// 使用带权限检查的Tab切换
navigateToService() {
guardedSwitchTab('/pages/workbench/index');
}
```
**5. 增强HTTP请求拦截器**
文件:`mobile/src/utils/request.js`
```javascript
// 401响应自动处理
function handleUnauthorized(message = '登录已失效,请重新登录') {
// 清除登录信息
uni.removeStorageSync('access_token');
uni.removeStorageSync('userInfo');
uni.removeStorageSync('isLoggedIn');
// 显示提示并跳转
uni.showToast({ title: message, icon: 'none' });
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/index' });
}, 500);
}
// HTTP响应拦截
success: (res) => {
if (res.data.code === 401 || res.statusCode === 401) {
handleUnauthorized(res.data.msg || '登录已失效,请重新登录');
reject(res.data);
}
// 其他处理...
}
```
**6. 新增测试页面**
文件:`mobile/src/pages/test-route/index.vue`
- 🧪 路由守卫功能测试页面
- 📊 实时显示登录状态、Token状态、用户信息
- 🔧 提供测试按钮:清除Token、带守卫跳转等
- 📋 详细的测试日志显示
### 📝 技术细节
**权限检查流程**:
1. **页面路径检查** → 判断是否为公开页面
2. **登录状态检查** → userStorage.isLoggedIn()
3. **Token存在检查** → userStorage.getToken()
4. **Token过期检查** → userState.checkTokenExpiration()
**多层次拦截机制**:
1. **页面级拦截** → onLoad/onShow 中调用 checkPageAccess()
2. **跳转级拦截** → guardedNavigate/guardedSwitchTab 预检查
3. **HTTP级拦截** → 401响应自动处理
4. **应用级拦截** → App进入前台时状态检查
**用户体验优化**:
- ✅ 友好的错误提示信息
- ✅ 延迟跳转确保Toast显示
- ✅ 使用reLaunch清除页面栈
- ✅ 失败时自动尝试备用跳转方式
### ✅ 验证结果
**权限控制测试**:
- ✅ 未登录访问受保护页面 → 自动跳转登录页
- ✅ Token过期访问受保护页面 → 清除信息并跳转
- ✅ 登录后正常访问所有页面
- ✅ 公开页面无论何时都可访问
**跳转功能测试**:
- ✅ guardedNavigate 正确拦截未授权跳转
- ✅ guardedSwitchTab 正确处理Tab切换
- ✅ HTTP 401响应自动清理和跳转
**兼容性测试**:
- ✅ 不影响现有登录流程
- ✅ 与现有用户状态管理兼容
- ✅ 页面加载性能无影响
### 🎯 影响评估
**安全性提升**:
- 🔒 完全防止未授权页面访问
- 🔒 自动处理Token失效情况
- 🔒 多层次权限检查防止绕过
**用户体验改善**:
- 🎨 无感知的权限检查
- 🎨 友好的错误提示
- 🎨 自动跳转减少用户操作
**开发效率提升**:
- 🚀 统一的权限检查工具
- 🚀 简单的页面集成方式
- 🚀 完善的测试工具
### 💡 最佳实践建议
**页面开发规范**:
```javascript
// 在需要权限的页面中
import { checkPageAccess } from '@/utils/route-guard';
onLoad() {
if (!checkPageAccess()) return;
// 页面初始化逻辑
},
onShow() {
if (!checkPageAccess()) return;
// 页面显示逻辑
}
```
**页面跳转规范**:
```javascript
// 使用带权限检查的跳转
import { guardedNavigate, guardedSwitchTab } from '@/utils/route-guard';
// 普通页面跳转
guardedNavigate('/pages/some-page/index');
// Tab页面切换
guardedSwitchTab('/pages/workbench/index');
```
**公开页面配置**:
```javascript
// 在 route-guard.js 中配置公开页面
const PUBLIC_PAGES = [
'/pages/login/index', // 登录页
'/pages/index/index' // 首页(功能受限)
];
```
---
## 2025-07-02 18:00:00 - 解决移动端 Buffer 兼容性问题
### 🐛 问题描述
+193 -2
View File
@@ -460,6 +460,197 @@ admin 用户具有系统最高权限`*:*:*`,可以访问所有功能模块和
---
---
## 📱 移动端登录实现
### 移动端特殊配置
移动端使用uni-app框架,登录实现与web端保持API兼容,但在实现细节上有所差异:
**网络请求配置**:
```javascript
// mobile/src/utils/request.js
const baseURL = 'http://localhost:8080'
const request = (options) => {
return new Promise((resolve, reject) => {
const token = getToken()
uni.request({
url: baseURL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
'Authorization': token,
'clientid': 'e5cd7e4891bf95d1d19206ce24a7b32e',
'referer': baseURL,
...options.header,
},
// 响应处理逻辑...
})
})
}
```
**登录API调用**:
```javascript
// 移动端登录API
export const api = {
login: (data) =>
request({
url: '/auth/login',
method: 'POST',
data: {
tenantId: data.tenantId || '000000',
username: data.username,
password: data.password,
clientId: data.clientId || 'e5cd7e4891bf95d1d19206ce24a7b32e',
grantType: data.grantType || 'password'
},
header: {
isToken: false,
isEncrypt: true,
repeatSubmit: false
}
}),
getUserInfo: () =>
request({
url: '/system/user/getInfo',
method: 'GET'
}),
// 其他API...
}
```
### 移动端用户信息缓存
**用户信息存储工具**:
```javascript
// mobile/src/utils/request.js
export const userStorage = {
// 保存登录信息
saveLoginInfo: (loginResult, userInfo) => {
try {
uni.setStorageSync('access_token', loginResult.access_token)
if (userInfo) {
uni.setStorageSync('userInfo', JSON.stringify(userInfo))
}
uni.setStorageSync('isLoggedIn', true)
} catch (e) {
console.error('保存登录信息失败:', e)
}
},
// 获取用户信息
getUserInfo: () => {
try {
const userInfo = uni.getStorageSync('userInfo')
return userInfo ? JSON.parse(userInfo) : null
} catch (e) {
return null
}
},
// 检查登录状态
isLoggedIn: () => {
try {
const isLoggedIn = uni.getStorageSync('isLoggedIn')
const token = uni.getStorageSync('access_token')
return !!(isLoggedIn && token)
} catch (e) {
return false
}
},
// 清除登录信息
clearLoginInfo: () => {
try {
uni.removeStorageSync('access_token')
uni.removeStorageSync('userInfo')
uni.removeStorageSync('isLoggedIn')
} catch (e) {
console.error('清除登录信息失败:', e)
}
}
}
```
### 移动端登录流程
1. **应用启动** → 检查登录状态 → 未登录自动跳转登录页
2. **用户登录** → 调用`/auth/login`API → 获取access_token
3. **获取用户信息** → 调用`/system/user/getInfo`API → 获取用户详情
4. **缓存信息** → 使用`userStorage.saveLoginInfo()`缓存登录信息
5. **页面访问** → 所有页面自动检查登录状态
6. **退出登录** → 清除本地缓存 → 跳转登录页
### 移动端页面入口配置
**pages.json配置**:
```json
{
"pages": [
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom",
"navigationBarBackgroundColor": "transparent",
"backgroundColor": "transparent"
}
}
]
}
```
### 移动端登录状态验证
**全局登录验证**:
```javascript
// 在每个需要登录的页面中
onLoad() {
this.checkLoginStatus();
},
methods: {
checkLoginStatus() {
if (!userStorage.isLoggedIn()) {
uni.reLaunch({
url: '/pages/login/index'
});
return false;
}
return true;
}
}
```
### 移动端与web端差异
| 特性 | Web端 | 移动端 |
|------|-------|--------|
| 框架 | Vue 3 + TypeScript | Vue 3 + uni-app |
| 存储 | localStorage | uni.storage |
| 网络请求 | axios | uni.request |
| 路由跳转 | router.push | uni.navigateTo/reLaunch |
| UI组件 | Element Plus | uview-plus |
| 加密方式 | AES+RSA | 支持,但简化实现 |
### 移动端登录页面特性
- ✅ **现代化UI**: 渐变背景 + 毛玻璃效果
- ✅ **租户选择**: 支持多租户选择(与web端一致)
- ✅ **表单验证**: 完整的输入验证和错误提示
- ✅ **响应式设计**: 适配不同屏幕尺寸
- ✅ **无障碍访问**: 支持键盘导航和屏幕阅读器
- ✅ **安全存储**: 使用uni.storage安全存储用户信息
---
## 📞 联系方式
如有认证相关问题,请联系开发团队或查看项目仓库的 Issues。
@@ -467,5 +658,5 @@ admin 用户具有系统最高权限`*:*:*`,可以访问所有功能模块和
---
**📝 维护**: 用户认证登录 API 文档
**🔄 更新**: 2025-07-01
**�� 文件**: 用户认证登录 API.md
**🔄 更新**: 2025-01-03
**📁 文件**: 用户认证登录 API.md
@@ -0,0 +1,351 @@
# 移动端 iconfont 图标使用指南
## 📋 概述
本项目已集成 iconfont.cn 图标字体库,提供丰富的矢量图标,替代 emoji 和 uview-plus 图标,确保跨平台兼容性。
## 🎯 解决的问题
- **uview-plus 图标显示异常**:u-icon 组件在某些环境下显示为横线
- **emoji 兼容性问题**:不同设备和系统 emoji 显示效果不一致
- **图标扩展性**:iconfont 提供更丰富的图标选择
## 📦 安装配置
### 1. 字体文件配置
**文件位置**:`mobile/src/static/fonts/iconfont.css`
```css
@font-face {
font-family: 'iconfont';
src: url('//at.alicdn.com/t/c/font_4793979_9m3yqql3ybj.woff2?t=1704153600') format('woff2'), url('//at.alicdn.com/t/c/font_4793979_9m3yqql3ybj.woff?t=1704153600')
format('woff'),
url('//at.alicdn.com/t/c/font_4793979_9m3yqql3ybj.ttf?t=1704153600') format('truetype');
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
```
### 2. 全局引入
**文件位置**:`mobile/src/main.js`
```javascript
// 导入 iconfont 样式
import '@/static/fonts/iconfont.css';
```
## 🎨 可用图标列表
### 基础图标
| 图标 | 类名 | 说明 | 编码 |
| ---- | ---------------- | -------- | ------- |
| 👤 | `icon-user` | 用户 | `\e7ae` |
| 🔒 | `icon-lock` | 锁定 | `\e60e` |
| 👁️ | `icon-eye` | 显示密码 | `\e61c` |
| 🙈 | `icon-eye-close` | 隐藏密码 | `\e61d` |
### 操作图标
| 图标 | 类名 | 说明 | 编码 |
| ---- | ------------- | ---- | ------- |
| 🚪 | `icon-login` | 登录 | `\e612` |
| 🔍 | `icon-search` | 搜索 | `\e6b4` |
| ➕ | `icon-add` | 添加 | `\e6a4` |
| ✅ | `icon-check` | 确认 | `\e6a8` |
| ❌ | `icon-close` | 关闭 | `\e6a9` |
### 导航图标
| 图标 | 类名 | 说明 | 编码 |
| ---- | ------------------ | ------ | ------- |
| 🏠 | `icon-home` | 首页 | `\e65e` |
| 💬 | `icon-message` | 消息 | `\e6a7` |
| ⚙️ | `icon-setting` | 设置 | `\e6b8` |
| ➡️ | `icon-arrow-right` | 箭头右 | `\e6a3` |
### 联系图标
| 图标 | 类名 | 说明 | 编码 |
| ---- | ------------ | ---- | ------- |
| 📞 | `icon-phone` | 电话 | `\e69a` |
| 📧 | `icon-mail` | 邮箱 | `\e692` |
## 🚀 使用方法
### 1. 基础用法
```vue
<template>
<!-- 基础图标 -->
<text class="iconfont icon-user"></text>
<text class="iconfont icon-lock"></text>
<!-- 带样式的图标 -->
<text class="iconfont icon-home custom-icon"></text>
</template>
<style>
.custom-icon {
font-size: 24px;
color: #5d87ff;
}
</style>
```
### 2. 登录表单中的应用
```vue
<template>
<view class="input-group">
<!-- 用户名输入框 -->
<text class="iconfont icon-user input-icon"></text>
<input class="login-input" type="text" placeholder="账号" />
</view>
<view class="input-group">
<!-- 密码输入框 -->
<text class="iconfont icon-lock input-icon"></text>
<input class="login-input" type="password" placeholder="密码" />
</view>
<!-- 登录按钮 -->
<button class="login-btn">
<text class="iconfont icon-login"></text>
<text>登录</text>
</button>
</template>
<style lang="scss">
.input-group {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 14px;
z-index: 2;
font-size: 18px;
color: #b0b8c9;
}
.login-input {
width: 100%;
height: 44px;
padding: 0 12px 0 44px;
border: 1px solid #e3e8f0;
border-radius: 8px;
background: #fff;
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: #5d87ff;
color: #fff;
}
</style>
```
### 3. 动态图标切换
```vue
<template>
<view class="password-toggle">
<text
:class="['iconfont', showPassword ? 'icon-eye-close' : 'icon-eye']"
@click="togglePassword"
></text>
</view>
</template>
<script>
export default {
data() {
return {
showPassword: false,
};
},
methods: {
togglePassword() {
this.showPassword = !this.showPassword;
},
},
};
</script>
```
## 🎨 样式自定义
### 1. 基础样式属性
```css
.iconfont {
/* 字体大小 */
font-size: 16px;
/* 颜色 */
color: #333;
/* 其他样式 */
opacity: 0.8;
transition: all 0.3s ease;
}
```
### 2. 响应式设计
```css
/* 小屏幕 */
@media (max-width: 375px) {
.iconfont {
font-size: 14px;
}
}
/* 大屏幕 */
@media (min-width: 768px) {
.iconfont {
font-size: 20px;
}
}
```
### 3. 主题适配
```css
/* 亮色主题 */
.light-theme .iconfont {
color: #333;
}
/* 暗色主题 */
.dark-theme .iconfont {
color: #fff;
}
```
## 🔧 扩展图标
### 1. 从 iconfont.cn 添加新图标
1. **访问项目**:打开 [iconfont.cn](https://www.iconfont.cn/) 的项目页面
2. **选择图标**:浏览并添加需要的图标到项目
3. **生成代码**:下载新的字体文件和 CSS 代码
4. **更新项目**:替换 `iconfont.css` 中的字体链接和图标类名
### 2. 图标命名规范
```css
/* 建议的命名格式 */
.icon-[功能]-[状态]
例如:
.icon-user /* 用户图标 */
.icon-eye-open /* 眼睛打开 */
.icon-eye-close /* 眼睛关闭 */
.icon-arrow-left /* 左箭头 */
.icon-arrow-right; /* 右箭头 */
```
## 📱 演示页面
**页面位置**:`mobile/src/pages/iconfont-demo/index.vue`
访问路径:`/pages/iconfont-demo/index`
演示页面包含:
- 所有可用图标的预览
- 图标分类展示(基础、操作、导航、联系)
- 使用代码示例
- 自定义样式演示
## ⚠️ 注意事项
### 1. 兼容性
- ✅ **支持平台**:H5、微信小程序、App、支付宝小程序
- ✅ **浏览器支持**:Chrome、Safari、Firefox、Edge
- ✅ **设备支持**:iOS、Android、HarmonyOS
### 2. 性能优化
```css
/* 字体预加载 */
.iconfont {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
```
### 3. 缓存策略
- 字体文件使用 CDN 加载,自动缓存
- 本地 CSS 文件确保离线可用
- 建议定期更新字体链接时间戳
### 4. 回退方案
```vue
<template>
<!-- 图标 + 文字回退 -->
<view class="icon-with-fallback">
<text class="iconfont icon-user"></text>
<text class="icon-fallback">用户</text>
</view>
</template>
<style>
.icon-fallback {
display: none;
}
/* 当字体加载失败时显示文字 */
@font-face {
font-family: 'iconfont-fallback';
src: local('Arial'), local('sans-serif');
}
.iconfont:before {
font-family: 'iconfont', 'iconfont-fallback';
}
</style>
```
## 📚 相关文件
- **样式文件**:`mobile/src/static/fonts/iconfont.css`
- **演示页面**:`mobile/src/pages/iconfont-demo/index.vue`
- **登录页面**:`mobile/src/pages/login/index.vue`
- **配置文件**:`mobile/src/main.js`
## 🎯 最佳实践
1. **统一使用**:项目中统一使用 iconfont,避免混用多种图标方案
2. **语义化命名**:使用有意义的类名,便于维护
3. **适当大小**:根据使用场景选择合适的字体大小
4. **颜色一致**:遵循设计规范,保持图标颜色的一致性
5. **性能考虑**:避免一次性加载过多不使用的图标
## 📝 更新日志
- **2025-07-01**:初始版本,集成 iconfont 字体库
- **预计扩展**:根据业务需求持续添加新图标
---
**维护者**:移动端开发团队
**更新时间**:2025-07-01
@@ -0,0 +1,280 @@
# 移动端路由守卫使用指南
## 📋 概述
移动端路由守卫是一个全局的权限控制系统,确保用户未登录或登录失效时,访问需要权限的页面会自动跳转到登录页。
## 🎯 核心功能
### 1. 自动权限检查
- **页面加载时检查** → `onLoad()` 生命周期
- **页面显示时检查** → `onShow()` 生命周期
- **页面跳转前检查** → 使用守卫跳转方法
- **HTTP 请求拦截** → 401 响应自动处理
### 2. 多层次验证
- ✅ 检查登录状态 `userStorage.isLoggedIn()`
- ✅ 检查 Token 存在 `userStorage.getToken()`
- ✅ 检查 Token 过期 `userState.checkTokenExpiration()`
- ✅ 区分公开页面和受保护页面
### 3. 优雅的用户体验
- 🎨 友好的错误提示
- 🎨 自动清理失效信息
- 🎨 延迟跳转确保提示显示
- 🎨 使用 `reLaunch` 清除页面栈
## 🔧 使用方法
### 在页面中使用
```javascript
<script>
import { checkPageAccess } from '@/utils/route-guard';
export default {
onLoad() {
// 页面加载时检查权限
if (!checkPageAccess()) {
return; // 权限检查失败,自动跳转登录页
}
// 页面初始化逻辑
this.initPageData();
},
onShow() {
// 页面显示时再次检查权限(防止后台切换回来时失效)
if (!checkPageAccess()) {
return;
}
// 刷新页面数据
this.refreshData();
},
methods: {
initPageData() {
// 需要登录权限的初始化逻辑
},
refreshData() {
// 需要登录权限的数据刷新逻辑
}
}
}
</script>
```
### 带权限检查的页面跳转
```javascript
<script>
import { guardedNavigate, guardedSwitchTab } from '@/utils/route-guard';
export default {
methods: {
// 普通页面跳转
goToProfile() {
guardedNavigate('/pages/profile/index');
},
// Tab页面切换
switchToWorkbench() {
guardedSwitchTab('/pages/workbench/index');
},
// 带参数跳转
goToDetail(id) {
guardedNavigate(`/pages/detail/index?id=${id}`);
}
}
}
</script>
```
### 使用页面混入(可选)
```javascript
<script>
import { routeGuardMixin } from '@/utils/route-guard';
export default {
// 使用混入,自动添加权限检查
mixins: [routeGuardMixin],
onLoad() {
// 混入会自动调用权限检查
// 这里只需要写业务逻辑
this.loadData();
}
}
</script>
```
## ⚙️ 配置说明
### 公开页面配置
在 `mobile/src/utils/route-guard.js` 中配置无需登录的页面:
```javascript
// 无需登录验证的页面列表
const PUBLIC_PAGES = [
'/pages/login/index', // 登录页
'/pages/index/index', // 首页(功能受限但可访问)
];
```
### 自定义登录页路径
```javascript
// 登录页路径
const LOGIN_PAGE = '/pages/login/index';
```
## 🧪 测试功能
访问测试页面验证路由守卫功能:
```
页面路径: /pages/test-route/index
```
测试页面提供:
- 📊 实时登录状态显示
- 🔧 测试按钮(清除 Token、跳转等)
- 📋 详细的操作日志
- 🎯 权限检查结果验证
## 🔄 工作流程
### 权限检查流程
```mermaid
graph TD
A[页面访问] --> B{是否公开页面?}
B -->|是| C[允许访问]
B -->|否| D{用户已登录?}
D -->|否| E[跳转登录页]
D -->|是| F{Token存在?}
F -->|否| E
F -->|是| G{Token未过期?}
G -->|否| E
G -->|是| C
```
### HTTP 拦截流程
```mermaid
graph TD
A[API请求] --> B[发送请求]
B --> C{响应状态}
C -->|200| D[正常处理]
C -->|401| E[清除登录信息]
E --> F[显示提示]
F --> G[跳转登录页]
```
## 📝 最佳实践
### 1. 页面权限设计原则
- **最小权限原则**:只有必要的页面才需要登录
- **用户友好原则**:首页等入口页面可以部分访问
- **安全优先原则**:敏感操作必须验证权限
### 2. 错误处理建议
```javascript
onLoad() {
try {
if (!checkPageAccess()) {
return;
}
// 业务逻辑
await this.loadUserData();
} catch (error) {
console.error('页面初始化失败:', error);
uni.showToast({
title: '页面加载失败',
icon: 'none'
});
}
}
```
### 3. 性能优化建议
- 权限检查放在业务逻辑之前
- 避免在权限检查失败后继续执行后续逻辑
- 合理使用页面缓存减少重复检查
## 🚨 注意事项
### 1. 公开页面处理
```javascript
// ❌ 错误:在公开页面中强制检查权限
onLoad() {
if (!checkPageAccess()) return; // 首页不应该强制登录
}
// ✅ 正确:区分功能的权限要求
onLoad() {
// 首页可以访问,但部分功能需要登录
this.checkLoginForFeatures();
}
```
### 2. 避免循环跳转
```javascript
// ❌ 错误:在登录页中调用权限检查
// pages/login/index.vue
onLoad() {
checkPageAccess(); // 会导致循环跳转
}
// ✅ 正确:登录页无需权限检查
onLoad() {
// 直接执行登录页逻辑
}
```
### 3. Token 刷新处理
```javascript
// 建议在HTTP拦截器中处理Token刷新
// 避免在页面级别处理复杂的Token逻辑
```
## 🔗 相关文档
- [移动端用户管理工具](./移动端用户管理工具使用指南.md)
- [移动端 API 请求规范](./移动端API请求规范.md)
- [移动端开发规范总结](./移动端开发规范总结.md)
## 💡 常见问题
### Q: 如何添加新的公开页面?
A: 在 `route-guard.js` 的 `PUBLIC_PAGES` 数组中添加页面路径。
### Q: 权限检查失败但没有跳转到登录页?
A: 检查控制台日志,确认 `reLaunch` 是否执行成功,必要时检查页面路径配置。
### Q: 如何自定义权限检查逻辑?
A: 可以扩展 `checkPageAccess` 函数,添加更复杂的权限验证逻辑。
### Q: 在什么情况下需要使用 `routeGuardMixin`?
A: 当页面逻辑复杂,希望自动化权限检查时使用,但通常建议手动调用以保持代码清晰。
+9 -4
View File
@@ -1,15 +1,20 @@
<script>
import { initRouteGuard } from '@/utils/route-guard';
export default {
onLaunch: function () {
console.log('App Launch')
console.log('App Launch');
// 初始化全局路由守卫
initRouteGuard();
},
onShow: function () {
console.log('App Show')
console.log('App Show');
},
onHide: function () {
console.log('App Hide')
console.log('App Hide');
},
}
};
</script>
<style>
+28 -7
View File
@@ -1,11 +1,32 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import uviewPlus from 'uview-plus'
import { createSSRApp } from 'vue';
import App from './App.vue';
import uviewPlus from 'uview-plus';
// 导入 uview-plus 样式
import 'uview-plus/index.scss';
// 导入 iconfont 样式
import '@/static/fonts/iconfont.css';
// 预加载字体文件 - 使用阿里CDN
uni.loadFontFace({
family: 'uicon-iconfont',
source: 'url("https://at.alicdn.com/t/font_2225171_8kdcwk4po24.ttf")',
success() {
console.log('uview-plus图标字体加载成功');
},
fail(error) {
console.error('uview-plus图标字体加载失败', error);
},
});
export function createApp() {
const app = createSSRApp(App)
app.use(uviewPlus)
const app = createSSRApp(App);
// 注册 uview-plus
app.use(uviewPlus);
return {
app
}
app,
};
}
+32
View File
@@ -1,5 +1,22 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^uv-(.*)": "uview-plus/components/u-$1/u-$1.vue"
}
},
"pages": [
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom",
"navigationBarBackgroundColor": "transparent",
"backgroundColor": "transparent"
}
},
{
"path": "pages/index/index",
"style": {
@@ -20,6 +37,21 @@
"navigationBarTitleText": "我的",
"navigationStyle": "custom"
}
},
{
"path": "pages/iconfont-demo/index",
"style": {
"navigationBarTitleText": "图标演示",
"navigationBarBackgroundColor": "#5d87ff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/test-route/index",
"style": {
"navigationBarTitleText": "路由守卫测试",
"navigationStyle": "custom"
}
}
],
"globalStyle": {
+262
View File
@@ -0,0 +1,262 @@
<template>
<view class="iconfont-demo">
<view class="demo-header">
<text class="demo-title">iconfont 图标库演示</text>
<text class="demo-subtitle">基于iconfont.cn的图标字体</text>
</view>
<view class="demo-content">
<!-- 基础图标 -->
<view class="icon-section">
<text class="section-title">基础图标</text>
<view class="icon-grid">
<view class="icon-item">
<text class="iconfont icon-user"></text>
<text class="icon-name">用户</text>
<text class="icon-code">icon-user</text>
</view>
<view class="icon-item">
<text class="iconfont icon-lock"></text>
<text class="icon-name">锁定</text>
<text class="icon-code">icon-lock</text>
</view>
<view class="icon-item">
<text class="iconfont icon-eye"></text>
<text class="icon-name">眼睛</text>
<text class="icon-code">icon-eye</text>
</view>
<view class="icon-item">
<text class="iconfont icon-eye-close"></text>
<text class="icon-name">关闭眼睛</text>
<text class="icon-code">icon-eye-close</text>
</view>
</view>
</view>
<!-- 操作图标 -->
<view class="icon-section">
<text class="section-title">操作图标</text>
<view class="icon-grid">
<view class="icon-item">
<text class="iconfont icon-login"></text>
<text class="icon-name">登录</text>
<text class="icon-code">icon-login</text>
</view>
<view class="icon-item">
<text class="iconfont icon-search"></text>
<text class="icon-name">搜索</text>
<text class="icon-code">icon-search</text>
</view>
<view class="icon-item">
<text class="iconfont icon-add"></text>
<text class="icon-name">添加</text>
<text class="icon-code">icon-add</text>
</view>
<view class="icon-item">
<text class="iconfont icon-close"></text>
<text class="icon-name">关闭</text>
<text class="icon-code">icon-close</text>
</view>
</view>
</view>
<!-- 导航图标 -->
<view class="icon-section">
<text class="section-title">导航图标</text>
<view class="icon-grid">
<view class="icon-item">
<text class="iconfont icon-home"></text>
<text class="icon-name">首页</text>
<text class="icon-code">icon-home</text>
</view>
<view class="icon-item">
<text class="iconfont icon-message"></text>
<text class="icon-name">消息</text>
<text class="icon-code">icon-message</text>
</view>
<view class="icon-item">
<text class="iconfont icon-setting"></text>
<text class="icon-name">设置</text>
<text class="icon-code">icon-setting</text>
</view>
<view class="icon-item">
<text class="iconfont icon-arrow-right"></text>
<text class="icon-name">箭头右</text>
<text class="icon-code">icon-arrow-right</text>
</view>
</view>
</view>
<!-- 联系图标 -->
<view class="icon-section">
<text class="section-title">联系图标</text>
<view class="icon-grid">
<view class="icon-item">
<text class="iconfont icon-phone"></text>
<text class="icon-name">电话</text>
<text class="icon-code">icon-phone</text>
</view>
<view class="icon-item">
<text class="iconfont icon-mail"></text>
<text class="icon-name">邮箱</text>
<text class="icon-code">icon-mail</text>
</view>
<view class="icon-item">
<text class="iconfont icon-check"></text>
<text class="icon-name">确认</text>
<text class="icon-code">icon-check</text>
</view>
</view>
</view>
<!-- 使用说明 -->
<view class="usage-section">
<text class="section-title">使用方法</text>
<view class="usage-code">
<text class="code-line">
&lt;text class="iconfont icon-user"&gt;&lt;/text&gt;
</text>
<text class="code-line">
&lt;text class="iconfont icon-lock"&gt;&lt;/text&gt;
</text>
<text class="code-line">/* 自定义样式 */</text>
<text class="code-line">.custom-icon {</text>
<text class="code-line">font-size: 24px;</text>
<text class="code-line">color: #5d87ff;</text>
<text class="code-line">}</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'IconFontDemo',
data() {
return {};
},
};
</script>
<style lang="scss" scoped>
.iconfont-demo {
min-height: 100vh;
background: #f8f9fa;
padding: 20px;
}
.demo-header {
text-align: center;
margin-bottom: 32px;
}
.demo-title {
display: block;
font-size: 24px;
font-weight: bold;
color: #222;
margin-bottom: 8px;
}
.demo-subtitle {
display: block;
font-size: 14px;
color: #666;
}
.demo-content {
max-width: 800px;
margin: 0 auto;
}
.icon-section {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-title {
display: block;
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #5d87ff;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 16px;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 8px;
border: 1px solid #e9ecef;
border-radius: 8px;
background: #fafbfc;
transition: all 0.2s ease;
}
.icon-item:hover {
border-color: #5d87ff;
background: #f0f4ff;
}
.icon-item .iconfont {
width: 32px;
height: 32px;
margin-bottom: 8px;
filter: brightness(0) saturate(100%) invert(38%) sepia(87%) saturate(6400%) hue-rotate(225deg)
brightness(104%) contrast(104%);
}
.icon-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
font-weight: 500;
}
.icon-code {
font-size: 12px;
color: #666;
font-family: 'Consolas', 'Monaco', monospace;
background: #f1f3f4;
padding: 2px 6px;
border-radius: 4px;
}
.usage-section {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.usage-code {
background: #2d3748;
border-radius: 8px;
padding: 16px;
font-family: 'Consolas', 'Monaco', monospace;
}
.code-line {
display: block;
color: #e2e8f0;
font-size: 14px;
line-height: 1.6;
margin-bottom: 4px;
}
.code-line:last-child {
margin-bottom: 0;
}
</style>
+26 -2
View File
@@ -166,6 +166,9 @@ import Card from '@/components/Card.vue';
import ServiceItem from '@/components/ServiceItem.vue';
import StatCard from '@/components/StatCard.vue';
import GridLayout from '@/components/GridLayout.vue';
import { userStorage } from '@/utils/request';
import { userManager, userState } from '@/utils/user';
import { guardedSwitchTab } from '@/utils/route-guard';
export default {
name: 'HomePage',
@@ -179,7 +182,7 @@ export default {
data() {
return {
userInfo: {
name: '张同学',
name: '用户',
},
currentDateTime: '',
weather: {
@@ -279,6 +282,7 @@ export default {
};
},
onLoad() {
this.checkLoginStatus();
this.updateDateTime();
this.startTimeUpdate();
},
@@ -288,6 +292,26 @@ export default {
}
},
methods: {
// 检查登录状态
checkLoginStatus() {
// 使用新的状态检查工具
if (!userState.autoLoginCheck()) {
return;
}
// 获取并更新用户信息
this.loadUserInfo();
},
// 加载用户信息
loadUserInfo() {
const displayUser = userManager.formatUserForDisplay();
if (displayUser) {
this.userInfo.name = displayUser.name;
this.userInfo.avatar = displayUser.avatar;
}
},
updateDateTime() {
const now = new Date();
const month = now.getMonth() + 1;
@@ -327,7 +351,7 @@ export default {
uni.showToast({ title: `使用${tool.title}`, icon: 'none' });
},
navigateToService() {
uni.switchTab({ url: '/pages/workbench/index' });
guardedSwitchTab('/pages/workbench/index');
},
viewAllTodos() {
uni.showToast({ title: '查看全部待办', icon: 'none' });
+1
View File
@@ -0,0 +1 @@
+65 -11
View File
@@ -155,6 +155,9 @@ import NavBar from '@/components/NavBar.vue';
import Card from '@/components/Card.vue';
import StatCard from '@/components/StatCard.vue';
import GridLayout from '@/components/GridLayout.vue';
import { userStorage } from '@/utils/request';
import { userManager, userState } from '@/utils/user';
import { checkPageAccess } from '@/utils/route-guard';
export default {
name: 'ProfilePage',
@@ -167,13 +170,13 @@ export default {
data() {
return {
userInfo: {
name: '张同学',
major: '计算机科学与技术',
grade: '大三',
studentId: '2021001234',
gpa: 4.2,
credits: 95,
ranking: 15,
name: '用户',
major: '未设置',
grade: '未设置',
studentId: '未设置',
gpa: 0,
credits: 0,
ranking: 0,
},
learningStats: [
{ number: 5, label: '本周课程' },
@@ -288,6 +291,24 @@ export default {
};
},
methods: {
// 检查登录状态
checkLoginStatus() {
return userState.autoLoginCheck();
},
// 加载用户信息
loadUserInfo() {
const displayUser = userManager.formatUserForDisplay();
if (displayUser) {
this.userInfo.name = displayUser.name;
this.userInfo.avatar = displayUser.avatar;
this.userInfo.department = displayUser.department;
this.userInfo.phone = displayUser.phone;
this.userInfo.email = displayUser.email;
this.userInfo.studentId = userManager.getCurrentUser()?.userId || '未设置';
}
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' });
},
@@ -344,17 +365,50 @@ export default {
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '正在退出...', icon: 'loading' });
uni.showLoading({ title: '正在退出...' });
// 清除登录信息
userStorage.clearLoginInfo();
setTimeout(() => {
uni.showToast({ title: '已退出登录', icon: 'success' });
// 这里可以跳转到登录页面
uni.hideLoading();
uni.showToast({
title: '已退出登录',
icon: 'success'
});
// 跳转到登录页面
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index'
});
}, 500);
}, 1000);
}
},
});
},
},
onLoad() {},
onLoad() {
// 检查页面访问权限
if (!checkPageAccess()) {
return;
}
if (this.checkLoginStatus()) {
this.loadUserInfo();
}
},
onShow() {
// 页面显示时再次检查权限
if (!checkPageAccess()) {
return;
}
// 刷新用户信息
this.loadUserInfo();
},
};
</script>
+346
View File
@@ -0,0 +1,346 @@
<template>
<view class="test-page">
<NavBar title="路由守卫测试" />
<view class="content">
<view class="test-section">
<text class="section-title">🛡️ 路由守卫测试页面</text>
<text class="section-desc">如果你能看到这个页面,说明已经通过了登录验证</text>
</view>
<view class="test-info">
<view class="info-item">
<text class="info-label">当前时间:</text>
<text class="info-value">{{ currentTime }}</text>
</view>
<view class="info-item">
<text class="info-label">登录状态:</text>
<text class="info-value" :class="isLoggedIn ? 'status-ok' : 'status-error'">
{{ isLoggedIn ? '✅ 已登录' : '❌ 未登录' }}
</text>
</view>
<view class="info-item">
<text class="info-label">Token状态:</text>
<text class="info-value" :class="hasToken ? 'status-ok' : 'status-error'">
{{ hasToken ? '✅ 有效' : '❌ 无效' }}
</text>
</view>
<view class="info-item">
<text class="info-label">用户信息:</text>
<text class="info-value">{{ userName || '未获取' }}</text>
</view>
</view>
<view class="test-buttons">
<button class="test-btn" @click="testRedirectToLogin">测试跳转登录页</button>
<button class="test-btn" @click="testClearToken">清除Token测试</button>
<button class="test-btn" @click="testNavigateWithGuard">带守卫的页面跳转</button>
<button class="test-btn" @click="refreshPageInfo">刷新页面信息</button>
</view>
<view class="log-section">
<text class="section-title">📋 测试日志</text>
<scroll-view class="log-content" scroll-y>
<view
v-for="(log, index) in logs"
:key="index"
class="log-item"
:class="log.type"
>
<text class="log-time">{{ log.time }}</text>
<text class="log-text">{{ log.message }}</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
import NavBar from '@/components/NavBar.vue';
import { userStorage } from '@/utils/request';
import { userManager, userState } from '@/utils/user';
import { checkPageAccess, guardedNavigate } from '@/utils/route-guard';
export default {
name: 'TestRoutePage',
components: {
NavBar,
},
data() {
return {
currentTime: '',
isLoggedIn: false,
hasToken: false,
userName: '',
logs: [],
};
},
onLoad() {
this.addLog('info', '页面加载开始');
// 检查页面访问权限
if (!checkPageAccess()) {
this.addLog('error', '页面访问权限检查失败,应该会自动跳转到登录页');
return;
}
this.addLog('success', '页面访问权限检查通过');
this.refreshPageInfo();
},
onShow() {
this.addLog('info', '页面显示事件触发');
// 页面显示时再次检查权限
if (!checkPageAccess()) {
this.addLog('error', '页面显示时权限检查失败');
return;
}
this.addLog('success', '页面显示时权限检查通过');
this.updateCurrentTime();
},
onUnload() {
this.addLog('info', '页面卸载');
},
methods: {
// 刷新页面信息
refreshPageInfo() {
this.updateCurrentTime();
this.checkLoginStatus();
this.checkTokenStatus();
this.loadUserInfo();
this.addLog('info', '页面信息已刷新');
},
// 更新当前时间
updateCurrentTime() {
this.currentTime = new Date().toLocaleString();
},
// 检查登录状态
checkLoginStatus() {
this.isLoggedIn = userStorage.isLoggedIn();
this.addLog('info', `登录状态检查: ${this.isLoggedIn ? '已登录' : '未登录'}`);
},
// 检查Token状态
checkTokenStatus() {
const token = userStorage.getToken();
this.hasToken = !!token;
const isExpired = userState.checkTokenExpiration();
this.addLog('info', `Token检查: ${this.hasToken ? '存在' : '不存在'}`);
if (this.hasToken) {
this.addLog('info', `Token过期检查: ${isExpired ? '已过期' : '未过期'}`);
}
},
// 加载用户信息
loadUserInfo() {
const user = userManager.getCurrentUser();
this.userName = user ? user.nickName || user.userName : '';
this.addLog('info', `用户信息: ${this.userName || '未获取到'}`);
},
// 测试跳转到登录页
testRedirectToLogin() {
this.addLog('warning', '手动跳转到登录页测试');
uni.reLaunch({
url: '/pages/login/index',
});
},
// 测试清除Token
testClearToken() {
this.addLog('warning', '清除Token进行测试');
userStorage.clearLoginInfo();
this.addLog('error', 'Token已清除,页面应该会自动跳转到登录页');
// 模拟页面重新检查权限
setTimeout(() => {
if (!checkPageAccess()) {
this.addLog('success', '权限检查失败,正在跳转登录页');
}
}, 1000);
},
// 测试带守卫的页面跳转
testNavigateWithGuard() {
this.addLog('info', '测试带权限检查的页面跳转');
const result = guardedNavigate('/pages/profile/index');
if (result) {
this.addLog('success', '跳转成功');
} else {
this.addLog('error', '跳转失败或被拦截');
}
},
// 添加日志
addLog(type, message) {
const log = {
type,
time: new Date().toLocaleTimeString(),
message,
};
this.logs.unshift(log);
// 限制日志数量
if (this.logs.length > 20) {
this.logs.pop();
}
console.log(`[${type.toUpperCase()}] ${message}`);
},
},
};
</script>
<style lang="scss" scoped>
.test-page {
min-height: 100vh;
background-color: $uni-bg-color-grey;
}
.content {
padding: 16px;
}
.test-section {
background: $uni-bg-color;
border-radius: $uni-border-radius-lg;
padding: 20px;
margin-bottom: 16px;
text-align: center;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: $uni-text-color;
display: block;
margin-bottom: 8px;
}
.section-desc {
font-size: 14px;
color: $uni-text-color-grey;
line-height: 1.5;
}
.test-info {
background: $uni-bg-color;
border-radius: $uni-border-radius-lg;
padding: 16px;
margin-bottom: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.info-label {
font-size: 14px;
color: $uni-text-color-grey;
}
.info-value {
font-size: 14px;
color: $uni-text-color;
font-weight: 500;
&.status-ok {
color: #52c41a;
}
&.status-error {
color: #ff4d4f;
}
}
.test-buttons {
margin-bottom: 16px;
}
.test-btn {
width: 100%;
background: $uni-color-primary;
color: $uni-text-color-inverse;
border: none;
border-radius: $uni-border-radius-base;
padding: 12px;
margin-bottom: 8px;
font-size: 14px;
&:last-child {
margin-bottom: 0;
}
&:active {
background: $uni-color-primary-dark;
}
}
.log-section {
background: $uni-bg-color;
border-radius: $uni-border-radius-lg;
padding: 16px;
}
.log-content {
max-height: 300px;
margin-top: 12px;
}
.log-item {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: $uni-border-radius-base;
border-left: 3px solid;
&.info {
background: #f0f9ff;
border-left-color: #0ea5e9;
}
&.success {
background: #f0f9f4;
border-left-color: #22c55e;
}
&.warning {
background: #fffbeb;
border-left-color: #f59e0b;
}
&.error {
background: #fef2f2;
border-left-color: #ef4444;
}
}
.log-time {
font-size: 12px;
color: $uni-text-color-grey;
margin-right: 8px;
}
.log-text {
font-size: 13px;
color: $uni-text-color;
line-height: 1.4;
}
</style>
+26 -1
View File
@@ -165,6 +165,8 @@ import NavBar from '@/components/NavBar.vue';
import Card from '@/components/Card.vue';
import ServiceItem from '@/components/ServiceItem.vue';
import GridLayout from '@/components/GridLayout.vue';
import { userStorage } from '@/utils/request';
import { checkPageAccess } from '@/utils/route-guard';
export default {
name: 'WorkbenchPage',
@@ -340,8 +342,31 @@ export default {
},
});
},
// 检查登录状态
checkLoginStatus() {
if (!userStorage.isLoggedIn()) {
uni.reLaunch({
url: '/pages/login/index',
});
return;
}
},
},
onLoad() {
// 检查页面访问权限
if (!checkPageAccess()) {
return;
}
this.checkLoginStatus();
},
onShow() {
// 页面显示时再次检查权限
if (!checkPageAccess()) {
return;
}
},
onLoad() {},
};
</script>
Binary file not shown.
+75 -1
View File
@@ -1 +1,75 @@
/* 空的iconfont文件 - 用于解决依赖问题 */
.iconfont {
display: inline-block;
width: 16px;
height: 16px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* 用户图标 */
.icon-user {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E");
}
/* 密码锁图标 */
.icon-lock {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6z'/%3E%3C/svg%3E");
}
/* 眼睛图标(显示密码) */
.icon-eye {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z'/%3E%3C/svg%3E");
}
/* 眼睛关闭图标(隐藏密码) */
.icon-eye-close {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z'/%3E%3C/svg%3E");
}
/* 登录图标 */
.icon-login {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z'/%3E%3C/svg%3E");
}
/* 箭头右图标 */
.icon-arrow-right {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z'/%3E%3C/svg%3E");
}
/* 更多常用图标 */
.icon-home {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z'/%3E%3C/svg%3E");
}
.icon-message {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z'/%3E%3C/svg%3E");
}
.icon-setting {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z'/%3E%3C/svg%3E");
}
.icon-search {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E");
}
.icon-add {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z'/%3E%3C/svg%3E");
}
.icon-check {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E");
}
.icon-close {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
}
.icon-phone {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z'/%3E%3C/svg%3E");
}
.icon-mail {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z'/%3E%3C/svg%3E");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+6
View File
@@ -15,6 +15,12 @@
// 引入 uview-plus 样式
@import 'uview-plus/theme.scss';
// 引入 uview-plus 图标字体 - 使用阿里CDN
@font-face {
font-family: 'uicon-iconfont';
src: url('https://at.alicdn.com/t/font_2225171_8kdcwk4po24.ttf') format('truetype');
}
/* 原有的Sass变量定义 - 保持向后兼容 */
$uni-color-primary: rgb(93, 135, 255);
$uni-color-primary-dark: rgb(73, 115, 235);
+158
View File
@@ -0,0 +1,158 @@
// @ts-nocheck
import CryptoJS from 'crypto-js';
// 配置信息
const ENCRYPT_CONFIG = {
// RSA公钥 - 与后端保持一致 (更新为正确版本,包含ijT7h)
RSA_PUBLIC_KEY:
'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==',
CLIENT_ID: 'e5cd7e4891bf95d1d19206ce24a7b32e',
TENANT_ID: '000000',
};
/**
* 生成随机AES密钥
* @param {number} length 密钥长度,默认32位
* @returns {string} AES密钥
*/
export function generateRandomAESKey(length = 32) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
/**
* AES加密数据
* @param {string} data 待加密的数据
* @param {string} key AES密钥
* @returns {string} 加密后的数据
*/
export function encryptWithAES(data, key) {
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}
/**
* RSA加密AES密钥 - 浏览器兼容版本
* @param {string} aesKey AES密钥
* @returns {Promise<string|null>} RSA加密后的AES密钥
*/
export async function encryptAESKeyWithRSA(aesKey) {
try {
// 将Base64公钥转换为ArrayBuffer
const publicKeyData = Uint8Array.from(atob(ENCRYPT_CONFIG.RSA_PUBLIC_KEY), (c) =>
c.charCodeAt(0),
);
// 导入RSA公钥
const publicKey = await crypto.subtle.importKey(
'spki',
publicKeyData,
{
name: 'RSA-OAEP',
hash: 'SHA-1',
},
false,
['encrypt'],
);
// 将AES密钥转为Base64
const aesKeyBase64 = btoa(aesKey);
const encoder = new TextEncoder();
const data = encoder.encode(aesKeyBase64);
// RSA加密
const encrypted = await crypto.subtle.encrypt(
{
name: 'RSA-OAEP',
},
publicKey,
data,
);
// 转换为Base64
const encryptedArray = new Uint8Array(encrypted);
return btoa(String.fromCharCode.apply(null, encryptedArray));
} catch (error) {
console.error('RSA加密失败:', error.message);
console.log('🔄 回退到简化加密方案...');
// 回退方案:使用AES密钥的Base64编码(临时方案)
try {
return btoa(aesKey);
} catch (fallbackError) {
console.error('回退加密也失败:', fallbackError.message);
return null;
}
}
}
/**
* 登录数据加密处理
* @param {Object} loginData 登录数据对象
* @returns {Promise<Object>} 包含加密数据和请求头的对象
*/
export async function encryptLoginData(loginData) {
try {
// 1. 生成AES密钥
const aesKey = generateRandomAESKey(32);
console.log('🔑 生成AES密钥:', aesKey);
// 2. 准备完整的登录数据
const fullLoginData = {
tenantId: loginData.tenantId || ENCRYPT_CONFIG.TENANT_ID,
username: loginData.username,
password: loginData.password,
clientId: loginData.clientId || ENCRYPT_CONFIG.CLIENT_ID,
grantType: loginData.grantType || 'password',
};
console.log('📋 登录数据:', JSON.stringify(fullLoginData, null, 2));
// 3. AES加密登录数据
const encryptedData = encryptWithAES(JSON.stringify(fullLoginData), aesKey);
console.log('🔐 AES加密后数据长度:', encryptedData.length);
// 4. RSA加密AES密钥
const encryptedKey = await encryptAESKeyWithRSA(aesKey);
if (!encryptedKey) {
throw new Error('RSA加密AES密钥失败');
}
console.log('🔑 RSA加密后密钥长度:', encryptedKey.length);
// 5. 返回加密结果和请求头
return {
success: true,
encryptedData: encryptedData,
headers: {
'Content-Type': 'application/json;charset=utf-8',
clientid: ENCRYPT_CONFIG.CLIENT_ID,
'encrypt-key': encryptedKey,
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
isToken: 'false',
isEncrypt: 'true',
repeatSubmit: 'false',
},
};
} catch (error) {
console.error('登录数据加密失败:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* 获取加密配置
*/
export function getEncryptConfig() {
return ENCRYPT_CONFIG;
}
+151
View File
@@ -0,0 +1,151 @@
// @ts-nocheck
import CryptoJS from 'crypto-js';
import JSEncrypt from 'jsencrypt';
// 配置信息
const ENCRYPT_CONFIG = {
// RSA公钥 - 与后端保持一致 (更新为正确版本,包含ijT7h)
RSA_PUBLIC_KEY:
'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==',
CLIENT_ID: 'e5cd7e4891bf95d1d19206ce24a7b32e',
TENANT_ID: '000000',
};
/**
* 生成随机AES密钥
* @param {number} length 密钥长度,默认32位
* @returns {string} AES密钥
*/
export function generateRandomAESKey(length = 32) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
/**
* AES加密数据
* @param {string} data 待加密的数据
* @param {string} key AES密钥
* @returns {string} 加密后的数据
*/
export function encryptWithAES(data, key) {
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}
/**
* RSA加密AES密钥 - 使用JSEncrypt
* @param {string} aesKey AES密钥
* @returns {string|null} RSA加密后的AES密钥
*/
export function encryptAESKeyWithRSA(aesKey) {
try {
console.log('🔧 开始RSA加密,AES密钥:', aesKey);
// 构造完整的PEM格式RSA公钥
const pemKey = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqEfJl9KxCi6s3Fpjs234+uCvG
SIDnR585F0jlTmWxbv0yYWe6xQDfnmhpJdwuG7nHEHfxIxLI/Yq+4fejDAMCAwEA
AQ==
-----END PUBLIC KEY-----`;
console.log('🔧 RSA公钥PEM格式已构造');
// 创建JSEncrypt实例
const encrypt = new JSEncrypt();
// 设置公钥
const keySetResult = encrypt.setPublicKey(pemKey);
console.log('🔧 公钥设置结果:', keySetResult);
// 将AES密钥转为Base64(与custom-login-script.js保持一致)
const aesKeyBase64 = btoa(unescape(encodeURIComponent(aesKey)));
console.log('🔧 AES密钥Base64:', aesKeyBase64);
// RSA加密
const encrypted = encrypt.encrypt(aesKeyBase64);
console.log('🔧 RSA加密结果:', encrypted);
if (!encrypted) {
throw new Error('JSEncrypt RSA加密返回null,可能是公钥格式错误或加密失败');
}
console.log('✅ RSA加密成功,结果长度:', encrypted.length);
return encrypted;
} catch (error) {
console.error('❌ RSA加密失败:', error.message);
console.error('❌ 错误堆栈:', error.stack);
return null;
}
}
/**
* 登录数据加密处理
* @param {Object} loginData 登录数据对象
* @returns {Object} 包含加密数据和请求头的对象
*/
export function encryptLoginData(loginData) {
try {
// 1. 生成AES密钥
const aesKey = generateRandomAESKey(32);
console.log('🔑 生成AES密钥:', aesKey);
// 2. 准备完整的登录数据
const fullLoginData = {
tenantId: loginData.tenantId || ENCRYPT_CONFIG.TENANT_ID,
username: loginData.username,
password: loginData.password,
clientId: loginData.clientId || ENCRYPT_CONFIG.CLIENT_ID,
grantType: loginData.grantType || 'password',
};
console.log('📋 登录数据:', JSON.stringify(fullLoginData, null, 2));
// 3. AES加密登录数据
const encryptedData = encryptWithAES(JSON.stringify(fullLoginData), aesKey);
console.log('🔐 AES加密后数据长度:', encryptedData.length);
// 4. RSA加密AES密钥
const encryptedKey = encryptAESKeyWithRSA(aesKey);
if (!encryptedKey) {
throw new Error('RSA加密AES密钥失败');
}
console.log('🔑 RSA加密后密钥长度:', encryptedKey.length);
// 5. 返回加密结果和请求头
return {
success: true,
encryptedData: encryptedData,
headers: {
'Content-Type': 'application/json;charset=utf-8',
clientid: ENCRYPT_CONFIG.CLIENT_ID,
'encrypt-key': encryptedKey,
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
isToken: 'false',
isEncrypt: 'true',
repeatSubmit: 'false',
},
};
} catch (error) {
console.error('登录数据加密失败:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* 获取加密配置
*/
export function getEncryptConfig() {
return ENCRYPT_CONFIG;
}
+199
View File
@@ -0,0 +1,199 @@
// @ts-nocheck
import CryptoJS from 'crypto-js';
// 配置信息
const ENCRYPT_CONFIG = {
// RSA公钥 - 与后端保持一致 (更新为正确版本,包含ijT7h)
RSA_PUBLIC_KEY:
'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==',
CLIENT_ID: 'e5cd7e4891bf95d1d19206ce24a7b32e',
TENANT_ID: '000000',
};
/**
* 生成随机AES密钥
* @param {number} length 密钥长度,默认32位
* @returns {string} AES密钥
*/
export function generateRandomAESKey(length = 32) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
/**
* AES加密数据
* @param {string} data 待加密的数据
* @param {string} key AES密钥
* @returns {string} 加密后的数据
*/
export function encryptWithAES(data, key) {
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}
/**
* Base64字符串转ArrayBuffer
* @param {string} base64
* @returns {ArrayBuffer}
*/
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
/**
* ArrayBuffer转Base64字符串
* @param {ArrayBuffer} buffer
* @returns {string}
*/
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* 导入RSA公钥
* @param {string} publicKeyBase64 Base64格式的公钥
* @returns {Promise<CryptoKey>}
*/
async function importRSAPublicKey(publicKeyBase64) {
try {
const keyBuffer = base64ToArrayBuffer(publicKeyBase64);
const key = await crypto.subtle.importKey(
'spki',
keyBuffer,
{
name: 'RSA-OAEP',
hash: 'SHA-1',
},
false,
['encrypt'],
);
return key;
} catch (error) {
console.error('导入RSA公钥失败:', error);
throw error;
}
}
/**
* RSA加密AES密钥 - 使用Web Crypto API
* @param {string} aesKey AES密钥
* @returns {Promise<string|null>} RSA加密后的AES密钥
*/
export async function encryptAESKeyWithRSA(aesKey) {
try {
// 检查Web Crypto API支持
if (!crypto || !crypto.subtle) {
throw new Error('当前环境不支持Web Crypto API');
}
// 导入RSA公钥
const publicKey = await importRSAPublicKey(ENCRYPT_CONFIG.RSA_PUBLIC_KEY);
// 将AES密钥转为Base64(与custom-login-script.js保持一致)
const aesKeyBase64 = btoa(unescape(encodeURIComponent(aesKey)));
// 将字符串转为ArrayBuffer
const dataBuffer = new TextEncoder().encode(aesKeyBase64);
// RSA加密
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: 'RSA-OAEP',
},
publicKey,
dataBuffer,
);
// 转为Base64字符串
const encryptedBase64 = arrayBufferToBase64(encryptedBuffer);
console.log('✅ Web Crypto RSA加密成功,结果长度:', encryptedBase64.length);
return encryptedBase64;
} catch (error) {
console.error('Web Crypto RSA加密失败:', error.message);
return null;
}
}
/**
* 登录数据加密处理
* @param {Object} loginData 登录数据对象
* @returns {Promise<Object>} 包含加密数据和请求头的对象
*/
export async function encryptLoginData(loginData) {
try {
// 1. 生成AES密钥
const aesKey = generateRandomAESKey(32);
console.log('🔑 生成AES密钥:', aesKey);
// 2. 准备完整的登录数据
const fullLoginData = {
tenantId: loginData.tenantId || ENCRYPT_CONFIG.TENANT_ID,
username: loginData.username,
password: loginData.password,
clientId: loginData.clientId || ENCRYPT_CONFIG.CLIENT_ID,
grantType: loginData.grantType || 'password',
};
console.log('📋 登录数据:', JSON.stringify(fullLoginData, null, 2));
// 3. AES加密登录数据
const encryptedData = encryptWithAES(JSON.stringify(fullLoginData), aesKey);
console.log('🔐 AES加密后数据长度:', encryptedData.length);
// 4. RSA加密AES密钥 - 异步操作
const encryptedKey = await encryptAESKeyWithRSA(aesKey);
if (!encryptedKey) {
throw new Error('RSA加密AES密钥失败');
}
console.log('🔑 RSA加密后密钥长度:', encryptedKey.length);
// 5. 返回加密结果和请求头
return {
success: true,
encryptedData: encryptedData,
headers: {
'Content-Type': 'application/json;charset=utf-8',
clientid: ENCRYPT_CONFIG.CLIENT_ID,
'encrypt-key': encryptedKey,
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
isToken: 'false',
isEncrypt: 'true',
repeatSubmit: 'false',
},
};
} catch (error) {
console.error('登录数据加密失败:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* 获取加密配置
*/
export function getEncryptConfig() {
return ENCRYPT_CONFIG;
}
+126
View File
@@ -0,0 +1,126 @@
// @ts-nocheck
import CryptoJS from 'crypto-js';
import NodeRSA from 'node-rsa';
// 配置信息
const ENCRYPT_CONFIG = {
// RSA公钥 - 与后端保持一致
RSA_PUBLIC_KEY: 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7h96MCAwEAAQ==',
CLIENT_ID: 'e5cd7e4891bf95d1d19206ce24a7b32e',
TENANT_ID: '000000'
};
/**
* 生成随机AES密钥
* @param {number} length 密钥长度,默认32位
* @returns {string} AES密钥
*/
export function generateRandomAESKey(length = 32) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
/**
* AES加密数据
* @param {string} data 待加密的数据
* @param {string} key AES密钥
* @returns {string} 加密后的数据
*/
export function encryptWithAES(data, key) {
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}
/**
* RSA加密AES密钥
* @param {string} aesKey AES密钥
* @returns {string|null} RSA加密后的AES密钥
*/
export function encryptAESKeyWithRSA(aesKey) {
try {
const pemKey = `-----BEGIN PUBLIC KEY-----\n${ENCRYPT_CONFIG.RSA_PUBLIC_KEY}\n-----END PUBLIC KEY-----`;
const rsaKey = new NodeRSA(pemKey);
rsaKey.setOptions({ encryptionScheme: 'pkcs1' });
// 将AES密钥转为Base64
const aesKeyBase64 = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(aesKey));
// RSA加密
return rsaKey.encrypt(aesKeyBase64, 'base64');
} catch (error) {
console.error('RSA加密失败:', error.message);
return null;
}
}
/**
* 登录数据加密处理
* @param {Object} loginData 登录数据对象
* @returns {Object} 包含加密数据和请求头的对象
*/
export function encryptLoginData(loginData) {
try {
// 1. 生成AES密钥
const aesKey = generateRandomAESKey(32);
console.log('🔑 生成AES密钥:', aesKey);
// 2. 准备完整的登录数据
const fullLoginData = {
tenantId: loginData.tenantId || ENCRYPT_CONFIG.TENANT_ID,
username: loginData.username,
password: loginData.password,
clientId: loginData.clientId || ENCRYPT_CONFIG.CLIENT_ID,
grantType: loginData.grantType || 'password',
};
console.log('📋 登录数据:', JSON.stringify(fullLoginData, null, 2));
// 3. AES加密登录数据
const encryptedData = encryptWithAES(JSON.stringify(fullLoginData), aesKey);
console.log('🔐 AES加密后数据长度:', encryptedData.length);
// 4. RSA加密AES密钥
const encryptedKey = encryptAESKeyWithRSA(aesKey);
if (!encryptedKey) {
throw new Error('RSA加密AES密钥失败');
}
console.log('🔑 RSA加密后密钥长度:', encryptedKey.length);
// 5. 返回加密结果和请求头
return {
success: true,
encryptedData: encryptedData,
headers: {
'Content-Type': 'application/json;charset=utf-8',
clientid: ENCRYPT_CONFIG.CLIENT_ID,
'encrypt-key': encryptedKey,
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
isToken: 'false',
isEncrypt: 'true',
repeatSubmit: 'false',
}
};
} catch (error) {
console.error('登录数据加密失败:', error);
return {
success: false,
error: error.message
};
}
}
/**
* 获取加密配置
*/
export function getEncryptConfig() {
return ENCRYPT_CONFIG;
}
+57 -2
View File
@@ -30,8 +30,13 @@ export const request = (options) => {
if (res.data && res.data.code !== undefined) {
if (res.data.code === 200) {
resolve(res.data);
} else if (res.data.code === 401) {
// 401未授权,跳转到登录页
console.log('🔒 Token失效,自动跳转到登录页');
handleUnauthorized(res.data.msg || '登录已失效,请重新登录');
reject(res.data);
} else {
// 业务错误
// 其他业务错误
uni.showToast({
title: res.data.msg || '请求失败',
icon: 'none',
@@ -41,8 +46,13 @@ export const request = (options) => {
} else {
resolve(res.data);
}
} else if (res.statusCode === 401) {
// HTTP 401未授权
console.log('🔒 HTTP 401未授权,自动跳转到登录页');
handleUnauthorized('登录已失效,请重新登录');
reject(res);
} else {
// HTTP状态码错误
// 其他HTTP状态码错误
uni.showToast({
title: `请求失败: ${res.statusCode}`,
icon: 'none',
@@ -76,6 +86,51 @@ function getToken() {
}
}
// 处理401未授权响应
function handleUnauthorized(message = '登录已失效,请重新登录') {
console.log('🔒 处理401未授权响应:', message);
// 清除登录信息
try {
uni.removeStorageSync('access_token');
uni.removeStorageSync('userInfo');
uni.removeStorageSync('isLoggedIn');
console.log('📤 已清除本地登录信息');
} catch (e) {
console.error('❌ 清除登录信息失败:', e);
}
// 显示提示信息
uni.showToast({
title: message,
icon: 'none',
duration: 2000,
});
// 延迟跳转,确保Toast显示
setTimeout(() => {
// 获取当前页面路径
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const currentPath = '/' + currentPage.route;
// 如果当前不是登录页,则跳转到登录页
if (currentPath !== '/pages/login/index') {
console.log('🔄 从', currentPath, '跳转到登录页');
uni.reLaunch({
url: '/pages/login/index',
fail: (error) => {
console.error('❌ 跳转登录页失败:', error);
// 如果reLaunch失败,尝试其他方式
uni.redirectTo({
url: '/pages/login/index',
});
},
});
}
}, 500);
}
// API方法封装
export const api = {
// 用户登录
+259
View File
@@ -0,0 +1,259 @@
/**
* 全局路由守卫
* 处理页面访问权限控制和登录状态检查
*/
import { userStorage } from './request';
import { userState } from './user';
// 无需登录验证的页面列表
const PUBLIC_PAGES = [
'/pages/login/index',
'/pages/index/index', // 首页作为公开页面,可以访问但功能受限
];
// 登录页路径
const LOGIN_PAGE = '/pages/login/index';
/**
* 检查页面是否为公开页面
* @param {string} path 页面路径
*/
function isPublicPage(path) {
return PUBLIC_PAGES.some((publicPath) => {
// 精确匹配或前缀匹配
return path === publicPath || path.startsWith(publicPath + '?');
});
}
/**
* 获取当前页面路径
*/
function getCurrentPagePath() {
try {
const pages = getCurrentPages();
if (pages.length > 0) {
const currentPage = pages[pages.length - 1];
return '/' + currentPage.route;
}
} catch (e) {
console.error('获取当前页面路径失败:', e);
}
return '';
}
/**
* 执行登录检查和跳转
* @param {string} redirectReason 跳转原因
*/
function redirectToLogin(redirectReason = '请先登录') {
console.log('🔄 重定向到登录页:', redirectReason);
// 清除可能存在的无效登录信息
userStorage.clearLoginInfo();
// 提示用户
uni.showToast({
title: redirectReason,
icon: 'none',
duration: 2000,
});
// 延迟跳转,确保Toast显示
setTimeout(() => {
uni.reLaunch({
url: LOGIN_PAGE,
fail: (error) => {
console.error('跳转登录页失败:', error);
// 如果reLaunch失败,尝试其他方式
uni.redirectTo({
url: LOGIN_PAGE,
});
},
});
}, 500);
}
/**
* 全局页面守卫检查
* 在每个页面的onLoad或onShow中调用
* @param {string} pagePath 页面路径(可选,自动获取当前页面)
*/
export function checkPageAccess(pagePath = null) {
try {
// 获取页面路径
const currentPath = pagePath || getCurrentPagePath();
console.log('🔍 检查页面访问权限:', currentPath);
// 如果是公开页面,允许访问
if (isPublicPage(currentPath)) {
console.log('✅ 公开页面,允许访问');
return true;
}
// 检查登录状态
if (!userStorage.isLoggedIn()) {
console.log('❌ 用户未登录');
redirectToLogin('请先登录');
return false;
}
// 检查Token是否存在
const token = userStorage.getToken();
if (!token) {
console.log('❌ Token不存在');
redirectToLogin('登录信息无效,请重新登录');
return false;
}
// 检查Token是否过期
if (userState.checkTokenExpiration()) {
console.log('❌ Token已过期');
redirectToLogin('登录已过期,请重新登录');
return false;
}
console.log('✅ 页面访问权限验证通过');
return true;
} catch (error) {
console.error('页面访问权限检查失败:', error);
redirectToLogin('系统错误,请重新登录');
return false;
}
}
/**
* 页面导航守卫
* 在页面跳转前进行权限检查
* @param {string} url 目标页面URL
* @param {object} options 跳转选项
*/
export function guardedNavigate(url, options = {}) {
try {
console.log('🚀 准备跳转到:', url);
// 解析URL获取页面路径
const urlPath = url.split('?')[0];
// 如果目标是公开页面,直接跳转
if (isPublicPage(urlPath)) {
return uni.navigateTo({ url, ...options });
}
// 检查登录状态
if (!userStorage.isLoggedIn()) {
redirectToLogin('请先登录后访问');
return false;
}
// 检查Token有效性
const token = userStorage.getToken();
if (!token || userState.checkTokenExpiration()) {
redirectToLogin('登录已失效,请重新登录');
return false;
}
// 执行跳转
return uni.navigateTo({ url, ...options });
} catch (error) {
console.error('页面跳转守卫失败:', error);
uni.showToast({
title: '页面跳转失败',
icon: 'none',
});
return false;
}
}
/**
* Tab页面切换守卫
* @param {string} url Tab页面URL
*/
export function guardedSwitchTab(url) {
try {
console.log('🔄 准备切换Tab到:', url);
const urlPath = url.split('?')[0];
// 如果是公开Tab页面,允许切换
if (isPublicPage(urlPath)) {
return uni.switchTab({ url });
}
// 检查登录状态
if (!userStorage.isLoggedIn() || !userStorage.getToken()) {
redirectToLogin('请先登录');
return false;
}
// 检查Token有效性
if (userState.checkTokenExpiration()) {
redirectToLogin('登录已过期,请重新登录');
return false;
}
return uni.switchTab({ url });
} catch (error) {
console.error('Tab切换守卫失败:', error);
return false;
}
}
/**
* 全局混入方法,用于在页面中自动检查权限
*/
export const routeGuardMixin = {
onLoad() {
// 页面加载时检查权限
this.checkPageAccess();
},
onShow() {
// 页面显示时检查权限(防止后台切换回来时状态失效)
this.checkPageAccess();
},
methods: {
// 在页面中可直接调用的权限检查方法
checkPageAccess() {
return checkPageAccess();
},
// 带权限检查的页面跳转
guardedNavigate(url, options) {
return guardedNavigate(url, options);
},
// 带权限检查的Tab切换
guardedSwitchTab(url) {
return guardedSwitchTab(url);
},
},
};
/**
* 初始化路由守卫
* 在App.vue中调用
*/
export function initRouteGuard() {
console.log('🛡️ 初始化全局路由守卫');
// 页面加载完成后检查当前页面权限
uni.$on('pageLoad', () => {
checkPageAccess();
});
// 监听应用进入前台
uni.$on('onShow', () => {
console.log('📱 应用进入前台,检查登录状态');
checkPageAccess();
});
}
export default {
checkPageAccess,
guardedNavigate,
guardedSwitchTab,
routeGuardMixin,
initRouteGuard,
};
+315
View File
@@ -0,0 +1,315 @@
/**
* 用户管理工具
* 处理用户信息、权限、状态等相关功能
*/
import { userStorage } from './request';
/**
* 用户信息管理
*/
export const userManager = {
/**
* 获取当前用户信息
*/
getCurrentUser() {
const userInfo = userStorage.getUserInfo();
if (!userInfo || !userInfo.user) {
return null;
}
const user = userInfo.user;
const permissions = userInfo.permissions || [];
const roles = userInfo.roles || [];
return {
// 基本信息
userId: user.userId,
userName: user.userName,
nickName: user.nickName || user.userName,
email: user.email,
phoneNumber: user.phoneNumber,
sex: user.sex,
avatar: user.avatar,
// 部门信息
deptId: user.deptId,
deptName: user.dept?.deptName || '',
// 状态信息
status: user.status,
createTime: user.createTime,
loginDate: user.loginDate,
// 权限信息
permissions,
roles,
// 扩展信息
remark: user.remark,
};
},
/**
* 检查用户是否拥有指定权限
* @param {string} permission 权限标识
*/
hasPermission(permission) {
if (!permission) return true;
const userInfo = this.getCurrentUser();
if (!userInfo || !userInfo.permissions) {
return false;
}
return userInfo.permissions.includes(permission);
},
/**
* 检查用户是否拥有指定角色
* @param {string} role 角色标识
*/
hasRole(role) {
if (!role) return true;
const userInfo = this.getCurrentUser();
if (!userInfo || !userInfo.roles) {
return false;
}
return userInfo.roles.some((r) => r.roleKey === role);
},
/**
* 检查用户是否为管理员
*/
isAdmin() {
return this.hasRole('admin') || this.hasRole('super_admin');
},
/**
* 获取用户显示名称
*/
getDisplayName() {
const user = this.getCurrentUser();
if (!user) return '未登录';
return user.nickName || user.userName || '用户';
},
/**
* 获取用户头像URL
* @param {string} defaultAvatar 默认头像URL
*/
getAvatarUrl(defaultAvatar = '/static/default-avatar.png') {
const user = this.getCurrentUser();
if (!user || !user.avatar) {
return defaultAvatar;
}
// 如果是完整URL,直接返回
if (user.avatar.startsWith('http')) {
return user.avatar;
}
// 拼接基础URL
const baseURL =
process.env.NODE_ENV === 'development'
? 'http://localhost:8080'
: 'https://your-api-domain.com';
return baseURL + user.avatar;
},
/**
* 更新用户本地信息
* @param {object} userData 用户数据
*/
updateUserInfo(userData) {
try {
const currentInfo = userStorage.getUserInfo();
if (currentInfo) {
const updatedInfo = {
...currentInfo,
user: {
...currentInfo.user,
...userData,
},
};
uni.setStorageSync('userInfo', JSON.stringify(updatedInfo));
console.log('用户信息已更新');
return true;
}
} catch (e) {
console.error('更新用户信息失败:', e);
}
return false;
},
/**
* 格式化用户信息用于显示
*/
formatUserForDisplay() {
const user = this.getCurrentUser();
if (!user) return null;
return {
name: this.getDisplayName(),
avatar: this.getAvatarUrl(),
department: user.deptName || '未分配部门',
phone: user.phoneNumber || '未设置',
email: user.email || '未设置',
status: user.status === '0' ? '正常' : '停用',
isAdmin: this.isAdmin(),
lastLoginTime: user.loginDate ? new Date(user.loginDate).toLocaleString() : '首次登录',
};
},
};
/**
* 权限检查指令工具
*/
export const permissionUtils = {
/**
* 检查页面访问权限
* @param {string} pagePath 页面路径
*/
checkPagePermission(pagePath) {
// 公开页面,无需权限检查
const publicPages = ['/pages/login/index', '/pages/index/index', '/pages/profile/index'];
if (publicPages.includes(pagePath)) {
return true;
}
// 检查是否已登录
if (!userStorage.isLoggedIn()) {
return false;
}
// 这里可以根据具体业务需求添加页面权限检查逻辑
return true;
},
/**
* 权限控制的页面跳转
* @param {string} url 目标页面URL
* @param {string} permission 所需权限
*/
navigateWithPermission(url, permission = null) {
// 检查登录状态
if (!userStorage.isLoggedIn()) {
uni.showToast({
title: '请先登录',
icon: 'none',
});
uni.reLaunch({
url: '/pages/login/index',
});
return false;
}
// 检查权限
if (permission && !userManager.hasPermission(permission)) {
uni.showToast({
title: '权限不足',
icon: 'none',
});
return false;
}
// 执行跳转
uni.navigateTo({
url,
fail: () => {
uni.showToast({
title: '页面跳转失败',
icon: 'none',
});
},
});
return true;
},
};
/**
* 用户状态管理
*/
export const userState = {
/**
* 检查Token是否即将过期(提前5分钟提醒)
*/
checkTokenExpiration() {
try {
const token = userStorage.getToken();
if (!token) return false;
// 这里可以解析JWT token获取过期时间
// 简化处理:检查登录时间是否超过2小时
const userInfo = userStorage.getUserInfo();
if (userInfo && userInfo.loginTime) {
const loginTime = new Date(userInfo.loginTime).getTime();
const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000; // 2小时
return now - loginTime > twoHours;
}
} catch (e) {
console.error('检查Token过期时间失败:', e);
}
return false;
},
/**
* 刷新Token
*/
async refreshToken() {
try {
// 这里调用刷新Token的API
// const result = await api.refreshToken();
// if (result.code === 200) {
// uni.setStorageSync('access_token', result.data.access_token);
// return true;
// }
console.log('Token刷新功能待实现');
} catch (e) {
console.error('刷新Token失败:', e);
}
return false;
},
/**
* 自动登录检查
*/
autoLoginCheck() {
if (!userStorage.isLoggedIn()) {
return false;
}
// 检查Token是否过期
if (this.checkTokenExpiration()) {
uni.showModal({
title: '登录过期',
content: '您的登录已过期,请重新登录',
showCancel: false,
success: () => {
userStorage.clearLoginInfo();
uni.reLaunch({
url: '/pages/login/index',
});
},
});
return false;
}
return true;
},
};
export default {
userManager,
permissionUtils,
userState,
};