一个简单的后台管理类项目

一、项目概述

这是一个专门用于运营商后台发起靓号审批申请的项目。技术选型:vue3+elementplus+vite+vue-router+pinia.

用到的插件主要有:

  • axios 请求后台数据
  • crypto-js 加解密
  • dayjs 日期处理
  • nanoid 生成 id
  • vue3-print-nb 打印文件插件
  • xlsx 处理 excel 文件
  • pinia-plugin-persistedstate 配合 pinia 实现数据持久化

二、工具文件夹

1.crypto-js 加解密

因为在实际上线后,本项目主要是从另外一个项目跳转登录,在跳转时携带了用户身份标识的参数。而这个参数不能使用明文传递,因此使用 crypto-js 进行了加密,在跳转到本项目后使用 crypto-js 将加密的参数解密处理进行用户身份认证,继而实现登录。在 src/utils 下新建 encipher.js 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import CryptoJS from "crypto-js";

// 加密
export function encrypt(message = "", key = "") {
const srcs = CryptoJS.enc.Utf8.parse(message);
const encrypted = CryptoJS.AES.encrypt(srcs, key);
return encrypted.toString();
}

// 解密
export function decrypt(encrypted = "", key = "") {
const decrypt = CryptoJS.AES.decrypt(encrypted, key);
const decrypted = CryptoJS.enc.Utf8.stringify(decrypt).toString();
return decrypted;
}

2.request.js 封装 axios

在 src/utils 下新建 encipher.js 文件。

因为项目的网络请求调用了多个地方的接口,可以看到我这里封装了多个 request 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 封装 axios 请求模块
*/
import axios from "axios";
import { useUserInfoStore } from "@/store/useUserInfoStore";
import { toRefs } from "vue";
export const request = axios.create({
baseURL: "http://133.0.109.121:31455", // 上云data地址
// baseURL: 'http://192.168.137.63:8081', // 后端测试地址
// baseURL: 'http://localhost:8081', // 本机测试

timeout: 5000, // 单位毫秒
});

export const requestExpress = axios.create({
baseURL: "http://133.0.109.121:31456", // 上云express地址
timeout: 5000, // 单位毫秒
});
export const requestCRM = axios.create({
baseURL: "http://133.0.109.121:31896", // CRM地址
// baseURL: 'http://localhost:8085',
timeout: 5000, // 单位毫秒
});
// 请求拦截器,统一注入token
request.interceptors.request.use((config) => {
const { userInfo } = toRefs(useUserInfoStore());
if (userInfo.value.staffCode) {
config.headers.Authorization = userInfo.value.staffCode;
}
// 在最后必须 return config
return config;
});
// 响应拦截器,剥离一层data数据
request.interceptors.response.use((res) => {
//统一解决后端返回的错误信息
if (res.data.code !== 200) {
return Promise.reject(res.data.message);
}
return res.data;
});

三、路由配置

