Compare commits

..

24 Commits

Author SHA1 Message Date
张成
2c265bc93c 1 2026-04-15 15:26:17 +08:00
张成
d7c07ecb77 1 2026-04-15 15:23:01 +08:00
张成
6cd965a336 1 2026-04-15 15:05:36 +08:00
张成
3f27c350e2 1 2026-04-13 14:31:40 +08:00
张成
602fed8f0c 1 2026-04-13 14:27:40 +08:00
张成
a6db441c4f 11 2026-04-13 14:10:40 +08:00
张成
45f6f330c6 1 2026-04-13 13:25:54 +08:00
张成
daa6a46af6 1 2026-04-13 13:21:48 +08:00
张成
51ff5d5d65 1 2026-04-13 11:54:27 +08:00
张成
992238f352 1 2026-04-13 11:45:01 +08:00
张成
c73afd2325 1 2026-04-13 11:16:22 +08:00
75149f994f 1 2026-03-30 21:08:24 +08:00
25f3e8e989 1 2026-03-29 18:25:02 +08:00
张成
ae4e342106 1 2026-03-27 16:59:41 +08:00
张成
27dcf1e9b3 1 2026-03-26 11:26:37 +08:00
张成
2b3b9748e1 1 2026-03-26 11:23:57 +08:00
张成
885f86bcc9 1 2026-03-26 11:04:18 +08:00
张成
b13fabb370 1 2026-03-25 11:28:45 +08:00
张成
c3623d4a95 1 2026-03-24 11:18:40 +08:00
张成
6de4936012 1 2026-03-24 11:10:14 +08:00
张成
c3b003304f 1 2026-02-10 14:10:04 +08:00
张成
997565a9d1 Merge branch 'main' of https://git.light120.com/zc/admin_core 2026-02-10 14:06:35 +08:00
张成
83e22cc032 1 2026-02-10 14:06:33 +08:00
张成
862f20e7e2 1 2026-02-08 19:25:30 +08:00
33 changed files with 2028 additions and 2842 deletions

