11. 开发流程
提示
建议自己的业务应用页面和接口都单独放置,不要直接和框架自带页面和接口文件混在一块儿,一是方便框架同步升级,二是业务应用代码独立干净。尽可能的保持框架源码不动,尽量用系统自带的组件,避免过渡封装无法升级
,若感觉框架某处功能必须需改,可以直接提 Pull Request
到开源仓库。https://gitee.com/zuohuaijun/Admin.NET/pulls
页面开发
页面位置
应用页面一般放的位置为: /src/views 文件夹下面。针对具体业务应用,可以根据业务应用名称创建不同的文件夹放置具体业务页面 vue
文件,建议和自带的 system
系统页面保持同一目录级别。每个业务应用页面建议格式如下:
├── XXX(具体业务应用名称)
├── component(存放一些与当前页面相关的组件)
├── editXXX.vue(编辑页面)
├── xxx(其他页面或组件)
├── index.vue(默认当前路由主页)
页面格式
页面格式上面一般为 template
页面布局,中间部分为 script
业务逻辑,底部为 style
样式表。整体代码规范可以参考系统自带页面。
下面为一般主页面格式(系统自带的角色管理页面 角色管理),包括增删改查操作模式、组件引用、变量定义等。
<template>
<div class="sys-role-container">
<el-card shadow="hover" :body-style="{ paddingBottom: '0' }">
<el-form :model="state.queryParams" ref="queryForm" :inline="true">
<el-form-item label="角色名称">
<el-input v-model="state.queryParams.name" placeholder="角色名称" clearable />
</el-form-item>
<el-form-item label="角色编码">
<el-input v-model="state.queryParams.code" placeholder="角色编码" clearable />
</el-form-item>
<el-form-item>
<el-button-group>
<el-button type="primary" icon="ele-Search" @click="handleQuery" v-auth="'sysRole:page'"> 查询 </el-button>
<el-button icon="ele-Refresh" @click="resetQuery"> 重置 </el-button>
</el-button-group>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="ele-Plus" @click="openAddRole" v-auth="'sysRole:add'"> 新增 </el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="full-table" shadow="hover" style="margin-top: 5px">
<el-table :data="state.roleData" style="width: 100%" v-loading="state.loading" border>
<el-table-column type="index" label="序号" width="55" align="center" fixed />
<el-table-column prop="name" label="角色名称" align="center" show-overflow-tooltip />
<el-table-column prop="code" label="角色编码" align="center" show-overflow-tooltip />
<el-table-column label="数据范围" align="center" show-overflow-tooltip>
<template #default="scope">
<el-tag effect="plain" v-if="scope.row.dataScope === 1">全部数据</el-tag>
<el-tag effect="plain" v-else-if="scope.row.dataScope === 2">本部门及以下数据</el-tag>
<el-tag effect="plain" v-else-if="scope.row.dataScope === 3">本部门数据</el-tag>
<el-tag effect="plain" v-else-if="scope.row.dataScope === 4">仅本人数据</el-tag>
<el-tag effect="plain" v-else-if="scope.row.dataScope === 5">自定义数据</el-tag>
</template>
</el-table-column>
<el-table-column prop="orderNo" label="排序" width="70" align="center" show-overflow-tooltip />
<el-table-column label="状态" width="70" align="center" show-overflow-tooltip>
<template #default="scope">
<el-tag type="success" v-if="scope.row.status === 1">启用</el-tag>
<el-tag type="danger" v-else>禁用</el-tag>
</template>
</el-table-column>
<el-table-column label="修改记录" width="100" align="center" show-overflow-tooltip>
<template #default="scope">
<ModifyRecord :data="scope.row" />
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right" align="center" show-overflow-tooltip>
<template #default="scope">
<el-button icon="ele-OfficeBuilding" size="small" text type="primary" @click="openGrantData(scope.row)" v-auth="'sysRole:grantDataScope'"> 数据范围 </el-button>
<el-button icon="ele-Edit" size="small" text type="primary" @click="openEditRole(scope.row)" v-auth="'sysRole:update'"> 编辑 </el-button>
<el-button icon="ele-Delete" size="small" text type="danger" @click="delRole(scope.row)" v-auth="'sysRole:delete'"> 删除 </el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:currentPage="state.tableParams.page"
v-model:page-size="state.tableParams.pageSize"
:total="state.tableParams.total"
:page-sizes="[10, 20, 50, 100]"
small
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
layout="total, sizes, prev, pager, next, jumper"
/>
</el-card>
<EditRole ref="editRoleRef" :title="state.editRoleTitle" @handleQuery="handleQuery" />
<GrantData ref="grantDataRef" @handleQuery="handleQuery" />
</div>
</template>
<script lang="ts" setup name="sysRole">
import { onMounted, reactive, ref } from 'vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import { auth } from '/@/utils/authFunction';
import EditRole from '/@/views/system/role/component/editRole.vue';
import GrantData from '/@/views/system/role/component/grantData.vue';
import ModifyRecord from '/@/components/table/modifyRecord.vue';
import { getAPI } from '/@/utils/axios-utils';
import { SysRoleApi } from '/@/api-services/api';
import { SysRole } from '/@/api-services/models';
const editRoleRef = ref<InstanceType<typeof EditRole>>();
const grantDataRef = ref<InstanceType<typeof GrantData>>();
const state = reactive({
loading: false,
roleData: [] as Array<SysRole>,
queryParams: {
name: undefined,
code: undefined,
},
tableParams: {
page: 1,
pageSize: 20,
total: 0 as any,
},
editRoleTitle: '',
});
onMounted(async () => {
handleQuery();
});
// 查询操作
const handleQuery = async () => {
state.loading = true;
let params = Object.assign(state.queryParams, state.tableParams);
var res = await getAPI(SysRoleApi).apiSysRolePagePost(params);
state.roleData = res.data.result?.items ?? [];
state.tableParams.total = res.data.result?.total;
state.loading = false;
};
// 重置操作
const resetQuery = () => {
state.queryParams.name = undefined;
state.queryParams.code = undefined;
handleQuery();
};
// 打开新增页面
const openAddRole = () => {
state.editRoleTitle = '添加角色';
editRoleRef.value?.openDialog({ id: undefined, status: 1, orderNo: 100 });
};
// 打开编辑页面
const openEditRole = async (row: any) => {
state.editRoleTitle = '编辑角色';
editRoleRef.value?.openDialog(row);
};
// 打开授权数据范围页面
const openGrantData = (row: any) => {
grantDataRef.value?.openDialog(row);
};
// 删除
const delRole = (row: any) => {
ElMessageBox.confirm(`确定删角色:【${row.name}】?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
await getAPI(SysRoleApi).apiSysRoleDeletePost({ id: row.id });
handleQuery();
ElMessage.success('删除成功');
})
.catch(() => {});
};
// 改变页面容量
const handleSizeChange = (val: number) => {
state.tableParams.pageSize = val;
handleQuery();
};
// 改变页码序号
const handleCurrentChange = (val: number) => {
state.tableParams.page = val;
handleQuery();
};
</script>
下面为一般编辑格式(系统自带的角色编辑页面 角色编辑),包括弹窗参数传递、数据提交、变量定义等。
<template>
<div class="sys-role-container">
<el-dialog v-model="state.isShowDialog" draggable :close-on-click-modal="false">
<template #header>
<div style="color: #fff">
<el-icon size="16" style="margin-right: 3px; display: inline; vertical-align: middle"> <ele-Edit /> </el-icon>
<span>{{ props.title }}</span>
</div>
</template>
<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="auto">
<el-row :gutter="35">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="角色名称" prop="name" :rules="[{ required: true, message: '角色名称不能为空', trigger: 'blur' }]">
<el-input v-model="state.ruleForm.name" placeholder="角色名称" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="角色编码" prop="code" :rules="[{ required: true, message: '角色编码不能为空', trigger: 'blur' }]">
<el-input v-model="state.ruleForm.code" placeholder="角色编码" clearable :disabled="state.ruleForm.code == 'sys_admin' && state.ruleForm.id != undefined" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="排序">
<el-input-number v-model="state.ruleForm.orderNo" placeholder="排序" class="w100" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="状态">
<el-radio-group v-model="state.ruleForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="2">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="备注">
<el-input v-model="state.ruleForm.remark" placeholder="请输入备注内容" clearable type="textarea" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="菜单权限" v-loading="state.loading">
<el-tree
ref="treeRef"
:data="state.menuData"
node-key="id"
show-checkbox
:props="{ children: 'children', label: 'title', class: treeNodeClass }"
icon="ele-Menu"
highlight-current
default-expand-all
style="height: 600px;overflow-y: auto;"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" @click="submit">确 定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="sysEditRole">
import { onMounted, reactive, ref } from 'vue';
import type { ElTree } from 'element-plus';
import { getAPI } from '/@/utils/axios-utils';
import { SysMenuApi, SysRoleApi } from '/@/api-services/api';
import { SysMenu, UpdateRoleInput } from '/@/api-services/models';
const props = defineProps({
title: String,
});
const emits = defineEmits(['handleQuery']);
const ruleFormRef = ref();
const treeRef = ref<InstanceType<typeof ElTree>>();
const state = reactive({
loading: false,
isShowDialog: false,
ruleForm: {} as UpdateRoleInput,
menuData: [] as Array<SysMenu>, // 菜单数据
});
onMounted(async () => {
state.loading = true;
var res = await getAPI(SysMenuApi).apiSysMenuListGet();
state.menuData = res.data.result ?? [];
state.loading = false;
});
// 打开弹窗
const openDialog = async (row: any) => {
ruleFormRef.value?.resetFields();
treeRef.value?.setCheckedKeys([]); // 清空选中值
state.ruleForm = JSON.parse(JSON.stringify(row));
if (row.id != undefined) {
var res = await getAPI(SysRoleApi).apiSysRoleOwnMenuListGet(row.id);
setTimeout(() => {
treeRef.value?.setCheckedKeys(res.data.result ?? []);
}, 100);
}
state.isShowDialog = true;
};
// 关闭弹窗
const closeDialog = () => {
emits('handleQuery');
state.isShowDialog = false;
};
// 取消
const cancel = () => {
state.isShowDialog = false;
};
// 提交
const submit = () => {
ruleFormRef.value.validate(async (valid: boolean) => {
if (!valid) return;
state.ruleForm.menuIdList = treeRef.value?.getCheckedKeys() as Array<number>; //.concat(treeRef.value?.getHalfCheckedKeys());
if (state.ruleForm.id != undefined && state.ruleForm.id > 0) {
await getAPI(SysRoleApi).apiSysRoleUpdatePost(state.ruleForm);
} else {
await getAPI(SysRoleApi).apiSysRoleAddPost(state.ruleForm);
}
closeDialog();
});
};
// 叶子节点同行显示样式
const treeNodeClass = (node: SysMenu) => {
let addClass = true; // 添加叶子节点同行显示样式
for (var key in node.children) {
// 如果存在子节点非叶子节点,不添加样式
if (node.children[key].children?.length ?? 0 > 0) {
addClass = false;
break;
}
}
return addClass ? 'penultimate-node' : '';
};
// 导出对象
defineExpose({ openDialog });
</script>
<style lang="scss" scoped>
.menu-data-tree {
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
padding: 5px;
}
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 40px;
white-space: pre-wrap;
line-height: 100%;
.el-tree-node {
display: inline-block;
}
.el-tree-node__content {
padding-left: 5px !important;
padding-right: 5px;
// .el-tree-node__expand-icon {
// display: none;
// }
}
}
}
</style>
接口开发
提示
接口请求写法分两种模式:
1、根据Swagger文档定义,在目录 /src/api 下面创建各个业务接口文件夹及接口ts文件。
2、根据Swagger文档定义,自动生成前端接口请求文件 https://editor-next.swagger.io/,不仅包括接口定义,也包括入参与返回结果结构定义,都是 ts 强类型,使用起来非常舒服,免去了手撸接口的麻烦及前后接口不对应的情况。极力推荐此模式!!!
相关信息
在团队协作中,在前后端分离的开发模式中,前后端程序员各司其职,后端程序负责编写接口(API),前端程序员负责编写客户端请求后端接口(API)并进行页面数据绑定。
通常前端程序需要将后端几百个甚至上千个接口进行一一对应编写,大多都是采用 $.ajax 或 axios 的方式。若采用手写则工作效率极低且易出错。
一旦后端接口参数或返回值发生改变,前端程序员需要一一进行对应修正,一旦出现纠正不完全就会导致系统无法响应或接收错误的用户消息从而造成不必要的维护工作和成本浪费。
手撸接口请求
下面为登录接口定义示例:
import request from '/@/utils/request';
/**
* (不建议写成 request.post(xxx),因为这样 post 时,无法 params 与 data 同时传参)
*
* 登录api接口集合
* @method signIn 用户登录
* @method signOut 用户退出登录
*/
export function useLoginApi() {
return {
signIn: (data: object) => {
return request({
url: '/user/signIn',
method: 'post',
data,
});
},
signOut: (data: object) => {
return request({
url: '/user/signOut',
method: 'post',
data,
});
},
};
}
自动生成接口
点击 Swagger 接口定义文件路径及复制内容,打开 https://editor-next.swagger.io/ , 将复制的 Swagger 内容拷贝进来,然后依次点击菜单 Generate Client
、typescript-axios
进行接口生成,将生成的文件解压缩复制相应的文件至目录 api-services
里面即可,如下图所示:
提示
建议具体业务应用单独搞成一个 Swagger 分组,不用每次生成框架或其他接口,只生成指定分组的接口文件接口。具体文件格式参考 https://gitee.com/zuohuaijun/Admin.NET/tree/next/Web/src/api-services
生成的接口名称其实就是路由名称的拼接。比如路由地址为 /api/sysConfig/sysInfo
,请求模式是 GET, 生成的接口名称则为 apiSysConfigSysInfoGet
, 名称都是直接拼接的并且把请求模式谓词放到了名称最后。 下面是接口使用示例,其中 res.data.result
就是后台返回的数据:
import { getAPI } from '/@/utils/axios-utils';
import { xxxApi } from '/@/api-services/api';
var res = await getAPI(xxxApi).apixxxPost({ xxx });
var res = await getAPI(xxxApi).apixxxGet({ xxx });
getAPI(xxxgApi)
.apixxxGet()
.then((res) => {
})
.catch(() => {
});
全局接口请求工具已内置无需再次处理,提供的 axios-utils.ts文件,包括双Token、Token过期刷新、全局拦截等逻辑都已处理好,直接用上面的示例调用即可。
import globalAxios, { AxiosInstance } from 'axios';
import { Configuration } from '../api-services';
import { BaseAPI, BASE_PATH } from '../api-services/base';
import { ElMessage } from 'element-plus';
import { Local, Session } from '../utils/storage';
// 接口服务器配置
export const serveConfig = new Configuration({
basePath: window.__env__.VITE_API_URL,
});
// token 键定义
export const accessTokenKey = 'access-token';
export const refreshAccessTokenKey = `x-${accessTokenKey}`;
// 获取 token
export const getToken = () => {
return Local.get(accessTokenKey);
};
// 获取请求头 token
export const getHeader = () => {
return { authorization: 'Bearer ' + getToken() };
};
// 清除 token
export const clearAccessTokens = () => {
clearTokens();
// 刷新浏览器
window.location.reload();
};
// 清除 token
export const clearTokens = () => {
Local.remove(accessTokenKey);
Local.remove(refreshAccessTokenKey);
Session.clear();
};
// axios 默认实例
export const axiosInstance: AxiosInstance = globalAxios;
// 这里可以配置 axios 更多选项 =========================================
axiosInstance.defaults.timeout = 1000 * 60 * 10; // 设置超时,默认 10 分钟
// axios 请求拦截
axiosInstance.interceptors.request.use(
(conf) => {
// 获取本地的 token或session中的token
const accessToken = Local.get(accessTokenKey) ? Local.get(accessTokenKey) : Session.get('token');
if (accessToken) {
// 将 token 添加到请求报文头中
conf.headers!['Authorization'] = `Bearer ${accessToken}`;
// 判断 accessToken 是否过期
const jwt: any = decryptJWT(accessToken);
const exp = getJWTDate(jwt.exp as number);
// token 已经过期
if (new Date() >= exp) {
// 获取刷新 token
const refreshAccessToken = Local.get(refreshAccessTokenKey);
// 携带刷新 token
if (refreshAccessToken) {
conf.headers!['X-Authorization'] = `Bearer ${refreshAccessToken}`;
}
}
}
// 这里编写请求拦截代码 =========================================
// 获取前端设置的语言
const globalI18n = Local.get('themeConfig')?.globalI18n;
if (globalI18n) {
// 添加到请求报文头中
conf.headers!['Accept-Language'] = globalI18n;
}
return conf;
},
(error) => {
// 处理请求错误
if (error.request) {
ElMessage.error(error);
}
// 请求错误代码及自定义处理
ElMessage.error(error);
return Promise.reject(error);
}
);
// axios 响应拦截
axiosInstance.interceptors.response.use(
(res) => {
// 获取状态码和返回数据
var status = res.status;
var serve = res.data;
// 处理 401
if (status === 401) {
clearAccessTokens();
}
// 处理未进行规范化处理的
if (status >= 400) {
throw new Error(res.statusText || 'Request Error.');
}
// 处理规范化结果错误
if (serve && serve.hasOwnProperty('errors') && serve.errors) {
throw new Error(JSON.stringify(serve.errors || 'Request Error.'));
}
// 读取响应报文头 token 信息
var accessToken = res.headers[accessTokenKey];
var refreshAccessToken = res.headers[refreshAccessTokenKey];
// 判断是否是无效 token
if (accessToken === 'invalid_token') {
clearAccessTokens();
}
// 判断是否存在刷新 token,如果存在则存储在本地
else if (refreshAccessToken && accessToken && accessToken !== 'invalid_token') {
Local.set(accessTokenKey, accessToken);
Local.set(refreshAccessTokenKey, refreshAccessToken);
}
// 响应拦截及自定义处理
if (serve.code === 401) {
clearAccessTokens();
} else if (serve.code === undefined) {
return Promise.resolve(res);
} else if (serve.code !== 200) {
var message;
// 判断 serve.message 是否为对象
if (serve.message && typeof serve.message == 'object') {
message = JSON.stringify(serve.message);
} else {
message = serve.message;
}
// 用户自定义处理异常
if (!res.config?.customCatch) {
ElMessage.error(message);
}
throw new Error(message);
}
return res;
},
(error) => {
// 处理响应错误
if (error.response) {
if (error.response.status === 401) {
clearAccessTokens();
}
}
// 用户自定义处理异常
if (!error.config?.customCatch) {
// 响应错误代码及自定义处理
ElMessage.error(error);
}
return Promise.reject(error);
}
);
/**
* 包装 Promise 并返回 [Error, any]
* @param promise Promise 方法
* @param errorExt 自定义错误信息(拓展)
* @returns [Error, any]
*/
export function feature<T, U = Error>(promise: Promise<T>, errorExt?: object): Promise<[U, undefined] | [null, T]> {
return promise
.then<[null, T]>((data: T) => [null, data])
.catch<[U, undefined]>((err: U) => {
if (errorExt) {
const parsedError = Object.assign({}, err, errorExt);
return [parsedError, undefined];
}
return [err, undefined];
});
}
/**
* 获取/创建服务 API 实例
* @param apiType BaseAPI 派生类型
* @param configuration 服务器配置对象
* @param basePath 服务器地址
* @param axiosObject axios 实例
* @returns 服务API 实例
*/
export function getAPI<T>(
// eslint-disable-next-line no-unused-vars
apiType: new (configuration?: Configuration, basePath?: string, axiosInstance?: AxiosInstance) => T,
configuration: Configuration = serveConfig,
basePath: string = BASE_PATH,
axiosObject: AxiosInstance = axiosInstance
) {
return new apiType(configuration, basePath, axiosObject);
}
/**
* 解密 JWT token 的信息
* @param token jwt token 字符串
* @returns <any>object
*/
export function decryptJWT(token: string): any {
token = token.replace(/_/g, '/').replace(/-/g, '+');
var json = decodeURIComponent(escape(window.atob(token.split('.')[1])));
return JSON.parse(json);
}
/**
* 将 JWT 时间戳转换成 Date
* @description 主要针对 `exp`,`iat`,`nbf`
* @param timestamp 时间戳
* @returns Date 对象
*/
export function getJWTDate(timestamp: number): Date {
return new Date(timestamp * 1000);
}
/**
* 实现异步延迟
* @param delay 延迟时间(毫秒)
* @returns
*/
export function sleep(delay: number) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
相关信息
若是采用传统的手写 $.ajax 和 axios 代码,系统则会选择用 request.ts 进行全局请求,也都是进行全局拦截处理好的,也是直接用即可,不需要管这全局请求文件。