在 src/router 下新建 index.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import { createRouter, createWebHashHistory } from "vue-router";
import { useUserInfoStore } from "@/store/useUserInfoStore";
import Home from "../views/home.vue";
import { toRefs } from "vue";
import { loginAPI } from "@/api/login";
import { ElMessage } from "element-plus";
import { decrypt } from "@/utils/encipher";
const routes = [
{
path: "/",
name: "Home",
component: Home,
redirect: "/NumberApplication",
children: [
// {
// path: '',
// name: 'dashboard',
// hidden: true,
// meta: {
// title: '系统首页',
// permiss: '1'
// },
// component: () =>
// import(/* webpackChunkName: "dashboard" */ '../views/dashboard.vue')
// },
//靓号申请(发起申请页)
{
path: "NumberApplication",
name: "NumberApplication",
meta: {
title: "靓号申请",
permiss: "2",
},
component: () => import("@/views/NumberApplication/index.vue"),
},
//靓号申请(填写表单页)
{
path: "NumberApplication2",
name: "NumberApplication2",
meta: {
title: "填写申请表单",
permiss: "3",
},
component: () => import("@/views/NumberApplication2/index.vue"),
},
//靓号申请3(指定号码)
{
path: "NumberApplication3",
name: "NumberApplication3",
meta: {
title: "查询指定号码",
permiss: "4",
},
component: () => import("@/views/NumberApplication3/index.vue"),
},
//靓号申请4(不指定号码)
{
path: "NumberApplication4",
name: "NumberApplication4",
meta: {
title: "不指定号码查询",
permiss: "5",
},
component: () => import("@/views/NumberApplication4/index.vue"),
},
//虚拟成本管理
{
path: "VirtualCost",
name: "VirtualCost",
meta: {
title: "虚拟成本管理",
permiss: "999",
},
component: () => import("@/views/VirtualCost.vue"),
},
//用户权限管理
{
path: "UserAccessConfig",
name: "UserAccessConfig",
meta: {
title: "用户访问权限管理",
permiss: "999",
},
component: () => import("@/views/UserAccessConfig.vue"),
},
//客户类型管理
{
path: "CustomerTypeConfig",
name: "CustomerTypeConfig",
meta: {
title: "客户类型管理",
permiss: "999",
},
component: () => import("@/views/CustomerTypeConfig.vue"),
},
//补充号码等级
{
path: "AddNumberLevel",
name: "AddNumberLevel",
meta: {
title: "补充号码等级",
permiss: "999",
},
component: () => import("@/views/AddNumberLevel.vue"),
},
],
},

{
path: "/login",
name: "Login",
meta: {
title: "登录",
},
component: () =>
import(/* webpackChunkName: "login" */ "../views/login.vue"),
},
{
path: "/403",
name: "403",
meta: {
title: "没有权限",
},
component: () => import(/* webpackChunkName: "403" */ "../views/403.vue"),
},
{
path: "/:pathMatch(.*)",
name: "404",
meta: {
title: "页面不存在",
},
component: () => import("@/views/404.vue"),
},
];

const router = createRouter({
history: createWebHashHistory(),
routes,
// scrollBehavior: () => ({ top: 0 }) 这里配置滚动条位置并不能生效,因为scrollBehavior配置的只对滚动容器是HTML,也就是整个页面才生效。而该项目的滚动容器并不是整个页面,而是home组件中的一个dom!!!
});
router.beforeEach(async (to, from, next) => {
document.title = `${to.meta.title} | 湖北电信靓号管理系统`;
const { userInfo, getUserInfo, keys } = toRefs(useUserInfoStore());
const staffCode = decrypt(to.query.staffCode);
if (staffCode) {
try {
const { data } = await loginAPI({
password: "111111",
staff_code: staffCode,
});
getUserInfo.value({ staff_code: data.staffCode });
next("/");
} catch {
ElMessage.error("登录失败");
}
}
if (!userInfo.value.staffCode && to.path !== "/login") {
next("/login");
} else if (
to.meta.permiss &&
keys.value &&
!keys.value.includes(to.meta.permiss)
) {
// 如果没有权限,则进入403
next("/403");
} else {
next();
}
});
export default router;

这里值得注意的点有:

1.页面滚动条

在路由这里我想通过 scrollBehavior 来控制页面的滚动行为,以达到无论上一个页面滚动条是否在顶部,在切换页面(路由)后,页面始终置顶的效果。

image-20240312105629967

正如我在代码注释中所写,如果我将 scrollBehavior 配置在路由中,那么它针对的将是整个页面即 app.vue(包含 header、菜单栏 sider-bar、以及主体显示部分 home),这样配置的话只有在一级路由切换时滚动条才会生效。而我想要的是只在 home 页切换时生效即可。

因此,在 home.vue 中配合路由后置守卫实现滚动条置顶效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<v-header />
<v-sidebar />
<div class="content-box" :class="{ 'content-collapse': sidebar.collapse }">
<v-tags></v-tags>
<!-- 这个才是真正的滚动容器,需要在这里配合路由守卫去控制滚动条的位置!!! -->
<div class="content" ref="scrollContainer">
<router-view v-slot="{ Component }" :key="key">
<transition name="move" mode="out-in">
<!-- <keep-alive :include="tags.nameList"> -->
<component :is="Component" :key="route.fullPath"></component>
<!-- </keep-alive> -->
</transition>
</router-view>
</div>
</div>
</template>
<script setup>
import { useSidebarStore } from '../store/sidebar'
// import { useTagsStore } from '../store/tags'
import vHeader from '../components/header.vue'
import vSidebar from '../components/sidebar.vue'
import vTags from '../components/tags.vue'
import { ref, computed } from 'vue'
import router from '@/router/index.js'
import { useRoute } from 'vue-router'
const sidebar = useSidebarStore()
// const tags = useTagsStore()
const scrollContainer = ref()
// 控制滚动条的位置
router.afterEach((to, from) => {
scrollContainer.value.scrollTop = 0
})
const route = useRoute()
const key = computed(() => {
return route.path + Math.random()
})
</script>