1106
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,781 +0,0 @@
# Admin Framework 使用说明
一个基于 Vue2 的通用后台管理系统框架,包含完整的系统功能、登录、路由管理、布局等核心功能。
## 📦 框架特性
### ✨ 核心功能
-**简化的 API** - 只需调用 `createApp()` 即可完成所有初始化
-**模块化设计** - 组件、路由、状态管理等功能按模块组织
-**完整的系统管理页面** - 用户、角色、菜单、日志等管理
-**登录和权限管理** - 完整的登录流程和权限控制
-**动态路由管理** - 基于权限菜单的动态路由生成
-**Vuex 状态管理** - 用户、应用状态管理
-**全局组件库** - Tables、Editor、Upload、TreeGrid、FieldRenderer、FloatPanel 等
-**工具库** - HTTP、日期、Token、Cookie 等工具
-**内置样式** - base.less、animate.css、iconfont 等
-**响应式布局** - 支持移动端适配
### 🎯 内置页面组件
- **主页组件** (`HomePage`) - 欢迎页面,显示系统标题
- **系统管理页面** (`SysUser`, `SysRole`, `SysLog`, `SysParamSetup`)
- **高级管理页面** (`SysMenu`, `SysControl`, `SysTitle`)
- **登录页面** (`LoginPage`)
- **错误页面** (`Page401`, `Page404`, `Page500`)
### 🛠️ 内置工具
- **HTTP 工具** (`http`) - 封装了 axios支持拦截器、文件上传下载
- **UI 工具** (`uiTool`) - 删除确认、树形转换、响应式设置、文件下载
- **通用工具** (`tools`) - 日期格式化、UUID 生成、Cookie 操作、深拷贝等
- **文件下载** - 支持 CSV 等格式的文件下载,自动处理换行符
## 🚀 快速开始
### 方式一:使用 Demo 项目(推荐)
我们提供了一个完整的 demo 项目,可以直接运行查看效果:
```bash
# 1. 进入 demo 项目
cd demo
# 2. 安装依赖
npm install
# 3. 启动开发服务器
npm run dev
```
浏览器会自动打开 `http://localhost:8080`,查看:
- `/login` - 登录页面
- `/home` - 主页
- `/system/user` - 用户管理
- `/ball/games` - 业务示例页面
### 方式二:构建框架
```bash
# 1. 安装依赖
npm install
# 2. 构建框架
npm run build
# 3. 产物在 dist/admin-framework.js
```
## 🎯 极简使用方式
### 只需 3 步即可完成集成!
#### 1. 引入框架
```javascript
import AdminFramework from './admin-framework.js'
```
#### 2. 创建应用
```javascript
const app = AdminFramework.createApp({
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: {
'business/product': ProductComponent,
'business/order': OrderComponent
}
})
```
#### 3. 挂载应用
```javascript
app.$mount('#app')
```
**就这么简单!** 框架会自动完成所有初始化工作。
## 📖 完整使用指南
### 1. 项目结构准备
```
your-project/
├── src/
│ ├── config/
│ │ └── index.js # 配置文件
│ ├── libs/
│ │ └── admin-framework.js # 框架文件
│ ├── views/
│ │ └── business/ # 业务页面
│ ├── api/
│ │ └── business/ # 业务 API
│ ├── App.vue
│ └── main.js
├── package.json
└── webpack.config.js
```
### 2. 安装依赖
```bash
npm install vue vue-router vuex view-design axios dayjs js-cookie vuex-persistedstate
```
### 3. 创建配置文件
`src/config/index.js` 中:
```javascript
module.exports = {
title: '你的系统名称',
homeName: '首页',
apiUrl: 'http://localhost:9090/admin_api/',
uploadUrl: 'http://localhost:9090/admin_api/upload',
cookieExpires: 7,
uploadMaxLimitSize: 10,
oss: {
region: 'oss-cn-shanghai',
accessKeyId: 'your-key',
accessKeySecret: 'your-secret',
bucket: 'your-bucket',
url: 'http://your-bucket.oss-cn-shanghai.aliyuncs.com',
basePath: 'your-path/'
}
}
```
### 4. 创建 main.js新版本 - 推荐)
```javascript
import AdminFramework from './libs/admin-framework.js'
// 导入业务组件(根据权限菜单接口的 component 字段)
import GamesComponent from './views/ball/games.vue'
import PayOrdersComponent from './views/order/pay_orders.vue'
// 🎉 只需一行代码!框架自动完成所有初始化
const app = AdminFramework.createApp({
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: {
'ball/games': GamesComponent,
'order/pay_orders': PayOrdersComponent
// 添加更多业务组件...
},
onReady() {
console.log('应用已启动!')
// 应用启动完成后的回调
}
})
// 挂载应用
app.$mount('#app')
```
### 5. 创建 App.vue
```vue
<template>
<div id="app">
<router-view/>
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
```
## 🔧 API 使用指南
### 框架实例方法
#### createApp(config) - 推荐使用
创建应用实例(新版本 API
```javascript
const app = AdminFramework.createApp({
title: '我的管理系统', // 应用标题(必需)
apiUrl: 'http://localhost:9098/admin_api/', // API 基础地址(必需)
uploadUrl: 'http://localhost:9098/admin_api/upload', // 上传地址(可选,默认为 apiUrl + 'upload'
componentMap: { // 业务组件映射(可选)
'business/product': ProductComponent,
'business/order': OrderComponent
},
onReady() { // 应用启动完成回调(可选)
console.log('应用已启动!')
}
})
```
### 工具库使用
#### HTTP 工具
```javascript
// 在组件中使用
export default {
async mounted() {
// GET 请求
const res = await this.$http.get('/api/users', { page: 1 })
// POST 请求
const result = await this.$http.post('/api/users', { name: 'test' })
// 文件导出
await this.$http.fileExport('/api/export', { type: 'excel' })
}
}
// 在非 Vue 组件中使用
import AdminFramework from './libs/admin-framework.js'
const res = await AdminFramework.http.get('/api/users')
```
#### UI 工具
```javascript
// 在组件中使用
export default {
methods: {
handleDelete() {
// 删除确认
this.$uiTool.delConfirm(() => {
// 执行删除逻辑
})
// 设置响应式字体
this.$uiTool.setRem()
// 树形转换
const treeData = this.$uiTool.transformTree(flatData)
}
}
}
```
#### 功能工具
```javascript
// 在组件中使用
export default {
methods: {
downloadFile() {
// 文件下载
this.$uiTool.downloadFile(response, 'filename.csv')
}
}
}
```
#### 通用工具
```javascript
// 在组件中使用
export default {
methods: {
formatDate() {
// 日期格式化
return this.$tools.formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
},
generateId() {
// UUID 生成
return this.$tools.generateUUID()
},
setCookie() {
// Cookie 操作
this.$tools.setCookie('name', 'value')
const value = this.$tools.getCookie('name')
}
}
}
```
### Store 模块使用
#### user 模块
```javascript
// 登录
await this.$store.dispatch('user/handleLogin', {
userFrom: { username, password },
Main: AdminFramework.Main,
ParentView: AdminFramework.ParentView,
Page404: AdminFramework.Page404
})
// 登出
this.$store.dispatch('user/handleLogOut')
// 设置权限菜单
this.$store.dispatch('user/setAuthorityMenus', {
Main: AdminFramework.Main,
ParentView: AdminFramework.ParentView,
Page404: AdminFramework.Page404
})
// 获取用户信息
const userName = this.$store.getters['user/userName']
const token = this.$store.state.user.token
```
#### app 模块
```javascript
// 设置面包屑
this.$store.commit('app/setBreadCrumb', route)
// 获取系统标题
this.$store.dispatch('app/getSysTitle', {
defaultTitle: '系统名称',
defaultLogo: '/logo.png'
})
// 获取系统配置
const sysFormModel = this.$store.getters['app/sysFormModel']
```
## 🗂️ 组件映射配置
### 业务组件映射
当后端权限菜单接口返回组件路径时,需要配置映射表:
```javascript
// 1. 导入业务组件
import GamesComponent from './views/ball/games.vue'
import PayOrdersComponent from './views/order/pay_orders.vue'
// 2. 配置映射
const componentMap = {
'ball/games': GamesComponent,
'ball/games.vue': GamesComponent, // 支持带 .vue 后缀
'order/pay_orders': PayOrdersComponent,
'order/pay_orders.vue': PayOrdersComponent
}
// 3. 在 Vue.use 时传入
Vue.use(AdminFramework, {
config,
ViewUI,
VueRouter,
Vuex,
createPersistedState,
componentMap // 传入组件映射表
})
```
### 框架已自动映射的系统组件
以下组件**无需配置**,框架已自动映射:
-`home/index` - 主页
-`system/sys_user` - 用户管理
-`system/sys_role` - 角色管理
-`system/sys_log` - 日志管理
-`system/sys_param_setup` - 参数设置
-`system/sys_menu` - 菜单管理
-`system/sys_control` - 控制器管理
-`system/sys_title` - 系统标题设置
## 🌐 全局访问
### window.framework
框架实例会自动暴露到全局,可以在任何地方访问:
```javascript
// 在非 Vue 组件中使用
const http = window.framework.http
const uiTool = window.framework.uiTool
const config = window.framework.config
// HTTP 请求
const res = await window.framework.http.get('/api/users')
// UI 工具
window.framework.uiTool.delConfirm(() => {
// 删除逻辑
})
```
### Vue 原型方法
在 Vue 组件中可以直接使用:
```javascript
export default {
methods: {
async loadData() {
// 直接使用 this.$xxx
const res = await this.$http.get('/api/users')
this.$uiTool.delConfirm(() => {})
this.$tools.formatDate(new Date())
this.$uiTool.downloadFile(response, 'file.csv')
}
}
}
```
## 📁 文件下载功能
### 使用 downloadFile 方法
框架提供了便捷的文件下载功能,支持 CSV 等格式:
```javascript
// 在 Vue 组件中使用
export default {
methods: {
// 导出数据
exportData() {
// 调用 API 获取数据
this.$http.fileExport('/api/export', params).then(res => {
// 使用 downloadFile 下载
this.$uiTool.downloadFile(res, '数据导出.csv')
this.$Message.success('导出成功!')
}).catch(error => {
this.$Message.error('导出失败:' + error.message)
})
}
}
}
```
### 支持的数据格式
- **CSV 格式**:自动处理换行符,保持表格格式
- **Blob 对象**:支持二进制文件下载
- **文本数据**:支持纯文本文件下载
### 自动处理特性
-**换行符保持**CSV 文件的换行符会被正确保持
-**文件名处理**:自动清理文件名中的特殊字符
-**浏览器兼容**:支持所有现代浏览器
-**内存管理**:自动清理临时 URL 对象
## 🎨 全局组件使用
### FloatPanel - 浮动面板组件
`FloatPanel` 是一个浮动在父窗体上的面板组件,类似于抽屉效果,常用于详情展示、表单编辑等场景。
**基本使用:**
```vue
<template>
<div>
<Button @click="showPanel">打开浮动面板</Button>
<FloatPanel
ref="floatPanel"
title="详情面板"
position="right"
:show-back="true"
back-text="返回"
@back="handleBack"
>
<div>这里是面板内容</div>
</FloatPanel>
</div>
</template>
<script>
export default {
methods: {
showPanel() {
// 通过 ref 调用 show 方法显示面板
this.$refs.floatPanel.show()
},
hidePanel() {
// 通过 ref 调用 hide 方法隐藏面板
this.$refs.floatPanel.hide()
},
handleBack() {
console.log('返回按钮被点击')
this.hidePanel()
}
}
}
</script>
```
**属性说明:**
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `title` | String | `''` | 面板标题 |
| `width` | String/Number | `'100%'` | 面板宽度(字符串或数字),默认占满父容器 |
| `height` | String/Number | `'100%'` | 面板高度(字符串或数字),默认占满父容器 |
| `position` | String | `'right'` | 面板位置:`left``right``top``bottom``center` |
| `showBack` | Boolean | `true` | 是否显示返回按钮 |
| `showClose` | Boolean | `false` | 是否显示关闭按钮 |
| `backText` | String | `'返回'` | 返回按钮文字 |
| `closeOnClickBackdrop` | Boolean | `false` | 点击遮罩是否关闭 |
| `mask` | Boolean | `false` | 是否显示遮罩(默认不显示) |
| `zIndex` | Number | `1000` | 层级 |
**方法:**
| 方法 | 说明 | 参数 |
|------|------|------|
| `show(callback)` | 显示面板 | `callback`: 可选的回调函数 |
| `hide()` | 隐藏面板 | - |
**事件:**
| 事件 | 说明 | 参数 |
|------|------|------|
| `back` | 点击返回按钮时触发 | - |
**插槽:**
| 插槽 | 说明 |
|------|------|
| `default` | 面板主体内容 |
| `header-right` | 头部右侧内容(可用于添加自定义按钮) |
**位置说明:**
- `left`: 从左侧滑入
- `right`: 从右侧滑入(默认)
- `top`: 从顶部滑入
- `bottom`: 从底部滑入
- `center`: 居中显示,带缩放动画
**完整示例:**
```vue
<template>
<div>
<Button @click="openDetailPanel">查看详情</Button>
<FloatPanel
ref="detailPanel"
title="用户详情"
position="right"
:show-back="true"
:show-close="true"
back-text="返回"
@back="handleBack"
>
<template #header-right>
<Button type="primary" @click="handleSave">保存</Button>
</template>
<div class="detail-content">
<Form :model="formData" :label-width="100">
<FormItem label="用户名">
<Input v-model="formData.username" />
</FormItem>
<FormItem label="邮箱">
<Input v-model="formData.email" />
</FormItem>
</Form>
</div>
</FloatPanel>
</div>
</template>
<script>
export default {
data() {
return {
formData: {
username: '',
email: ''
}
}
},
methods: {
openDetailPanel() {
this.$refs.detailPanel.show()
},
handleBack() {
this.$refs.detailPanel.hide()
},
handleSave() {
// 保存逻辑
console.log('保存数据', this.formData)
this.$Message.success('保存成功')
this.$refs.detailPanel.hide()
}
}
}
</script>
```
**特性说明:**
- ✅ 基于父元素定位,不会遮挡菜单
- ✅ 宽度和高度默认 100%,占满父容器
- ✅ 无遮罩背景,完全浮在父页面上
- ✅ 路由切换或组件销毁时自动关闭
- ✅ 支持多种位置和动画效果
- ✅ 支持自定义头部右侧内容
## 📝 业务开发示例
### 创建业务页面
```vue
<!-- src/views/business/product.vue -->
<template>
<div>
<h1>产品管理</h1>
<Button @click="loadData">加载数据</Button>
<Tables :columns="columns" :data="list" />
</div>
</template>
<script>
export default {
data() {
return {
list: [],
columns: [
{ title: 'ID', key: 'id' },
{ title: '名称', key: 'name' },
{ title: '价格', key: 'price' }
]
}
},
async mounted() {
await this.loadData()
},
methods: {
async loadData() {
// 使用框架提供的 http 工具
const res = await this.$http.get('/product/list', { page: 1 })
this.list = res.data
},
async handleDelete(id) {
// 使用框架提供的 UI 工具
this.$uiTool.delConfirm(async () => {
await this.$http.post('/product/delete', { id })
this.$Message.success('删除成功')
await this.loadData()
})
}
}
}
</script>
```
### 创建业务 API
```javascript
// src/api/business/productServer.js
// 注意:不需要 import http直接使用 http
class ProductServer {
async getList(params) {
return await http.get('/product/list', params)
}
async save(data) {
return await http.post('/product/save', data)
}
async delete(id) {
return await http.post('/product/delete', { id })
}
async exportCsv(params) {
return await http.fileExport('/product/export', params)
}
}
export default new ProductServer()
```
## ❓ 常见问题
### Q1: 打包后文件太大怎么办?
A: 框架已经将 Vue、VueRouter、Vuex、ViewUI、Axios 设置为外部依赖,不会打包进去。确保在项目中单独安装这些依赖。
### Q2: 如何只使用部分功能?
A: 可以按需导入:
```javascript
import { http, uiTool, tools } from './libs/admin-framework.js'
```
### Q3: 权限菜单中的业务页面显示 404 怎么办?
A: 需要配置组件映射表:
```javascript
Vue.use(AdminFramework, {
// ... 其他配置
componentMap: {
'ball/games': GamesComponent,
'order/pay_orders': PayOrdersComponent
}
})
```
### Q4: 如何自定义配置?
A: 修改 `config/index.js` 文件:
```javascript
module.exports = {
title: '你的系统名称',
apiUrl: 'http://your-api-url/',
// ... 其他配置
}
```
### Q5: 如何使用登录功能?
A: 在组件中:
```javascript
export default {
methods: {
async login() {
await this.$store.dispatch('user/handleLogin', {
userFrom: { username: 'admin', password: '123456' },
Main: AdminFramework.Main,
ParentView: AdminFramework.ParentView,
Page404: AdminFramework.Page404
})
this.$router.push({ name: 'home' })
}
}
}
```
### Q6: 需要单独引入样式文件吗?
A: **不需要!** 框架已内置所有样式:
-`base.less` - 基础样式
-`animate.css` - 动画样式
-`ivewExpand.less` - ViewUI 扩展样式
-`iconfont.css` - 字体图标样式
只需引入框架即可:
```javascript
import AdminFramework from './libs/admin-framework.js'
Vue.use(AdminFramework, { ... })
```
## 📦 技术栈
- Vue 2.6+
- Vue Router 3.x
- Vuex 3.x
- View Design (iView) 4.x
- Axios
- Less
- Webpack 5
## 📄 许可证
MIT License
## 👨‍💻 作者
light
---
**祝开发愉快!** 🎉
如有问题,请查看 Demo 项目示例或联系开发团队。

File diff suppressed because it is too large Load Diff

View File

@@ -1,325 +0,0 @@
# AdminFramework 快速开始
## 🚀 3 分钟上手
### 第一步:引入框架
```javascript
import AdminFramework from './admin-framework.js'
```
### 第二步:创建应用
```javascript
const app = AdminFramework.createApp({
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: {
'business/product': ProductComponent,
'business/order': OrderComponent
}
})
```
### 第三步挂载应用1
```javascript
app.$mount('#app')
```
## ✨ 就这么简单!
**完整代码只需 12 行:**
## 📁 文件下载功能
框架内置了便捷的文件下载功能:
```javascript
// 在组件中使用
export default {
methods: {
exportData() {
// 调用 API 获取数据
this.$http.fileExport('/api/export', params).then(res => {
// 下载文件(自动处理换行符)
this.$uiTool.downloadFile(res, '数据导出.csv')
})
}
}
}
```
**特性:**
- ✅ 自动处理 CSV 换行符
- ✅ 支持多种文件格式
- ✅ 浏览器兼容性好
```javascript
// main.js
import AdminFramework from './admin-framework.js'
import ProductComponent from './views/business/product.vue'
import OrderComponent from './views/business/order.vue'
const app = AdminFramework.createApp({
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: {
'business/product': ProductComponent,
'business/order': OrderComponent
}
})
app.$mount('#app')
```
## 📋 组件映射表配置
`componentMap` 用于定义业务页面的动态路由。
**重要提示:** 只需传递不带 `.vue` 后缀的路径,框架会自动处理带后缀和不带后缀的两种情况!
```javascript
// 直接在 createApp 中配置
const app = AdminFramework.createApp({
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: {
// ✅ 正确:只传递不带 .vue 的路径
'business/product': ProductComponent,
'business/order': OrderComponent,
'system/user': UserComponent,
// ❌ 错误:不要同时传递带和不带 .vue 的路径(多余)
// 'business/product.vue': ProductComponent, // 框架会自动添加
}
})
```
**说明:** 框架内部会自动为每个组件创建两个映射:
- `'business/product'` → ProductComponent
- `'business/product.vue'` → ProductComponent自动添加
所以后台菜单配置 `business/product``business/product.vue` 都可以正常工作!
## ⚙️ 配置说明
### 必填参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `title` | 应用标题 | `'我的管理系统'` |
| `apiUrl` | API 基础地址 | `'http://localhost:9098/admin_api/'` |
| `componentMap` | 组件映射表 | 见上方示例 |
### 可选参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `uploadUrl` | 上传文件地址 | `apiUrl + 'upload'` |
| `onReady` | 应用启动回调 | - |
## 🎯 完整示例
```javascript
import AdminFramework from './admin-framework.js'
import componentMap from './router/component-map.js'
const app = AdminFramework.createApp({
// 必填配置
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: componentMap,
// 可选配置
uploadUrl: 'http://cdn.example.com/upload', // 可选,默认为 apiUrl + 'upload'
// 应用启动完成回调
onReady() {
console.log('应用已启动!')
console.log('当前登录用户:', this.$store.state.user)
}
})
app.$mount('#app')
```
## 📦 框架内置功能
使用 `createApp()` 后,框架会自动提供:
### 1. 内置页面
- ✅ 登录页面 (`/login`)
- ✅ 主页 (`/home`)
- ✅ 404 页面 (`/404`)
- ✅ 401 页面 (`/401`)
- ✅ 500 页面 (`/500`)
### 2. 系统管理页面
- ✅ 用户管理 (`/system/user`)
- ✅ 角色管理 (`/system/role`)
- ✅ 日志管理 (`/system/log`)
- ✅ 参数设置 (`/system/param`)
- ✅ 菜单管理 (`/system/menu`)
- ✅ 权限控制 (`/system/control`)
- ✅ 标题设置 (`/system/title`)
### 3. 全局组件
-`<Tables>` - 增强型表格
-`<UploadSingle>` - 单图上传
-`<UploadMultiple>` - 多图上传
-`<TreeGrid>` - 树形表格
-`<AsyncModal>` - 异步弹窗
-`<Editor>` - 富文本编辑器
-`<CommonIcon>` - 图标选择器
-`<FloatPanel>` - 浮动面板
### 4. 工具方法
在任何组件中可以使用:
```javascript
// HTTP 请求
this.$http.get('/api/users')
this.$http.post('/api/users', data)
// UI 工具
this.$uiTool.showLoading()
this.$uiTool.hideLoading()
this.$uiTool.showSuccess('操作成功')
// 通用工具
this.$tools.formatDate(new Date())
this.$tools.deepClone(obj)
// 文件下载
this.$uiTool.downloadFile(response, 'filename.xlsx')
// 配置信息
this.$config.title
this.$config.apiUrl
this.$config.uploadUrl
// 状态管理
this.$store.state.user.token
this.$store.state.app.sysTitle
```
## 🎨 自定义页面
### 1. 创建页面组件
```vue
<!-- views/business/product-list.vue -->
<template>
<div>
<Tables :columns="columns" :data="list" />
</div>
</template>
<script>
export default {
data() {
return {
list: [],
columns: [
{ title: '产品名称', key: 'name' },
{ title: '价格', key: 'price' }
]
}
},
created() {
this.loadData()
},
methods: {
async loadData() {
const res = await this.$http.get('/products')
this.list = res.data
}
}
}
</script>
```
### 2. 添加到组件映射表
```javascript
// router/component-map.js
import ProductList from '../views/business/product-list.vue'
export default {
'business/product': ProductList // 路径对应后台菜单的 component 字段
}
```
### 3. 在后台配置菜单
在"菜单管理"中添加菜单项:
- 菜单名称: `产品列表`
- 路由路径: `/business/product`
- 组件路径: `business/product`(对应 componentMap 的 key
## 📝 常见问题
### Q1: uploadUrl 如何配置?
**A:** 默认情况下无需配置,框架会自动设置为 `apiUrl + 'upload'`
如果需要自定义(如使用 CDN可以手动传入
```javascript
AdminFramework.createApp({
apiUrl: 'http://localhost:9098/admin_api/',
uploadUrl: 'http://cdn.example.com/upload' // 自定义上传地址
})
```
### Q2: 如何访问框架实例?
**A:** 框架实例会自动暴露到全局:
```javascript
// 浏览器控制台
window.framework // 框架实例
window.app // Vue 实例
window.rootVue // Vue 实例(别名)
```
### Q3: 如何添加自定义 Vuex 模块?
**A:** 使用旧的 `install()` 方式:
```javascript
import AdminFramework from './admin-framework.js'
AdminFramework.install(Vue, {
config: { ... },
ViewUI,
VueRouter,
Vuex,
customModules: {
myModule: myModuleConfig
}
})
```
### Q4: 框架文件太大怎么办?
**A:** 新版框架3.6 MB内置了所有依赖使用更方便。如果需要减小文件大小可以使用旧的 `install()` 方式,手动引入依赖。
## 🔗 相关文档
- [简化使用说明.md](./简化使用说明.md) - 详细的使用说明和对比
- [README.md](./README.md) - 项目介绍和特性
- [demo/src/main.js](./demo/src/main.js) - 完整示例代码
## 💡 最佳实践
1. **推荐使用 `createApp()`** - 代码更简洁,减少 77% 的代码量
2. **组件映射表单独管理** - 方便维护和扩展
3. **利用内置组件** - 如 `<Tables>``<UploadSingle>` 等,提高开发效率
4. **使用 `onReady` 回调** - 在应用启动后执行初始化逻辑
## 🎉 开始开发吧!
现在你已经了解了 AdminFramework 的快速使用方法,开始构建你的管理系统吧!

View File

@@ -240,13 +240,11 @@ const config = {
## 开发建议
1. **开发时使用 build:dev**
- 生成 sourcemap方便调试
- 代码不压缩,易读
1. **框架产物**
- 在仓库根目录执行 `npm run build`,生成 `dist/admin-framework.js`UMD、压缩
2. **生产时使用 build**
- 代码压缩,体积小
- 无 sourcemap安全
2. **调试**
- 需要跟源码时可在业务工程中直接引用框架 `src/index.js` 并配置 Webpack或使用 `window.framework` / `window.rootVue`
3. **使用浏览器调试工具**
```javascript

View File

@@ -255,7 +255,7 @@ export default {
this.gridOption.param.pageOption.total = res.data.count
},
async showAddWarp() {
this.$refs.editModal.addShow({ 'venue_type': 'indoor', 'surface_type': 'hard', 'court_count': '1', 'status': 'active', 'create_time': 'CURRENT_TIMESTAMP', 'updated_at': 'CURRENT_TIMESTAMP', }, async (newRow) => {
this.$refs.editModal.addShow({ 'venue_type': 'indoor', 'surface_type': 'hard', 'court_count': '1', 'status': 'active' }, async (newRow) => {
let res = await venuesServer.add(newRow)
this.$Message.success('新增成功!')
this.init()

View File

@@ -316,7 +316,7 @@ export default {
this.gridOption.param.pageOption.total = res.data.count
},
async showAddWarp() {
this.$refs.editModal.addShow({ 'is_subscribed': '0', 'last_login_time': 'CURRENT_TIMESTAMP', 'create_time': 'CURRENT_TIMESTAMP', 'updated_at': 'CURRENT_TIMESTAMP', }, async (newRow) => {
this.$refs.editModal.addShow({ 'is_subscribed': '0', 'last_login_time': 'CURRENT_TIMESTAMP' }, async (newRow) => {
let res = await wch_usersServer.add(newRow)
this.$Message.success('新增成功!')
this.init()

26
package-lock.json generated
View File

@@ -28,7 +28,6 @@
"@babel/preset-env": "^7.12.0",
"autoprefixer": "^10.4.21",
"babel-loader": "^8.2.0",
"cross-env": "^10.1.0",
"css-loader": "^5.0.0",
"file-loader": "^6.2.0",
"less": "^4.0.0",
@@ -1814,13 +1813,6 @@
"node": ">=10.0.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2849,24 +2841,6 @@
"@cropper/utils": "^2.0.1"
}
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@@ -5,10 +5,8 @@
"main": "dist/admin-framework.js",
"scripts": {
"build": "webpack --mode production",
"build:dev": "cross-env NODE_ENV=development webpack --mode production",
"dev": "webpack --mode development --watch",
"serve": "npm run build && npx http-server -p 8080 -o /demo/index.html",
"serve:dev": "npm run build:dev && npx http-server -p 8080 -o /demo/index.html"
"serve": "npm run build && npx http-server -p 8080 -o /demo/index.html"
},
"keywords": [
"admin",
@@ -45,7 +43,6 @@
"@babel/preset-env": "^7.12.0",
"autoprefixer": "^10.4.21",
"babel-loader": "^8.2.0",
"cross-env": "^10.1.0",
"css-loader": "^5.0.0",
"file-loader": "^6.2.0",
"less": "^4.0.0",

View File

@@ -18,6 +18,7 @@ export { default as modelFieldServer } from './modelFieldServer'
export { default as modelServer } from './modelServer'
export { default as paramSetupServer } from './paramSetupServer'
export { default as sysControlTypeServer } from './sysControlTypeServer'
export { default as sysTenantServer } from './sysTenantServer'

View File

@@ -0,0 +1,22 @@
import http from '@/utils/http'
class SysTenantServer {
async list() {
return http.get('/sys_tenant/index', {})
}
async add(row) {
return http.post('/sys_tenant/add', row)
}
async edit(row) {
return http.post('/sys_tenant/edit', row)
}
async del(row) {
return http.post('/sys_tenant/del', row)
}
}
const sysTenantServer = new SysTenantServer()
export default sysTenantServer

View File

@@ -6,10 +6,6 @@
z-index: 999999;
}
.ivu-select-dropdown-list {
height: 200px !important;
overflow: auto;
}
.float-right {
float: right;
@@ -114,6 +110,9 @@
}
.table-warp {
flex: 1;
display: flex;
flex-direction: column;
.ivu-card-body {
height: 100%;
display: flex;
@@ -190,20 +189,21 @@
text-overflow: ellipsis;
}
/* 自适应表格宽度 */
/* 与 View Design 一致:横向/纵向滚动由 .ivu-table-body 上的 overflow 类控制,避免 wrapper 与整表同高导致横向条在页面最底 */
.ivu-table-wrapper {
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: auto;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ivu-table {
width: 100% !important;
flex: 1;
min-height: 0;
}
/* 移除 table-layout: auto使用 iView 默认的 fixed 布局以确保列对齐 */
@@ -212,8 +212,8 @@
} */
.ivu-table-body {
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* 按钮风格一致 */

View File

@@ -5,14 +5,24 @@
height: 64px;
display: flex;
flex-direction: row;
align-items: center;
.custom-content-con {
height: 64px;
padding-right: 120px;
line-height: 64px;
display: inline-block;
vertical-align: top;
.header-breadcrumb {
margin-left: 30px;
flex: 1;
min-width: 0;
overflow: hidden;
line-height: 64px;
}
.header-right-slot {
flex-shrink: 0;
height: 64px;
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
padding-right: 10px;
line-height: 64px;
}
}

View File

@@ -1,8 +1,8 @@
<template>
<div class="header-bar">
<sider-trigger :collapsed="collapsed" icon="md-menu" @on-change="handleCollpasedChange"></sider-trigger>
<custom-bread-crumb show-icon style="margin-left: 30px;" :list="breadCrumbList"></custom-bread-crumb>
<div class="custom-content-con">
<custom-bread-crumb class="header-breadcrumb" show-icon :list="breadCrumbList"></custom-bread-crumb>
<div class="header-right-slot">
<slot></slot>
</div>
</div>

View File

@@ -19,7 +19,7 @@ export default {
computed: {
iconclass() {
let curClass = 'terminal-icon ml10'
let curClass = 'terminal-icon'
// Terminal 功能暂时禁用
// if (this.isServerRun) {
// curClass += ' run'
@@ -39,6 +39,8 @@ export default {
<style lang="less" scoped>
.terminal-box {
margin-top: 3px;
margin-right: 10px;
flex-shrink: 0;
}
.terminal-icon {
color: #c5c8ce;

View File

@@ -3,7 +3,7 @@
<label class="loginName-box">{{userName}}</label>
<Dropdown>
<Avatar :src="typeof userAvator === 'string' ? userAvator : userIcon" />
<Avatar :src="avatarSrc" />
<Icon :size="18" type="md-arrow-dropdown"></Icon>
<DropdownMenu slot="list">
@@ -21,7 +21,7 @@
<script>
import './user.less'
import { mapMutations, mapActions } from 'vuex'
import { mapActions } from 'vuex'
const userIcon = require("@/assets/images/administrato.png").default
@@ -39,6 +39,23 @@ export default {
userIcon: userIcon
}
},
computed: {
/** 接口无头像或空串时用本地默认图,避免 Avatar 空 src 显示灰色块 */
avatarSrc() {
const v = this.userAvator
if (v != null && typeof v === 'string' && v.trim() !== '') {
const s = v.trim()
if (/^https?:\/\//i.test(s)) {
return s
}
if (this.$http && typeof this.$http.ImgSrc === 'function') {
return this.$http.ImgSrc(s)
}
return s
}
return this.userIcon
},
},
methods: {
...mapActions('user', ['handleLogOut']),
logout() {
@@ -55,9 +72,9 @@ export default {
</script>
<style lang="less" scoped>
.user-avator-dropdown {
position: absolute;
top: 0px;
right: 10px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.loginName-box {

View File

@@ -16,8 +16,15 @@
<Header class="header-con-main">
<headerBar class="header-con" @on-coll-change="collpasedChange" :collapsed="collapsed">
<Terminal></Terminal>
<user :userName="userName" :user-avator="userAvator || ''" />
<div class="header-user-area">
<Terminal></Terminal>
<span
v-if="currentTenant"
class="main-tenant-tag"
:title="'租户:' + currentTenant.name + '' + currentTenant.code + ''"
>{{ currentTenant.name }}</span>
<user :userName="userName" :user-avator="userAvator || ''" />
</div>
</headerBar>
</Header>
<Layout class="main-layout-content">
@@ -42,7 +49,7 @@ import User from './components/user'
import ABackTop from './components/a-back-top'
import Fullscreen from './components/fullscreen'
import Language from './components/language'
import { mapGetters } from 'vuex'
import { mapGetters, mapState } from 'vuex'
import headerBar from './components/header-bar'
import loadFlower from '../load-flower/index'
import Terminal from './components/terminal'
@@ -77,6 +84,7 @@ export default {
userName: 'user/userName',
userAvator: 'user/avatorImgPath'
}),
...mapState('user', ['currentTenant']),
cacheList() {
return ['ParentView']
}
@@ -149,6 +157,30 @@ export default {
z-index: 999999;
}
.header-user-area {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
}
.main-tenant-tag {
margin-right: 12px;
flex-shrink: 0;
padding: 0 10px;
height: 28px;
line-height: 28px;
font-size: 13px;
color: #2d8cf0;
background: rgba(45, 140, 240, 0.08);
border-radius: 4px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-brand {
display: flex;
justify-content: center;

View File

@@ -1,6 +1,7 @@
<template>
<Switch
:value="value"
<!-- iView / View Design 全局注册为 i-switch勿用 <Switch> 与本组件 name 形成自引用 -->
<i-switch
:value="innerValue"
v-bind="$attrs"
@on-change="handleChange"
/>
@@ -8,12 +9,23 @@
<script>
export default {
name: 'Switch',
name: 'FrameworkSwitch',
props: ['value'],
inheritAttrs: false,
computed: {
innerValue() {
const v = this.value
if (v === true || v === 1 || v === '1' || v === 'true') return true
if (v === false || v === 0 || v === '0' || v === 'false') return false
if (v === '' || v === null || v === undefined) return false
return Boolean(v)
}
},
methods: {
handleChange(checked) {
this.$emit('input', checked)
this.$emit('change', checked)
this.$emit('on-change', checked)
}
}
}

View File

@@ -3,7 +3,7 @@
<!-- 使用组件映射表来简化条件渲染 -->
<component
:is="getComponentName(col.com)"
:value="col.com === 'Radio' ? radioValue : value"
:value="inputBindValue"
v-bind="getComponentProps(col)"
:disabled="disabled"
:class="getComponentClass(col.com)"
@@ -58,20 +58,14 @@
<script>
import templateRender from './templateRender'
// 导入框架中的图标配置
let icons = []
try {
icons = require('../../config/icons.json') || []
} catch (e) {
console.warn('未找到图标配置文件,图标选择功能将不可用', e)
icons = []
}
import FrameworkSwitch from '../switch/index.vue'
import { SELECT_ICON_OPTIONS } from './selectIconOptions'
export default {
name: 'FieldRenderer',
components: {
templateRender
templateRender,
FrameworkSwitch
},
props: {
col: {
@@ -89,21 +83,25 @@ export default {
},
data() {
return {
icons: []
icons: SELECT_ICON_OPTIONS
}
},
computed: {
// 直接返回原始值,不进行类型转换
radioValue() {
return this.value
}
},
mounted() {
// 安全处理图标数据
if (Array.isArray(icons)) {
this.icons = icons.map((item) => item.trim ? item.trim() : item)
} else {
this.icons = []
},
inputBindValue() {
if (this.col.com === 'Radio') {
return this.radioValue
}
if (this.col.com === 'Switch') {
const v = this.value
if (v === true || v === 1 || v === '1' || v === 'true') return true
if (v === false || v === 0 || v === '0' || v === 'false') return false
if (v === '' || v === null || v === undefined) return false
return Boolean(v)
}
return this.value
}
},
methods: {
@@ -112,13 +110,13 @@ export default {
const componentMap = {
'Select': 'Select',
'Radio': 'RadioGroup',
'SelectIcon': 'AutoComplete',
'SelectIcon': 'Select',
'UploadSingle': 'UploadSingle',
'TextArea': 'TextArea',
'Input': 'Input',
'DatePicker': 'DatePicker',
'TimePicker': 'TimePicker',
'Switch': 'Switch',
'Switch': 'FrameworkSwitch',
'Checkbox': 'Checkbox',
'Slider': 'Slider',
'Rate': 'Rate',
@@ -151,13 +149,18 @@ export default {
delete baseProps.data_type
delete baseProps.type
delete baseProps.rowStyle
delete baseProps.key
delete baseProps.title
delete baseProps.render
// 根据组件类型添加特定属性
if (col.com === 'Select') {
baseProps.filterable = true
baseProps.clearable = true
} else if (col.com === 'SelectIcon') {
baseProps.icon = 'ios-search'
baseProps.filterable = true
baseProps.clearable = true
baseProps.placeholder = col.placeholder || '搜索或选择图标'
} else if (col.com === 'TextArea') {
baseProps.rows = 4
baseProps.placeholder = '请输入内容'
@@ -237,7 +240,10 @@ export default {
'Input': 'width:100%;',
'TextArea': 'width:100%;',
'DatePicker': 'width:100%;',
'TimePicker': 'width:100%;'
'TimePicker': 'width:100%;',
'SelectIcon': 'width:100%;',
"inputNumber": "width:100%;",
}
return styleMap[componentType] || ''
},
@@ -252,14 +258,17 @@ export default {
this.$emit('input', value)
},
// 处理变化事件
// 处理变化事件i-switch 等只发 on-change需同步 input 供 editModal 更新行)
handleChange(value) {
// 如果接收到的是事件对象,提取值
if (value && typeof value === 'object' && value.target) {
value = value.target.value
}
// 直接传递值,不进行类型转换
this.$emit('change', value)
const out =
this.col.com === 'Switch'
? !!value
: value
this.$emit('input', out)
this.$emit('change', out)
}
}
}

View File

@@ -13,11 +13,24 @@
<a v-if="isDown&&value&&value.length>0" class="link right-link" @click="downExecl">下载</a>
</div>
<Table :class="tbClass" ref="tablesMain" border :data="insideTableData" :width='defaultWidth' :height='defaultHeight' :columns="insideColumns" v-bind="$attrs" @on-selection-change='onSelect'>
<slot></slot>
<slot name="footer" slot="footer"></slot>
<slot name="loading" slot="loading"></slot>
</Table>
<div class="table-main">
<Table
v-bind="$attrs"
:class="tbClass"
ref="tablesMain"
border
:data="insideTableData"
:width="defaultWidth"
:height="defaultHeight"
:max-height="effectiveTableMaxHeight"
:columns="insideColumns"
@on-selection-change="onSelect"
>
<slot></slot>
<slot name="footer" slot="footer"></slot>
<slot name="loading" slot="loading"></slot>
</Table>
</div>
</div>
</div>
@@ -81,6 +94,9 @@ export default {
},
// 动态计算的偏移量
calculatedOffset: null,
/** 传给 iView Table 的 max-heightpx使表体在可视区内滚动横向条始终在视口底部 */
bodyViewportMaxHeight: null,
_tableContentResizeObserver: null,
}
},
@@ -145,6 +161,13 @@ export default {
// 使用手动指定的偏移量
return `calc(100vh - ${this.maxHeightOffset}px)`
},
/** 未传 height 时由容器测量得到,避免宽表横向滚动条落在整表最底部 */
effectiveTableMaxHeight() {
if (this.height) return undefined
if (this.bodyViewportMaxHeight == null) return undefined
return this.bodyViewportMaxHeight
},
},
methods: {
@@ -174,6 +197,7 @@ export default {
// 触发表格重新渲染
this.$refs.tablesMain.$forceUpdate()
}
this.syncTableBodyMaxHeight()
})
},
onChangePage(page) {
@@ -184,6 +208,43 @@ export default {
this.$emit('on-select', selection, row)
},
syncTableBodyMaxHeight() {
if (this.height) {
this.bodyViewportMaxHeight = null
return
}
this.$nextTick(() => {
const wrap = this.$refs.table_content
if (!wrap) return
const titleEl = wrap.querySelector('.table-title')
const titleH = (this.title || this.isDown) && titleEl ? titleEl.offsetHeight : 0
const avail = wrap.clientHeight - titleH
const next = Math.max(160, Math.floor(avail))
if (this.bodyViewportMaxHeight !== next) {
this.bodyViewportMaxHeight = next
}
this.$nextTick(() => {
const inst = this.$refs.tablesMain
if (inst && typeof inst.handleResize === 'function') {
inst.handleResize()
}
})
})
},
setupTableContentResizeObserver() {
const el = this.$refs.table_content
if (!el || typeof ResizeObserver === 'undefined') return
if (this._tableContentResizeObserver) {
this._tableContentResizeObserver.disconnect()
}
const ro = new ResizeObserver(() => {
this.syncTableBodyMaxHeight()
})
ro.observe(el)
this._tableContentResizeObserver = ro
},
// 动态计算表格容器上方所有元素的高度总和
calculateOffset() {
this.$nextTick(() => {
@@ -203,6 +264,9 @@ export default {
// 计算总偏移量
this.calculatedOffset = topOffset + pageBoxHeight + bottomPadding
this.$nextTick(() => {
this.syncTableBodyMaxHeight()
})
})
},
},
@@ -243,6 +307,7 @@ export default {
// 动态计算偏移量
this.calculateOffset()
this.setupTableContentResizeObserver()
// 如果数据或列配置为空,输出警告
if (!this.insideTableData || this.insideTableData.length === 0) {
@@ -255,21 +320,34 @@ export default {
// 监听窗口大小变化,重新计算偏移量
window.addEventListener('resize', this.calculateOffset)
window.addEventListener('resize', this.syncTableBodyMaxHeight)
},
beforeDestroy() {
// 移除窗口大小监听
window.removeEventListener('resize', this.calculateOffset)
window.removeEventListener('resize', this.syncTableBodyMaxHeight)
if (this._tableContentResizeObserver) {
this._tableContentResizeObserver.disconnect()
this._tableContentResizeObserver = null
}
},
}
</script>
<style lang="less" scoped>
.table-warp {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
.table-content {
overflow-y: auto;
/* 纵向由 Table 内部表体滚动;避免与外层双滚动导致横向条在整页最底 */
overflow: hidden;
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.table-title {
font-weight: bold;
@@ -279,10 +357,17 @@ export default {
padding: 0rem 0.15rem;
height: 50px;
line-height: 50px;
flex-shrink: 0;
.right-link {
float: right;
}
}
.table-main {
flex: 1;
min-height: 0;
overflow: hidden;
}
}
.page-box {

View File

@@ -0,0 +1,306 @@
/**
* 菜单/表单「图标」下拉选项(与 View Design Icon 的 type 一致)
* 不依赖 icons.json保证 UMD 打包后无需额外静态资源即可使用
*/
export const SELECT_ICON_OPTIONS = [
'md-add',
'md-add-circle',
'md-alarm',
'md-albums',
'md-alert',
'md-american-football',
'md-analytics',
'md-aperture',
'md-apps',
'md-appstore',
'md-archive',
'md-arrow-back',
'md-arrow-down',
'md-arrow-dropdown',
'md-arrow-dropdown-circle',
'md-arrow-dropleft',
'md-arrow-dropleft-circle',
'md-arrow-dropright',
'md-arrow-dropright-circle',
'md-arrow-dropup',
'md-arrow-dropup-circle',
'md-arrow-forward',
'md-arrow-round-back',
'md-arrow-round-down',
'md-arrow-round-forward',
'md-arrow-round-up',
'md-arrow-up',
'md-at',
'md-attach',
'md-backspace',
'md-barcode',
'md-baseball',
'md-basket',
'md-basketball',
'md-battery-charging',
'md-battery-dead',
'md-battery-full',
'md-beaker',
'md-beer',
'md-bicycle',
'md-bluetooth',
'md-boat',
'md-body',
'md-bonfire',
'md-book',
'md-bookmark',
'md-bookmarks',
'md-bowtie',
'md-briefcase',
'md-browsers',
'md-brush',
'md-bug',
'md-build',
'md-bulb',
'md-bus',
'md-cafe',
'md-calculator',
'md-calendar',
'md-call',
'md-camera',
'md-car',
'md-card',
'md-cart',
'md-cash',
'md-chatboxes',
'md-chatbubbles',
'md-checkbox',
'md-checkbox-outline',
'md-checkmark',
'md-checkmark-circle',
'md-checkmark-circle-outline',
'md-clipboard',
'md-clock',
'md-close',
'md-close-circle',
'md-closed-captioning',
'md-cloud',
'md-cloud-circle',
'md-cloud-done',
'md-cloud-download',
'md-cloud-outline',
'md-cloud-upload',
'md-cloudy',
'md-cloudy-night',
'md-code',
'md-code-download',
'md-code-working',
'md-cog',
'md-color-fill',
'md-color-filter',
'md-color-palette',
'md-color-wand',
'md-compass',
'md-construct',
'md-contact',
'md-contacts',
'md-contract',
'md-contrast',
'md-copy',
'md-create',
'md-crop',
'md-cube',
'md-cut',
'md-desktop',
'md-disc',
'md-document',
'md-done-all',
'md-download',
'md-easel',
'md-egg',
'md-exit',
'md-expand',
'md-eye',
'md-eye-off',
'md-fastforward',
'md-female',
'md-filing',
'md-film',
'md-finger-print',
'md-flag',
'md-flame',
'md-flash',
'md-flask',
'md-flower',
'md-folder',
'md-folder-open',
'md-football',
'md-funnel',
'md-game-controller-a',
'md-game-controller-b',
'md-git-branch',
'md-git-commit',
'md-git-compare',
'md-git-merge',
'md-git-network',
'md-git-pull-request',
'md-glasses',
'md-globe',
'md-grid',
'md-hammer',
'md-hand',
'md-happy',
'md-headset',
'md-heart',
'md-heart-outline',
'md-help',
'md-help-buoy',
'md-help-circle',
'md-home',
'md-ice-cream',
'md-image',
'md-images',
'md-infinite',
'md-information',
'md-information-circle',
'md-ionic',
'md-ionitron',
'md-jet',
'md-key',
'md-keypad',
'md-laptop',
'md-leaf',
'md-link',
'md-list',
'md-list-box',
'md-locate',
'md-lock',
'md-log-in',
'md-log-out',
'md-magnet',
'md-mail',
'md-mail-open',
'md-male',
'md-man',
'md-map',
'md-medal',
'md-medical',
'md-medkit',
'md-megaphone',
'md-menu',
'md-mic',
'md-mic-off',
'md-microphone',
'md-moon',
'md-more',
'md-move',
'md-musical-note',
'md-musical-notes',
'md-navigate',
'md-no-smoking',
'md-notifications',
'md-notifications-off',
'md-notifications-outline',
'md-nuclear',
'md-nutrition',
'md-open',
'md-options',
'md-outlet',
'md-paper',
'md-paper-plane',
'md-partly-sunny',
'md-pause',
'md-paw',
'md-people',
'md-person',
'md-person-add',
'md-phone-landscape',
'md-phone-portrait',
'md-photos',
'md-pie',
'md-pin',
'md-pint',
'md-pizza',
'md-plane',
'md-planet',
'md-play',
'md-podium',
'md-power',
'md-pricetag',
'md-pricetags',
'md-print',
'md-pulse',
'md-qr-scanner',
'md-quote',
'md-radio',
'md-radio-button-off',
'md-radio-button-on',
'md-rainy',
'md-recording',
'md-redo',
'md-refresh',
'md-refresh-circle',
'md-remove',
'md-remove-circle',
'md-reorder',
'md-repeat',
'md-resize',
'md-restaurant',
'md-return-left',
'md-return-right',
'md-reverse-camera',
'md-rewind',
'md-ribbon',
'md-rose',
'md-sad',
'md-school',
'md-search',
'md-send',
'md-settings',
'md-share',
'md-share-alt',
'md-shirt',
'md-shuffle',
'md-skip-backward',
'md-skip-forward',
'md-snow',
'md-speedometer',
'md-square',
'md-square-outline',
'md-star',
'md-star-half',
'md-star-outline',
'md-stats',
'md-stopwatch',
'md-subway',
'md-sunny',
'md-swap',
'md-switch',
'md-sync',
'md-tablet-landscape',
'md-tablet-portrait',
'md-tennisball',
'md-text',
'md-thermometer',
'md-thumbs-down',
'md-thumbs-up',
'md-thunderstorm',
'md-time',
'md-timer',
'md-train',
'md-transgender',
'md-trash',
'md-trending-down',
'md-trending-up',
'md-trophy',
'md-umbrella',
'md-undo',
'md-unlock',
'md-videocam',
'md-volume-down',
'md-volume-mute',
'md-volume-off',
'md-volume-up',
'md-walk',
'md-warning',
'md-watch',
'md-water',
'md-wifi',
'md-wine',
'md-woman'
]

View File

@@ -72,6 +72,7 @@ export default {
.table-scroll-container {
width: 100%;
height: 100%;
overflow: auto;
overflow-y: visible;
/* 横向滚动条始终可见,不需要滚动到底 */

View File

@@ -115,6 +115,17 @@ export const defaultMenus = [
is_show_menu: 1,
icon: 'md-text',
sort: 3
},
{
id: 125,
name: '租户管理',
path: '/system/tenant',
component: 'system/sys_tenant',
parent_id: 120,
type: '页面',
is_show_menu: 1,
icon: 'md-git-branch',
sort: 4
}
]
}

View File

@@ -3,6 +3,16 @@ import uiTool from '../utils/uiTool'
import { defaultMenus, filterMenusByIds } from '../config/menuConfig'
import userServer from '../api/system/userServer'
function readTenantLs() {
try {
const s = localStorage.getItem('currentTenant')
if (!s || s === 'undefined') return null
return JSON.parse(s)
} catch {
return null
}
}
export default {
namespaced: true,
state: {
@@ -10,7 +20,9 @@ export default {
avatorImgPath: '',
token: getToken(),
authorityMenus: [],
menuList: localStorage.getItem('menuList') ? JSON.parse(localStorage.getItem('menuList')) : []
menuList: localStorage.getItem('menuList') ? JSON.parse(localStorage.getItem('menuList')) : [],
/** 登录返回的租户信息:{ id, name, code, is_platform } */
currentTenant: readTenantLs()
},
mutations: {
setAvator(state, avatorPath) {
@@ -31,6 +43,14 @@ export default {
setMenuList(state, menus) {
state.menuList = menus
localStorage.setItem('menuList', JSON.stringify(menus))
},
setCurrentTenant(state, tenant) {
state.currentTenant = tenant && typeof tenant === 'object' ? tenant : null
if (state.currentTenant) {
localStorage.setItem('currentTenant', JSON.stringify(state.currentTenant))
} else {
localStorage.removeItem('currentTenant')
}
}
},
getters: {
@@ -162,6 +182,11 @@ export default {
commit('setUserName', name)
commit('setToken', token)
if (res.data.tenant) {
commit('setCurrentTenant', res.data.tenant)
} else {
commit('setCurrentTenant', null)
}
// 登录接口返回的 authorityMenus 是菜单 ID 数组字符串
console.log('登录返回的菜单 IDs:', authorityMenusIds)
@@ -205,6 +230,7 @@ export default {
commit('setToken', '')
commit('setAuthorityMenus', '[]')
commit('setMenuList', [])
commit('setCurrentTenant', null)
localStorage.removeItem('menuList')
window.location.reload()
}

View File

@@ -1,6 +1,27 @@
import axios from 'axios'
import { formatDate } from './tools'
/** 递归删除对象中值为空字符串的键(不修改数组内的空字符串元素) */
function stripEmptyStringKeys(value) {
if (value === null || value === undefined) {
return value
}
if (Array.isArray(value)) {
return value.map((item) => stripEmptyStringKeys(item))
}
if (typeof value === 'object' && Object.prototype.toString.call(value) === '[object Object]') {
const next = {}
for (const k of Object.keys(value)) {
const v = value[k]
if (v === '') {
continue
}
next[k] = stripEmptyStringKeys(v)
}
return next
}
return value
}
class Http {
constructor() {
@@ -131,13 +152,18 @@ class Http {
}
param = JSON.parse(JSON.stringify(param))
return param
return stripEmptyStringKeys(param)
}
formatFormDataParam(param) {
param = param || {}
let formData = new FormData()
Object.keys(param).forEach(key => {
formData.append(key, param[key])
Object.keys(param).forEach((key) => {
const v = param[key]
if (v === '') {
return
}
formData.append(key, v)
})
return formData
}

View File

@@ -4,6 +4,7 @@ import SysLog from './system/sys_log.vue'
import SysParamSetup from './system/sys_param_setup.vue'
import SysRole from './system/sys_role.vue'
import SysUser from './system/sys_user.vue'
import SysTenant from './system/sys_tenant.vue'
import SysLogOperate from './system/sys_log_operate.vue'
@@ -36,6 +37,7 @@ export function setupComponentMap(customMap = {}, uiTool) {
'system/sys_param_setup': SysParamSetup,
'system/sys_role': SysRole,
'system/sys_user': SysUser,
'system/sys_tenant': SysTenant,
'system/sys_control': SysControl,
'system/sys_menu': SysMenu,
'system/sys_title': SysTitle,
@@ -54,11 +56,13 @@ export function setupComponentMap(customMap = {}, uiTool) {
}
export default {
HomePage,
SysLog,
SysLogOperate,
SysParamSetup,
SysRole,
SysUser,
SysTenant,
SysControl,
SysMenu,
SysTitle,

View File

@@ -9,7 +9,7 @@
</div>
<div class="login-con">
<Card icon="log-in" title="欢迎登录" style="width:350px;height:300px;" :bordered="false">
<Card icon="log-in" title="欢迎登录" style="width:350px;height:260px;" :bordered="false">
<div class="form-con">
<login-form @on-success-valid="handleSubmit"></login-form>
</div>
@@ -45,7 +45,10 @@ export default {
...mapActions('user', ['handleLogin']),
async handleSubmit({ userName, password }) {
try {
let userFrom = { name: userName, password: password }
let userFrom = {
name: userName,
password: password
}
await this.handleLogin({
userFrom,
Main,

View File

@@ -4,7 +4,7 @@
<Button type="primary" @click="addWarp()">新增</Button>
</div>
<div class="table-body">
<TreeGrid :columns="gridOption.columns" :data="gridOption.data"></TreeGrid>
<TreeGrid :columns="gridOption.columns" :data="gridOption.data"></TreeGrid>
</div>
<editModal ref="editModal" :columns="gridOption.editColumns" :rules="gridOption.rules">
<div slot="bottom">
@@ -12,12 +12,12 @@
<fieldItem name='类别'>
<RadioGroup v-model="editRow.type">
<Radio :label="item.key" :key="item.key" v-for="item in typeSource">
{{item.value}}
{{ item.value }}
</Radio>
</RadioGroup>
</fieldItem>
<div v-if="editRow.type==='页面'||editRow.type==='功能'">
<div v-if="editRow.type === '页面' || editRow.type === '功能'">
<fieldItem name='数据模型'>
<Select v-model="editRow.model_id">
<Option v-for="item in modelRows" :value="item.id" :key="item.id">{{ item.value }}</Option>
@@ -33,7 +33,7 @@
</fieldItem>
</div>
<fieldItem name='地址' v-if="editRow.type==='外链'">
<fieldItem name='地址' v-if="editRow.type === '外链'">
<Input v-model="editRow.component" placeholder="请输入网址" />
</fieldItem>
@@ -266,16 +266,18 @@ export default {
async initCol() {
let res = await menuServer.modelAll()
let data = res.data.map((row) => {
let { id, key, name } = row
let value = key
if (name) {
value = value + '-' + name
}
if ( res.data && typeof res.data === 'array') {
let data = res.data.map((row) => {
let { id, key, name } = row
let value = key
if (name) {
value = value + '-' + name
}
return { id, value, key }
})
this.modelRows = [{ id: 0, value: '自定义模板', key: 'custom_template' }, ...data] || []
return { id, value, key }
})
this.modelRows = [{ id: 0, value: '自定义模板', key: 'custom_template' }, ...data] || []
}
},
calculate() {
@@ -400,6 +402,17 @@ export default {
}
</script>
<style>
<style scoped>
.content-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.table-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
</style>

View File

@@ -48,13 +48,6 @@ export default {
this.showEditWarp(params.row)
},
},
{
title: '清空',
type: 'warning',
click: () => {
this.clearConfirm(params.row)
},
}
]
return uiTool.getBtn(h, btns)

View File

@@ -1,5 +1,8 @@
<template>
<div class="content-view">
<Alert type="info" show-icon closable style="margin-bottom: 12px">
角色为<strong>全库共用</strong>不按租户隔离各租户用户可在用户管理中绑定同一角色菜单权限以角色配置为准
</Alert>
<div class="table-head-tool">
<Button type="primary" @click="showAddWarp">新增</Button>
</div>
@@ -73,6 +76,23 @@ export default {
this.init()
},
methods: {
parseRoleMenuIds(menus) {
if (menus == null || menus === '') {
return []
}
if (Array.isArray(menus)) {
return menus.map((id) => Number(id)).filter((id) => !Number.isNaN(id))
}
if (typeof menus === 'string') {
try {
const parsed = JSON.parse(menus)
return this.parseRoleMenuIds(parsed)
} catch {
return []
}
}
return []
},
async init() {
let res = await roleServer.list()
this.gridOption.data = res.data
@@ -120,12 +140,8 @@ export default {
let res = await menuServer.list()
let tree = uiTool.transformTree(res.data)
if (row.menus) {
this.expandTreeAll = JSON.parse(row.menus)
this.treeData = this.mapTree(tree)
} else {
this.treeData = this.mapTree(tree)
}
this.expandTreeAll = this.parseRoleMenuIds(row.menus)
this.treeData = this.mapTree(tree)
this.isShowPermission = true
},
@@ -136,7 +152,7 @@ export default {
p.children = this.mapTree(p.children)
}
let row = this.expandTreeAll.find((p2) => p2 === p.id)
let row = this.expandTreeAll.find((p2) => Number(p2) === Number(p.id))
if (row) {
p.checked = true
}

View File

@@ -0,0 +1,143 @@
<template>
<div class="content-view">
<Alert type="warning" show-icon style="margin-bottom: 12px">
<strong>平台租户</strong>is_platform=1可维护租户列表普通租户登录后本页仅能看到自身租户信息
新增时<strong>租户编码可留空</strong>留空则由服务端自动生成填写则使用自定义编码须唯一需在数据库执行迁移脚本创建 <code>sys_tenant</code> 表及默认数据
</Alert>
<div class="table-head-tool">
<Button type="primary" @click="showAddWarp">新增租户</Button>
</div>
<div class="table-body">
<tables ref="tables" v-model="gridOption.data" :columns="gridOption.columns" />
</div>
<editModal ref="editModal" :columns="gridOption.columns" :data="gridOption.editRow" />
</div>
</template>
<script>
import uiTool from '@/utils/uiTool'
import sysTenantServer from '@/api/system/sysTenantServer'
export default {
name: 'sys_tenant_page',
data() {
return {
gridOption: {
editRow: {},
columns: [
{ title: 'id', key: 'id' },
{ title: '名称', key: 'name', name: 'name', required: true },
{
title: '租户编码',
key: 'code',
name: 'code',
placeholder: '留空则保存时自动生成;填写则使用自定义编码(须唯一)'
},
{ title: '备注', key: 'remark', name: 'remark' },
{
title: '状态',
key: 'status',
name: 'status',
com: 'Radio',
source: [
{ key: 1, value: '启用' },
{ key: 0, value: '停用' }
],
render(h, p) {
return h('span', p.row.status === 1 ? '启用' : '停用')
}
},
{
title: '平台租户',
key: 'is_platform',
name: 'is_platform',
com: 'Switch',
render(h, p) {
return h('span', Number(p.row.is_platform) === 1 ? '是' : '否')
}
},
{
title: '操作',
render: (h, params) => {
if (params.row.id === 1) {
return h('span', { style: { color: '#999' } }, '内置租户')
}
const btns = [
{
title: '修改',
type: 'primary',
click: () => this.showEditWarp(params.row)
},
{
title: '删除',
type: 'primary',
click: () => this.delConfirm(params.row)
}
]
return uiTool.getBtn(h, btns)
}
}
],
data: []
}
}
},
created() {
this.init()
},
methods: {
normalizeTenantRow(row) {
const r = { ...row }
r.is_platform = r.is_platform === true || r.is_platform === 1 || r.is_platform === '1' ? 1 : 0
if (r.code != null && typeof r.code === 'string' && !r.code.trim()) {
delete r.code
}
return r
},
async init() {
const res = await sysTenantServer.list()
if (res && res.code === 0) {
this.gridOption.data = res.data || []
}
},
showAddWarp() {
this.$refs.editModal.addShow(
{ status: 1, is_platform: 0 },
async (row) => {
await sysTenantServer.add(this.normalizeTenantRow(row))
this.$Message.success('新增成功')
this.init()
}
)
},
showEditWarp(row) {
this.$refs.editModal.editShow(row, async (newRow) => {
await sysTenantServer.edit(this.normalizeTenantRow(newRow))
this.$Message.success('修改成功')
this.init()
})
},
delConfirm(row) {
uiTool.delConfirm(async () => {
await sysTenantServer.del(row)
this.$Message.success('删除成功')
this.init()
})
}
}
}
</script>
<style scoped>
.content-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.table-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
</style>

View File

@@ -1,5 +1,9 @@
<template>
<div class="content-view">
<Alert v-if="currentTenant" type="info" show-icon closable style="margin-bottom: 12px">
当前租户<strong>{{ currentTenant.name }}</strong>{{ currentTenant.code }}用户按租户隔离
<strong>角色全库共用</strong>下拉中的角色对所有租户一致
</Alert>
<div class="table-head-tool">
<Button type="primary" @click="showAddWarp">新增</Button>
@@ -16,9 +20,10 @@
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { mapGetters, mapState } from 'vuex'
import userServer from '@/api/system/userServer'
import roleServer from '@/api/system/roleServer'
import sysTenantServer from '@/api/system/sysTenantServer'
import uiTool from '@/utils/uiTool'
export default {
@@ -29,8 +34,21 @@ export default {
gridOption: {
editRow: {},
columns: [
{ title: '登陆名', key: 'name' },
{ title: '登陆名', key: 'name', name: 'name' },
{
title: '租户',
key: 'tenant_id',
name: 'tenant_id',
com: 'Select',
required: true,
render(h, params) {
if (params.row.tenant) {
const t = params.row.tenant
return h('span', `${t.name || ''}${t.code || ''}`)
}
return h('span', params.row.tenant_id != null ? String(params.row.tenant_id) : '')
}
},
{
title: '密码',
key: 'password',
@@ -41,6 +59,7 @@ export default {
{
title: '所属角色',
key: 'roleId',
name: 'roleId',
com: 'Select',
render: (h, params) => {
if (params.row.role) {
@@ -83,6 +102,12 @@ export default {
...mapGetters({
shopList: 'shop/shopList',
}),
...mapState('user', ['currentTenant']),
},
watch: {
currentTenant() {
this.initCol()
},
},
created() {
this.init()
@@ -94,31 +119,53 @@ export default {
this.gridOption.data = res.data
},
async initCol() {
let res = await roleServer.list()
this.roles = res.data
let roleSource = this.roles.map((p) => {
return {
key: p.id,
value: p.name,
}
})
const res = await roleServer.list()
this.roles = res.data || []
const roleSource = this.roles.map((p) => ({
key: p.id,
value: p.name,
}))
let roleRow = this.gridOption.columns.find((p) => p.key === 'roleId')
const roleRow = this.gridOption.columns.find((p) => p.key === 'roleId')
if (roleRow) {
roleRow.source = roleSource
}
const tenantRes = await sysTenantServer.list()
const tenants = tenantRes && tenantRes.code === 0 && Array.isArray(tenantRes.data) ? tenantRes.data : []
const tenantSource = tenants.map((t) => ({
key: Number(t.id),
value: `${t.name || ''}${t.code || ''}`,
}))
const platform = this.currentTenant && Number(this.currentTenant.is_platform) === 1
const tenantCol = this.gridOption.columns.find((p) => p.key === 'tenant_id')
if (tenantCol) {
tenantCol.source = tenantSource
tenantCol.disabled = !platform
tenantCol.disabledOnAdd = !platform
}
},
showAddWarp() {
this.$refs.editModal.addShow({}, async (newRow) => {
await userServer.add(newRow)
const tid = this.currentTenant && this.currentTenant.id != null ? Number(this.currentTenant.id) : undefined
this.$refs.editModal.addShow({ tenant_id: tid }, async (newRow) => {
const payload = { ...newRow }
if (payload.tenant_id != null) {
payload.tenant_id = Number(payload.tenant_id)
}
await userServer.add(payload)
this.$Message.success('新增成功!')
this.init()
})
},
showEditWarp(row) {
this.$refs.editModal.editShow(row, async (newRow) => {
await userServer.edit(newRow)
this.$refs.editModal.editShow({ ...row }, async (newRow) => {
const payload = { ...newRow }
if (payload.tenant_id != null) {
payload.tenant_id = Number(payload.tenant_id)
}
await userServer.edit(payload)
this.$Message.success('修改成功!')
this.init()
})

View File

@@ -1,6 +1,25 @@
const fs = require('fs')
const path = require('path')
const webpack = require('webpack')
/** 构建完成后将根目录 README.md 复制为 dist/admin-framework.md */
function copyReadmeToDistPlugin() {
return {
apply(compiler) {
compiler.hooks.afterEmit.tap('CopyReadmeToDist', () => {
const src = path.join(__dirname, 'README.md')
const dest = path.join(compiler.options.output.path, 'admin-framework.md')
try {
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest)
}
} catch (e) {
console.warn('[webpack] 复制 README.md -> dist/admin-framework.md 失败:', e.message)
}
})
}
}
}
const { VueLoaderPlugin } = require('vue-loader')
const TerserPlugin = require('terser-webpack-plugin')
@@ -142,6 +161,7 @@ module.exports = {
},
plugins: [
new VueLoaderPlugin(),
copyReadmeToDistPlugin(),
new webpack.BannerPlugin({
banner: () => {
const now = new Date()