2.路由前置守卫拦截逻辑

这里的拦截逻辑是:

  • 如果是从另一个项目携带用户名跳转而来,则使用该用户名和默认密码’111111’进行登录,登录成功,获取用户信息存储到 store 中。从而实现跨一个平台免登录的效果。
  • 如果 store 中没有存储用户信息,说明用户未登录
  • 每个页面具有对应的权限等级标识,通过 meta 中的 permiss 标识。在 store 中简单的将用户角色分为了管理员和普通用户两种(当然也可以根据需求进行多样化的调整),通过 store 中存储的用户信息判断该用户是普通用户还是管理员。这样就可以在路由中判断用户是否具有要去的页面的权限了。

由此引出 store 中的 useUserInfo.js

四、store 中存放用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { getUserInfoAPI } from "@/api/login.js";
import { getRegion } from "@/api/NumberApplication.js";
import { ElMessage } from "element-plus";
import router from "@/router/index.js";
import { verifyPageAccessAPI } from "@/api/login.js";
export const useUserInfoStore = defineStore(
"userInfo",
() => {
const userInfo = ref({});
// 获取用户信息
const getUserInfo = async (value) => {
const userRegionInfo = {
new_region_id: "",
region_id: "",
region_name: "",
};
const { data } = await getUserInfoAPI(value);
const result = await getRegion({ staff_code: value.staff_code });
// 如果工号以hb开头,先用部门去匹配,如果匹配不到,则region_id为1000
if (value.staff_code.startsWith("HB")) {
const temp = result.data.find((item) => {
return item.region_name === data[0].dept;
});
if (!temp) {
userRegionInfo.region_id = 1000;
userRegionInfo.new_region_id = 0;
userRegionInfo.region_name = "其他";
} else {
userRegionInfo.new_region_id = temp.new_region_id
? temp.new_region_id
: 0;
userRegionInfo.region_id = temp.region_id;
userRegionInfo.region_name = temp.region_name;
}
} else {
userRegionInfo.new_region_id = result.data[0].new_region_id;
userRegionInfo.region_name = result.data[0].region_name;
userRegionInfo.region_id = result.data[0].region_id;
}
//userAccessList就是用户所拥有的权限列表
const userAccessList = await verifyPageAccessAPI({
staff_code: value.staff_code,
});
// 目前的逻辑是如果该用户同时拥有菜单配置menu_id为3031和审批人配置menu_id为3032的权限,那么他就是管理员
const hasAccess1 = userAccessList.data.some(
(item) => item.menu_id === "3031"
);
const hasAccess2 = userAccessList.data.some(
(item) => item.menu_id === "3032"
);
const isAdmin = hasAccess1 && hasAccess2;
userInfo.value = {
...data[0],
staffCode: value.staff_code,
...userRegionInfo,
isAdmin,
};
ElMessage.success("登录成功");
router.replace("/");
};
// 更改用户信息
const setUserInfo = (value) => {
userInfo.value = value;
};
// 清除用户信息
const clearUserInfo = () => {
userInfo.value = {};
};
const defaultList = ref({
admin: [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"999", //这是虚拟成本的权限标识
],
user: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
});
//在此表中的人员都是管理员
console.log(userInfo.value.isAdmin, "111");

const keys = computed(
() => defaultList.value[userInfo.value.isAdmin ? "admin" : "user"]
);
console.log(keys);
const handleSet = (val) => {
keys.value = val;
};

return {
userInfo,
getUserInfo,
setUserInfo,
clearUserInfo,
keys,
defaultList,
handleSet,
};
},
{
// persist:true
persist: {
storage: sessionStorage, //localStorage
},
}
);
  • 可以看到这里定义了获取用户信息、清除用户信息的方法。可以在登录和退出登录时调用。其中的用户信息可以在项目需要的时候随时随地方便获取,同时还定义了修改用户信息的方法,不过它一般应该只用来将一些信息添加到用户信息身上。
  • 本项目目前只有管理员和普通用户两种角色,他们被定义在 defaultList 中的 admin 和 user 中,通过用户信息的简单判断来对应 defaultList 中的 admin 和 user。再结合路由守卫,这样他们就分别有了管理员和普通用户的页面权限。当然这里的角色定义和角色判断可以结合后端灵活实现和扩展。
  • 由于使用了 pinia-plugin-persistedstate 插件,可以直接通过 persist 配置选项中的 storage 将数据持久化.注意如果直接给 true 默认使用的是 localStorage。

五、自定义指令和组件

自定义指令放在 src/directives/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useUserInfoStore } from "@/store/useUserInfoStore.js";
import { toRefs } from "vue";
// 自定义权限指令
export const permissDirective = {
install(app) {
const { keys } = toRefs(useUserInfoStore());
app.directive("permiss", {
mounted(el, binding) {
if (keys.value && !keys.value.includes(String(binding.value))) {
el["hidden"] = true;
}
},
});
},
};

自定义组件放在 src/components 中,并最终通过 index.js 进行统一导出

1
2
3
4
5
6
7
8
9
10
11
12
import TaskDeal from "./TaskDeal.vue";
import TaskDetail from "./TaskDetail.vue";
import TaskDetailDid from "./TaskDetailDid.vue";
import TaskDealNew from "./TaskDealNew.vue";
export default {
install(app) {
app.component("TaskDeal", TaskDeal);
app.component("TaskDetail", TaskDetail);
app.component("TaskDetailDid", TaskDetailDid);
app.component("TaskDealNew", TaskDealNew);
},
};

最后别忘了在 main.js 中注册使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { createApp } from "vue";
import { createPinia } from "pinia";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
import App from "./App.vue";
import router from "./router/index.js";
import "element-plus/dist/index.css";
import "./assets/css/icon.css";
// 引入pinia数据持久化插件
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import { permissDirective } from "@/directives/index.js";
import Components from "@/components/index.js";
import axios from "axios";
import print from "vue3-print-nb";

const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
pinia.use(piniaPluginPersistedstate);
app.use(router);
app.use(permissDirective);
// 注册elementplus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.config.globalProperties.$http = axios;

app.use(Components).use(print);
app.mount("#app");

六、用户信息脱敏处理

​ 对于像客户的电话号码、姓名、身份证号这些敏感数据,需要进行脱敏后再显示,为此可在 src/utils 下新建 sensitiveDataHandle.js 进行数据脱敏处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 信息脱敏处理
// beginLen:开始脱敏的字符下标; endLen:结束脱敏的字符下标; str:需要脱敏的字符串; max:掩码最大的重复数量,若不传,则默认为20
export function getStr(beginLen, endLen, str, max = 20) {
// substr(begin,length),begin:需要截取的字符串的下标,必须是数值,若为负数,则从字符串最后一位向左数;
// length:需要截取的字符串长度,若不写此参数,则返回从开始位置到结束的所有字符
const firstStr = str.substr(0, beginLen);
// 结束脱敏的字符下标是否为0,为0则返回空,不为0则返回截取的字符
const lastStr = endLen == 0 ? "" : str.substr(endLen);
// Math.max(x,y,z,...,n),返回x,y,z,...,n中的最大值
// Math.abs(x),返回x的绝对值
// Math.min(x,y,z,...,n),返回x,y,z,...,n中的最小值
// 开始脱敏的下标 加上 结束脱敏的下标是否小于字符串总长度
let repeatNum = Math.max(0, str.length - (beginLen + Math.abs(endLen)));
// 若传了最大掩码重复数,则取数值小的那个
repeatNum = Math.min(max, repeatNum);
// 字符串复制指定次数,语法: string.repeat(count), count:设置需要重复的次数
const middleStr = "*".repeat(repeatNum); // 掩码要重复的数量
return firstStr + middleStr + lastStr;
}

七、客户类别互斥多选

原先客户类别选择框只是简单的多选,比如我可以在所有的选项中随意的选取几项。但是其实选项里面是有分组和互斥关系的,比如市州政府、事业单位客户和省级政府、事业单位客户和省级政府、事业单位关键人这三个应该是一组的,只能从这组中选择一个。基于此又提出了更高的需求:要求选项之间分组,选择时互斥的多选

image-20240312151003640

我将客户类型数据定义在了 src/constant/customerType.js 的常量中,用的时候直接引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
//四级手机靓号客户类型
// export const customerTypeLvl4 = [
// { label: '市州政府、事业单位客户', value: '市州政府、事业单位客户' },
// { label: '县市级别关键人', value: '县市级别关键人' },
// { label: '政企团购80户以上关键人', value: '政企团购80户以上关键人' },
// { label: '年收入超过30万客户', value: '年收入超过30万客户' },
// { label: '产数项目超100万客户', value: '产数项目超100万客户' },
// { label: '省级政府、事业单位客户', value: '省级政府、事业单位客户' },
// { label: '市州级别关键人', value: '市州级别关键人' },
// { label: '政企团购150户以上关键人', value: '政企团购150户以上关键人' },
// { label: '年收入超过100万客户', value: '年收入超过100万客户' },
// { label: '产数项目超300万客户', value: '产数项目超300万客户' },
// { label: '省级政府、事业单位关键人', value: '省级政府、事业单位关键人' },
// { label: '重点大型企业集团客户关键人', value: '重点大型企业集团客户关键人' },
// { label: '政企团购300户以上关键人', value: '政企团购300户以上关键人' },
// { label: '年收入超过200万客户', value: '年收入超过200万客户' },
// { label: '产数项目超500万客户', value: '产数项目超500万客户' }
// ]
// //五级手机靓号客户类型
// export const customerTypeLvl5 = [
// { label: '省级政府、事业单位客户', value: '省级政府、事业单位客户' },
// { label: '市州级别关键人', value: '市州级别关键人' },
// { label: '政企团购150户以上关键人', value: '政企团购150户以上关键人' },
// { label: '年收入超过100万客户', value: '年收入超过100万客户' },
// { label: '产数项目超300万客户', value: '产数项目超300万客户' },
// { label: '省级政府、事业单位关键人', value: '省级政府、事业单位关键人' },
// { label: '重点大型企业集团客户关键人', value: '重点大型企业集团客户关键人' },
// { label: '政企团购300户以上关键人', value: '政企团购300户以上关键人' },
// { label: '年收入超过200万客户', value: '年收入超过200万客户' },
// { label: '产数项目超500万客户', value: '产数项目超500万客户' }
// ]
// //六级手机靓号客户类型
// export const customerTypeLvl6 = [
// { label: '省级政府、事业单位关键人', value: '省级政府、事业单位关键人' },
// { label: '重点大型企业集团客户关键人', value: '重点大型企业集团客户关键人' },
// { label: '政企团购300户以上关键人', value: '政企团购300户以上关键人' },
// { label: '年收入超过200万客户', value: '年收入超过200万客户' },
// { label: '产数项目超500万客户', value: '产数项目超500万客户' }
// ]

export const customerTypeLvl4 = [
{
label: "1",
options: [
{
label: "市州政府、事业单位客户",
value: "市州政府、事业单位客户",
state: false,
},
{
label: "省级政府、事业单位客户",
value: "省级政府、事业单位客户",
state: false,
},
{
label: "省级政府、事业单位关键人",
value: "省级政府、事业单位关键人",
state: false,
},
],
},
{
label: "2",
options: [
{ label: "县市级别关键人", value: "县市级别关键人", state: false },
{ label: "市州级别关键人", value: "市州级别关键人", state: false },
{
label: "重点大型企业集团客户关键人",
value: "重点大型企业集团客户关键人",
state: false,
},
],
},
{
label: "3",
options: [
{
label: "政企团购80户以上关键人",
value: "政企团购80户以上关键人",
state: false,
},
{
label: "政企团购150户以上关键人",
value: "政企团购150户以上关键人",
state: false,
},
{
label: "政企团购300户以上关键人",
value: "政企团购300户以上关键人",
state: false,
},
],
},
{
label: "4",
options: [
{
label: "年收入超过30万客户",
value: "年收入超过30万客户",
state: false,
},
{
label: "年收入超过100万客户",
value: "年收入超过100万客户",
state: false,
},
{
label: "年收入超过200万客户",
value: "年收入超过200万客户",
state: false,
},
],
},
{
label: "5",
options: [
{
label: "产数项目超100万客户",
value: "产数项目超100万客户",
state: false,
},
{
label: "产数项目超300万客户",
value: "产数项目超300万客户",
state: false,
},
{
label: "产数项目超500万客户",
value: "产数项目超500万客户",
state: false,
},
],
},
];
//五级手机靓号客户类型
export const customerTypeLvl5 = [
{
label: "1",
options: [
{ label: "省级政府、事业单位客户", value: "省级政府、事业单位客户" },
{ label: "省级政府、事业单位关键人", value: "省级政府、事业单位关键人" },
],
},
{
label: "2",
options: [
{ label: "市州级别关键人", value: "市州级别关键人" },
{
label: "重点大型企业集团客户关键人",
value: "重点大型企业集团客户关键人",
},
],
},
{
label: "3",
options: [
{ label: "政企团购150户以上关键人", value: "政企团购150户以上关键人" },
{ label: "政企团购300户以上关键人", value: "政企团购300户以上关键人" },
],
},
{
label: "4",
options: [
{ label: "年收入超过100万客户", value: "年收入超过100万客户" },
{ label: "年收入超过200万客户", value: "年收入超过200万客户" },
],
},
{
label: "5",
options: [
{ label: "产数项目超300万客户", value: "产数项目超300万客户" },
{ label: "产数项目超500万客户", value: "产数项目超500万客户" },
],
},
];
//六级手机靓号客户类型
export const customerTypeLvl6 = [
{ label: "省级政府、事业单位关键人", value: "省级政府、事业单位关键人" },
{ label: "重点大型企业集团客户关键人", value: "重点大型企业集团客户关键人" },
{ label: "政企团购300户以上关键人", value: "政企团购300户以上关键人" },
{ label: "年收入超过200万客户", value: "年收入超过200万客户" },
{ label: "产数项目超500万客户", value: "产数项目超500万客户" },
];

在具体的业务代码中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<template>
<!-- 只在手机靓号四级、五级、六级时显示 -->
<el-form-item
label="客户类型"
prop="customerType"
v-if="
selectFormData.level_name === '四级手机靓号' ||
selectFormData.level_name === '五级手机靓号' ||
selectFormData.level_name === '六级手机靓号'
"
>
<el-select
style="width: 250px"
v-model="selectFormData.customerType"
placeholder="请选择"
multiple
collapse-tags
collapse-tags-tooltip
@change="selectChange"
>
<template v-if="selectFormData.level_name === '六级手机靓号'">
<el-option
v-for="item in customerTypeList"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</template>
<template v-else>
<el-option-group
v-for="group in customerTypeList"
:key="group.label"
:label="group.label"
>
<el-option
v-for="item in group.options"
:key="item.value"
:label="item.label"
:value="item.value"
:disabled="item.state"
/>
</el-option-group>
</template>
</el-select>
</el-form-item>
</template>
<script setup>
const customerTypeList = ref([])
watchEffect(() => {
if (selectFormData.value.level_name === '四级手机靓号') {
customerTypeList.value = customerTypeLvl4
} else if (selectFormData.value.level_name === '五级手机靓号') {
customerTypeList.value = customerTypeLvl5
} else if (selectFormData.value.level_name === '六级手机靓号') {
customerTypeList.value = customerTypeLvl6
} else {
customerTypeList.value = []
}
const selectChange = (val) => {
if (val.length === 0) {
customerTypeList.value.forEach((item) =>
item.options.forEach((ele) => (ele.state = false))
)
}
const tempList = ref([])
customerTypeList.value.forEach((item) => {
const tempArr = item.options.find((ele) => {
return ele.label === val[val.length - 1]
})
if (tempArr) {
tempList.value = item.options
}
})
tempList.value.forEach((item) => {
if (item.label !== val[val.length - 1]) {
item.state = true
}
})
}
</script>

这里我为了实现组内互斥效果,在选项上加了一个 state 字段控制选项的可选状态。当本组内已经有选项被选择时,则将该组内其他选项的可选状态禁用。同时不影响其他组的可选状态。具体逻辑在 selectChange 函数中实现。

八、文件上传下载功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
<template>
<!-- 上传附件区域 -->
<div class="container" style="margin-bottom: 10px">
<div class="title">
<span>上传附件</span>
</div>
<el-upload
v-model:file-list="fileList"
ref="uploadRef"
class="upload-demo"
:on-remove="handleRemove"
:on-change="changeFile"
:http-request="upload"
action="#"
:auto-upload="false"
multiple
style="display: flex; margin-bottom: 10px"
>
<template #trigger>
<el-button type="primary" :icon="Document" style="margin-right: 150px"
>选择文件</el-button
>
</template>
<template #tip v-if="!tempList.length">
<div class="el-upload__tip" style="margin: 10px 0 0 -216px">
未选择任何文件
</div>
</template>
<el-button
class="ml-3"
:icon="Upload"
type="success"
@click="handleUpload"
style="margin-top: 2px"
>
上传
</el-button>
</el-upload>
<span style="font-size: 14px; color: #606266">附件列表</span>
<el-table
:data="fileTableData"
border
class="table"
header-cell-class-name="table-header"
highlight-current-row
>
<el-table-column
prop="uid"
label="附件编号"
width="155"
align="center"
></el-table-column>
<el-table-column prop="filename" label="附件名称"> </el-table-column>
<el-table-column prop="size" label="附件大小"> </el-table-column>
<el-table-column label="操作" width="220" align="center">
<template #default="scope">
<el-button
type="warning"
size="small"
:icon="Delete"
@click="handleDel(scope.$index, scope.row)"
>
删除
</el-button>
<el-button
type="primary"
class="red"
size="small"
:icon="Download"
@click="handleDownload(scope.$index, scope.row)"
>
下载
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
// 文件上传
// 文件上传列表
const fileTableData = ref([]);
onMounted(() => {
if (applyForm.fileTableData) {
fileTableData.value = applyForm.fileTableData;
}
});
const fileList = ref([]);
const uploadRef = ref();
const tempList = ref([]);
// file就是要删除的file
const handleRemove = (file) => {};
// 不能够一味 的进行push 因为该函数会被多次调用 fileList其实就是当前最新的文件列表
const changeFile = (file) => {
// params参数中的file就是要上传的文件
// 文件类型不限制
// const isIMAGE = (file.raw.type === 'image/jpeg' || file.raw.type === 'image/png' || file.raw.type === 'image/gif')
const isLt25M = file.size / 1024 / 1024 < 25;
// 文件大小限制
if (!isLt25M) {
return ElMessage.error("上传文件大小不能超过25MB!");
}
const reader = new FileReader();
reader.readAsDataURL(file.raw);
reader.onload = function () {
// console.log('文件的base64数据', this.result)// 文件的base64数据

const fileInstance = {
filename: "",
size: "",
filenameHandler: "",
fileContent: "",
username: applyForm.staff_name,
nodeCode: "100",
uid: "",
};
const milliseconds = getCurrentMilliseconds();
// console.log('milliseconds', milliseconds)
fileInstance.filename = file.name;
fileInstance.size = file.size;
fileInstance.filenameHandler = milliseconds + file.name;
fileInstance.fileContent = this.result.split(",")[1];
fileInstance.uid = file.uid;
tempList.value.push(fileInstance);
};
};
// 后端要求filename加前缀New做新旧平台存储文件的区分
//tempFileListTableData是给最后提交时用的
const tempFileListTableData = computed(() => {
return fileTableData.value.map((item) => {
return {
filename: item.filename,
size: item.size,
filenameHandler: "New" + item.filenameHandler,
fileContent: item.fileContent,
username: item.username,
nodeCode: item.nodeCode,
uid: item.uid,
};
});
});
//tempFileList是给上传按钮用的
const tempFileList = computed(() => {
return tempList.value.map((item) => {
return {
filename: item.filename,
size: item.size,
filenameHandler: "New" + item.filenameHandler,
fileContent: item.fileContent,
username: item.username,
nodeCode: item.nodeCode,
uid: item.uid,
};
});
});

const upload = () => {};
const handleUpload = async () => {
try {
await uploadAPI({ fileList: tempFileList.value });
fileTableData.value.push(...tempList.value);
ElMessage.success("上传成功");
tempFileListTableData.value = [];
tempList.value = [];
fileList.value = [];
} catch {
ElMessage.error("上传失败");
}

uploadRef.value.submit();
};
</script>

九、其他

剩下的就是按照业务需求开发了

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2023-2025 congtianfeng
  • 访问人数: | 浏览次数: