Vue+ElementUI脚手架搭建
因为最近需要渠道商需要单独处理管理系统提供对账处理, 所以需要速成搭建项目且公司内部主要技术栈在 Vue+ElementUI 上,
所以最后项目选型就确定下来开始速成开发.
所以直接从头构建配置好项目应该没问题:
# 安装 vue-cli 命令行工具进入项目初始化配置
npm create vue@latest
# 构建项目 fusion-vue 就行
# ✔ Project name(项目目录): … fusion-vue
# ✔ Add TypeScript?(是否采用typescript,看个人习惯) … No
# ✔ Add JSX Support?(是否支持JSX, 一般不需要) … No
# ✔ Add Vue Router for Single Page Application development?(VueRouter单页开发,后续手动追加) … No
# ✔ Add Pinia for state management?(引入Pinia状态管理) … No
# ✔ Add Vitest for Unit testing?(Vitest作为单元测试) … No
# ✔ Add an End-to-End Testing Solution?(是否采用e2d测试工具) … No
# ✔ Add ESLint for code quality?(是否引入ESLint作为代码质量检测, 看个人习惯) … No
# ✔ Add Prettier for code formatting? … No / Yes
# ✔ Add Vue DevTools 7 extension for debugging? (experimental) … No / Yes
# 之后进入目录安装插件
cd fusion-vue
npm install
这里初始化 vue 项目完成, 执行命令查看是否能够唤起:
# 启动开发环境, 默认访问 http://localhost:5173/ 就能看到具体的内容
npm run dev
引入 ElementUI-Plus
直接命令行来加入依赖:
# 安装最新版本 ElementUI 和 icon, axios
npm install element-plus @element-plus/icons-vue axios --save
终端提示 legacy JS API Deprecation Warning, 需要在 vite.config.[js|ts] 追加配置:
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
// 更新 Sass 版本依赖
css: {
preprocessorOptions: {
scss: { api: 'modern-compiler' },
}
}
})
推荐安装按需引入插件, 否则插件大规范引入进来也麻烦:
npm install -D unplugin-vue-components unplugin-auto-import
之后在 vite.config.[js|ts] 追加配置, 最后基本配置文件如下:
import {fileURLToPath, URL} from 'node:url'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import {ElementPlusResolver} from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
// 更新 Sass 版本依赖
css: {
preprocessorOptions: {
scss: {api: 'modern-compiler'},
}
}
})
现在该项目就是标准采用 Vite 构建的项目, 之后测试编写处理下初始测试页面:
<!-- 默认 App.vue 入口文件 -->
<template>
<main :style="{display:'flex'}">
<div :style="{flexDirection: 'column'}">
<div>
<input type="text" name="message" v-model.trim="message">
</div>
<div>
<el-button @click="messageClick">{{ message.length > 0 ? `Query -> ${message}` : "Query" }}</el-button>
</div>
<div>
<table>
<thead>
<tr>
<td>ID</td>
<td>Name</td>
<td>Age</td>
</tr>
</thead>
<tbody>
<tr v-for="(row,idx) in rows" :key="idx">
<td>{{ row.id }}</td>
<td>{{ row.name }}</td>
<td>{{ row.age }}</td>
</tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</main>
</template>
<!-- 挂载脚本 -->
<script>
const rows = [
{
id: 1,
name: "John",
age: 30
},
{
id: 2,
name: "Zoe",
age: 20
}
];
// 配置输出对象
export default {
data() {
return {
message: '',
rows: rows
}
},
methods: {
messageClick(event) {
if (this.message.length > 0) {
this.rows = rows.filter(row => row.name.startsWith(this.message))
} else {
this.rows = rows;
}
}
}
}
</script>
这里展示 Vue 一些基础功能, 确认能够正确运行就能够进行后续处理了, 这里直接安装路由工具:
# 最好不要用 npm 单独安装路由, 可能会出现异常
# npm install vue-router
# 如果项目本身存在可以采用 npm uninstall vue-router 命令先卸载
# 官方新版本需要带上 @4 配置, 后续版本可能会递增
npm install vue-router@4
注意新版本路由替换了以往的命名方式, 采用了 大驼峰 格式:
router-link(老版本):RouterLink(新版本)router-view(老版本):RouterView(新版本)
之后按照 官方文档 这里先建立自己简单的路由分页:
<!-- 默认 App.vue 入口文件 -->
<template>
<h1>Pages</h1>
<h3>Path: {{ $route.fullPath }}</h3>
<div :style="{display:'flex', flexDirection:'column', justifyContent:'center', alignItems:'center'}">
<nav>
<!-- 使用 RouterLink 组件来导航. -->
<!-- 通过传入 `to` 属性指定链接. -->
<!-- <RouterLink> 默认会被渲染成一个 `<a>` 标签 -->
<RouterLink to="/">Go to Dashboard</RouterLink>
<RouterLink to="/login">Go to Login</RouterLink>
</nav>
<main>
<!-- 组件默认渲染节点, 相当于占位符 -->
<RouterView/>
</main>
</div>
</template>
之后就是注册路由功能, 需要注意后续的注册路由现在移交到 createApp 步骤上, 所以现在需要去 main.js 处理引入:
import './assets/main.css'
import {createApp} from 'vue'
import App from './App.vue'
import {createRouter, createWebHistory} from "vue-router";
// 测试引入初始化项目组件
import HelloWorld from "@/components/HelloWorld.vue";
import TheWelcome from "@/components/TheWelcome.vue";
// 这里简单构建个路由, 后续要扩展对应路由配置表
const routes = [
{path: '/', component: HelloWorld},
{path: '/login', component: TheWelcome},
]
// 这里不采用官方文档的 createMemoryHistory 路由, 而是采用路径路由( createWebHistory ), 还有个 createWebHashHistory 哈希路由
// 实际上比较常用的就是 createWebHistory 路径路由看起来直观点
const router = createRouter({
history: createWebHistory(),
routes,
})
// 注册路由组件
createApp(App)
.use(router)
.mount('#app')
之后就能通过项目点击节点来切换不同组件了.
模板套用
目前主要是项目比较迫切要上线demo, 所以参考了官方之后发现 BuildAdmin直接上手.
这里虽然是前后端共享的业务, 但是后台接口数据已经另外有Java服务端单独抽离维护,
所以只需要内部的 web 目录的前端结构;
另外需要说明项目主体采用 typescript 构建, 也就是配置文件需要改变.
原来的 vite 配置文件变更 vite.config.js -> vite.config.ts, 内部内容也不一样:
import vue from '@vitejs/plugin-vue'
import {resolve} from 'path'
import type {ConfigEnv, UserConfig} from 'vite'
import {loadEnv} from 'vite'
import {svgBuilder} from '/@/components/icon/svg/index'
import {customHotUpdate, isProd} from '/@/utils/vite'
const pathResolve = (dir: string): any => {
return resolve(__dirname, '.', dir)
}
// https://vitejs.cn/config/
const viteConfig = ({mode}: ConfigEnv): UserConfig => {
const {VITE_PORT, VITE_OPEN, VITE_BASE_PATH, VITE_OUT_DIR} = loadEnv(mode, process.cwd())
const alias: Record<string, string> = {
'/@': pathResolve('./src/'),
assets: pathResolve('./src/assets'),
'vue-i18n': isProd(mode) ? 'vue-i18n/dist/vue-i18n.cjs.prod.js' : 'vue-i18n/dist/vue-i18n.cjs.js',
}
return {
plugins: [vue(), svgBuilder('./src/assets/icons/'), customHotUpdate()],
root: process.cwd(),
resolve: {alias},
base: VITE_BASE_PATH,
server: {
port: parseInt(VITE_PORT),
open: VITE_OPEN != 'false',
},
build: {
cssCodeSplit: false,
sourcemap: false,
outDir: VITE_OUT_DIR,
emptyOutDir: true,
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: {
// 分包配置,配置完成自动按需加载
vue: ['vue', 'vue-router', 'pinia', 'vue-i18n', 'element-plus'],
echarts: ['echarts'],
},
},
},
},
}
}
export default viteConfig
这里
/@/components/icon/svg/index和/@/utils/vite报错可以先不用处理
package.json 也需要进行改动, 包引入做另外处理:
{
"name": "fusion-vue",
"version": "2025.1.20",
"private": true,
"type": "module",
"scripts": {
"dev": "esno ./src/utils/build.ts && vite --force",
"build": "vite build && esno ./src/utils/build.ts",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
"format": "npx prettier --write .",
"typecheck": "vue-tsc --noEmit"
}
}
内部采用 vue-i18n 做多语言设计, 且 pinia 做全局状态管理, 实际上直接命令引入即可:
# 引入正式依赖
npm install @element-plus/icons-vue @vueuse/core axios echarts element-plus font-awesome lodash-es mitt nprogress pinia pinia-plugin-persistedstate qrcode.vue screenfull sortablejs v-code-diff vue vue-i18n vue-router --save
# 引入开发依赖
npm install @eslint/js @types/lodash-es @types/node @types/nprogress @types/sortablejs @vitejs/plugin-vue async-validator esbuild eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-vue esno globals [email protected] rollup sass typescript typescript-eslint vite vue-tsc --save-dev
因为内部默认锁定最新版本, 后续官方不再维护就需要确认依赖锁定版本, 比如 [email protected] 这样锁定版本来引入.
配置文件变更 jsconfig.json -> tsconfig.json, 内部内容也不一样:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": [
"ESNext",
"DOM"
],
"useDefineForClassFields": true,
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"baseUrl": "./",
"allowJs": true,
"skipLibCheck": true,
"paths": {
"/@/*": [
"src/*"
]
},
"types": [
"vite/client",
"element-plus/global"
]
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"types/**/*.d.ts",
"vite.config.ts"
]
}
追加 eslint 配置文件 eslint.config.js:
import js from '@eslint/js'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import eslintPluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import ts from 'typescript-eslint'
export default [
// 三大基本推荐规则
js.configs.recommended,
...ts.configs.recommended,
...eslintPluginVue.configs['flat/recommended'],
// 忽略规则
{
ignores: ['node_modules', 'dist', 'public'],
},
// 全局变量
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
// vue
{
files: ['**/*.vue'],
languageOptions: {
parserOptions: {
// ts 解析器
parser: ts.parser,
// 允许 jsx
ecmaFeatures: {
jsx: true,
},
},
},
},
// eslint + prettier 的兼容性问题解决规则
eslintConfigPrettier,
eslintPluginPrettierRecommended,
// ts
{
files: ['**/*.{ts,tsx,vue}'],
rules: {
'no-empty': 'off',
'no-undef': 'off',
'no-unused-vars': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-prototype-builtins': 'off',
'no-use-before-define': 'off',
'no-case-declarations': 'off',
'no-console': 'off',
'no-control-regex': 'off',
'vue/v-on-event-hyphenation': 'off',
'vue/custom-event-name-casing': 'off',
'vue/component-definition-name-casing': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/html-self-closing': 'off',
'vue/require-default-prop': 'off',
'vue/no-arrow-functions-in-watch': 'off',
'vue/no-v-html': 'off',
'vue/comment-directive': 'off',
'vue/multi-word-component-names': 'off',
'vue/require-prop-types': 'off',
'vue/html-indent': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
// prettier 规则
{
files: ['**/*.{ts,tsx,vue,js}'],
rules: {
'prettier/prettier': [
'warn', // 使用警告而不是错误
{
endOfLine: 'auto', // eslint 无需检查文件换行符
},
],
},
},
]
另外追加 prettier 配置文件 .prettierrc.js(注意是点开头文件):
export default {
printWidth: 150,
// 指定每个缩进级别的空格数
tabWidth: 4,
// 使用制表符而不是空格缩进行
useTabs: false,
// 在语句末尾打印分号
semi: false,
// 使用单引号而不是双引号
singleQuote: true,
// 更改引用对象属性的时间 可选值"<as-needed|consistent|preserve>"
quoteProps: 'as-needed',
// 在JSX中使用单引号而不是双引号
jsxSingleQuote: false,
// 多行时尽可能打印尾随逗号。(例如,单行数组永远不会出现逗号结尾。) 可选值"<none|es5|all>",默认none
trailingComma: 'es5',
// 在对象文字中的括号之间打印空格
bracketSpacing: true,
// 在单独的箭头函数参数周围包括括号 always:(x) => x \ avoid:x => x
arrowParens: 'always',
// 这两个选项可用于格式化以给定字符偏移量(分别包括和不包括)开始和结束的代码
rangeStart: 0,
rangeEnd: Infinity,
// 指定要使用的解析器,不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准 always\never\preserve
proseWrap: 'preserve',
// 指定HTML文件的全局空格敏感度 css\strict\ignore
htmlWhitespaceSensitivity: 'css',
// Vue文件脚本和样式标签缩进
vueIndentScriptAndStyle: false,
// 换行符使用 lf 结尾是 可选值"<auto|lf|crlf|cr>"
endOfLine: 'lf',
}
编辑器默认配置文件, 追加 .editorconfig(注意点开头文件):
# https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
indent_style = tab
insert_final_newline = false
trim_trailing_whitespace = false
NPM 默认配置文件, 追加 .npmrc(注意点开头文件):
# 若不开启且使用 pnpm 安装依赖后,element-plus 和 vue-i18n(useI18n) 共存时组件的类型定义将丢失
shamefully-hoist=true
内部的 src 目录和 index.html 文件直接复制官方模板改改就行( 注意 index.html 现在引入 main.ts ),
实际上直接删除 src 目录和 index.html 文件之后直接复制更好点.
还有官方提供 .env, .env.development, .env.production 多环境配置, 这里也直接复制就行:
// .env 文件内容, 这行不要复制进去
# port 端口号
VITE_PORT = 3000
# open 运行 npm run dev 时自动打开浏览器
VITE_OPEN = false
// .env.development 文件内容, 这行不要复制进去
# 本地环境
ENV = 'development'
# base路径
VITE_BASE_PATH = './'
# 本地环境接口地址 - 尾部无需带'/'
VITE_AXIOS_BASE_URL = 'http://localhost:8000'
// .env.production 文件内容, 这行不要复制进去
# 线上环境
ENV = 'production'
# base路径
VITE_BASE_PATH = '/'
# 导出路径
VITE_OUT_DIR = 'dist'
# 线上环境接口地址 - 'getCurrentDomain:表示获取当前域名'
VITE_AXIOS_BASE_URL = 'getCurrentDomain'
.env 实际上本地开发不应该提交版本库, 主要需要先提交配置模板才将其引入版本库控制
最后将官方模板的 src, public, types 在原来目录删除并复制过去就可以了, 建议以此版本顶下最初初始化版本号并提交版本库.
执行启动命令, 这一步虽然可能会有报错但也是正常, 主要是后端是直接采用我们自己引入的方式, 所以需要再处理下:
# 启动指令, 后续访问端口取 .env VITE_PORT 配置项
npm run dev
进入 http://localhost:3000 之后会进入选择中间界面, 实际上我们本身只需要 后台管理 界面其他不需要,
所以我们主要现在的工作就是裁剪掉不需要模块.
关键的配置目录在 src/router 之中, 内部具有很多路由信息, 打开之后发现内部还带有会员系统, 这个会员系统就是我们需要裁剪掉的.
不要先急着直接
src/router/static.ts删除掉关于会员中心路由, 应该先删除关联文件最后才清除掉路由信息
审查代码之后发现裁剪模块对应文件:
- 文件:
src/router/static/memberCenterBase.ts - 目录:
src/layouts/frontend - 目录:
src/api/frontend - 目录:
src/lang/frontend - 目录:
src/views/frontend - 文件:
src/stores/userInfo.ts
这些目录和文件可以删除, 但是最好版本库备份之后处理
删除之后就是内部清理依赖, 之后将路由设置默认跳转 src/router/static.ts 文件最后整理:
import type {RouteRecordRaw} from 'vue-router'
import {adminBaseRoutePath} from '/@/router/static/adminBase'
const pageTitle = (name: string): string => {
return `pagesTitle.${name}`
}
/*
* 静态路由
* 自动加载 ./static 目录的所有文件,并 push 到以下数组
*/
const staticRoutes: Array<RouteRecordRaw> = [
{
// 首页
path: '/',
name: '/',
component: () => import('/@/views/backend/login.vue'),
meta: {
title: pageTitle('adminLogin'),
},
},
{
// 管理员登录页 - 不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局
path: adminBaseRoutePath + '/login',
name: 'adminLogin',
component: () => import('/@/views/backend/login.vue'),
meta: {
title: pageTitle('adminLogin'),
},
},
{
path: '/:path(.*)*',
redirect: '/404',
},
{
// 404
path: '/404',
name: 'notFound',
component: () => import('/@/views/common/error/404.vue'),
meta: {
title: pageTitle('notFound'), // 页面不存在
},
},
{
// 后台找不到页面了-可能是路由未加载上
path: adminBaseRoutePath + ':path(.*)*',
redirect: (to) => {
return {
name: 'adminMainLoading',
params: {
to: JSON.stringify({
path: to.path,
query: to.query,
}),
},
}
},
},
{
// 无权限访问
path: '/401',
name: 'noPower',
component: () => import('/@/views/common/error/401.vue'),
meta: {
title: pageTitle('noPower'),
},
},
]
const staticFiles: Record<string, Record<string, RouteRecordRaw>> = import.meta.glob('./static/*.ts', {eager: true})
for (const key in staticFiles) {
if (staticFiles[key].default) staticRoutes.push(staticFiles[key].default)
}
export default staticRoutes
默认 AXIOS 请求配置 src/utils/axios.ts 文件最后确定注册路由:
// 第186行附近
const isAdminAppFlag = isAdminApp()
// let routerPath = isAdminAppFlag ? adminBaseRoute.path : memberCenterBaseRoutePath
let routerPath = adminBaseRoute.path;
实际上可以通过 src/utils/common.ts 的 isAdminApp 全局函数看到有多少内部做了切换,
官方模板采用这种方法设计判断路由跳转其实不合理, 导致了裁剪功能依赖性太多, 所以这里建议追加函数返回路由配置地址:
// 追加 src/utils/common.ts 函数, 返回默认请求路径
/**
* 获取默认请求路径
*/
export const getBaseUrl = () => {
// 后续多重路由在这个函数内部识别并返回
return adminBaseRoute.path
}
之后所有用到 isAdminApp 函数返回路径的地方替换成 baseUrl 函数,
最后的 src/utils/axios.ts 文件如下:
import type {AxiosRequestConfig, Method} from 'axios'
import axios from 'axios'
import {ElLoading, ElNotification, type LoadingOptions} from 'element-plus'
import {refreshToken} from '/@/api/common'
import {i18n} from '/@/lang'
import router from '/@/router/index'
import adminBaseRoute from '/@/router/static/adminBase'
import {useAdminInfo} from '/@/stores/adminInfo'
import {useConfig} from '/@/stores/config'
import {getBaseUrl, isAdminApp} from '/@/utils/common'
window.requests = []
window.tokenRefreshing = false
const pendingMap = new Map()
const loadingInstance: LoadingInstance = {
target: null,
count: 0,
}
/**
* 根据运行环境获取基础请求URL
*/
export const getUrl = (): string => {
const value: string = import.meta.env.VITE_AXIOS_BASE_URL as string
return value == 'getCurrentDomain' ? window.location.protocol + '//' + window.location.host : value
}
/**
* 根据运行环境获取基础请求URL的端口
*/
export const getUrlPort = (): string => {
const url = getUrl()
return new URL(url).port
}
/**
* 创建`Axios`
* 默认开启`reductDataFormat(简洁响应)`,返回类型为`ApiPromise`
* 关闭`reductDataFormat`,返回类型则为`AxiosPromise`
*/
function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequestConfig, options: Options = {}, loading: LoadingOptions = {}): T {
const config = useConfig()
const adminInfo = useAdminInfo()
const Axios = axios.create({
baseURL: getUrl(),
timeout: 1000 * 10,
headers: {
'think-lang': config.lang.defaultLang,
server: true,
},
responseType: 'json',
})
// 自定义后台入口
if (adminBaseRoute.path != '/admin' && isAdminApp() && /^\/admin\//.test(axiosConfig.url!)) {
axiosConfig.url = axiosConfig.url!.replace(/^\/admin\//, adminBaseRoute.path + '.php/')
}
// 合并默认请求选项
options = Object.assign(
{
CancelDuplicateRequest: true, // 是否开启取消重复请求, 默认为 true
loading: false, // 是否开启loading层效果, 默认为false
reductDataFormat: true, // 是否开启简洁的数据结构响应, 默认为true
showErrorMessage: true, // 是否开启接口错误信息展示,默认为true
showCodeMessage: true, // 是否开启code不为1时的信息提示, 默认为true
showSuccessMessage: false, // 是否开启code为1时的信息提示, 默认为false
anotherToken: '', // 当前请求使用另外的用户token
},
options
)
// 请求拦截
Axios.interceptors.request.use(
(config) => {
removePending(config)
options.CancelDuplicateRequest && addPending(config)
// 创建loading实例
if (options.loading) {
loadingInstance.count++
if (loadingInstance.count === 1) {
loadingInstance.target = ElLoading.service(loading)
}
}
// 自动携带token
if (config.headers) {
const token = adminInfo.getToken()
if (token) (config.headers as anyObj).batoken = token
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截
Axios.interceptors.response.use(
(response) => {
removePending(response.config)
options.loading && closeLoading(options) // 关闭loading
if (response.config.responseType == 'json') {
if (response.data && response.data.code !== 1) {
if (response.data.code == 409) {
if (!window.tokenRefreshing) {
window.tokenRefreshing = true
return refreshToken()
.then((res) => {
if (res.data.type == 'admin-refresh') {
adminInfo.setToken(res.data.token, 'auth')
response.headers.batoken = `${res.data.token}`
window.requests.forEach((cb) => cb(res.data.token, 'admin-refresh'))
}
window.requests = []
return Axios(response.config)
})
.catch((err) => {
if (isAdminApp()) {
adminInfo.removeToken()
if (router.currentRoute.value.name != 'adminLogin') {
router.push({name: 'adminLogin'})
return Promise.reject(err)
} else {
response.headers.batoken = ''
window.requests.forEach((cb) => cb('', 'admin-refresh'))
window.requests = []
return Axios(response.config)
}
} else {
if (router.currentRoute.value.name != 'userLogin') {
router.push({name: 'userLogin'})
return Promise.reject(err)
} else {
response.headers['ba-user-token'] = ''
window.requests.forEach((cb) => cb('', 'user-refresh'))
window.requests = []
return Axios(response.config)
}
}
})
.finally(() => {
window.tokenRefreshing = false
})
} else {
return new Promise((resolve) => {
// 用函数形式将 resolve 存入,等待刷新后再执行
window.requests.push((token: string, type: string) => {
if (type == 'admin-refresh') {
response.headers.batoken = `${token}`
} else {
response.headers['ba-user-token'] = `${token}`
}
resolve(Axios(response.config))
})
})
}
}
if (options.showCodeMessage) {
ElNotification({
type: 'error',
message: response.data.msg,
zIndex: 9999,
})
}
// 自动跳转到路由name或path
if (response.data.code == 302) {
router.push({
path: response.data.data.routePath ?? '',
name: response.data.data.routeName ?? ''
})
}
if (response.data.code == 303) {
let routerPath = getBaseUrl();
// 需要登录,清理 token,转到登录页
if (response.data.data.type == 'need login') {
adminInfo.removeToken()
routerPath += '/login'
}
router.push({path: routerPath})
}
// code不等于1, 页面then内的具体逻辑就不执行了
return Promise.reject(response.data)
} else if (options.showSuccessMessage && response.data && response.data.code == 1) {
ElNotification({
message: response.data.msg ? response.data.msg : i18n.global.t('axios.Operation successful'),
type: 'success',
zIndex: 9999,
})
}
}
return options.reductDataFormat ? response.data : response
},
(error) => {
error.config && removePending(error.config)
options.loading && closeLoading(options) // 关闭loading
options.showErrorMessage && httpErrorStatusHandle(error) // 处理错误状态码
return Promise.reject(error) // 错误继续返回给到具体页面
}
)
return Axios(axiosConfig) as T
}
export default createAxios
/**
* 处理异常
* @param {*} error
*/
function httpErrorStatusHandle(error: any) {
// 处理被取消的请求
if (axios.isCancel(error)) return console.error(i18n.global.t('axios.Automatic cancellation due to duplicate request:') + error.message)
let message = ''
if (error && error.response) {
switch (error.response.status) {
case 302:
message = i18n.global.t('axios.Interface redirected!')
break
case 400:
message = i18n.global.t('axios.Incorrect parameter!')
break
case 401:
message = i18n.global.t('axios.You do not have permission to operate!')
break
case 403:
message = i18n.global.t('axios.You do not have permission to operate!')
break
case 404:
message = i18n.global.t('axios.Error requesting address:') + error.response.config.url
break
case 408:
message = i18n.global.t('axios.Request timed out!')
break
case 409:
message = i18n.global.t('axios.The same data already exists in the system!')
break
case 500:
message = i18n.global.t('axios.Server internal error!')
break
case 501:
message = i18n.global.t('axios.Service not implemented!')
break
case 502:
message = i18n.global.t('axios.Gateway error!')
break
case 503:
message = i18n.global.t('axios.Service unavailable!')
break
case 504:
message = i18n.global.t('axios.The service is temporarily unavailable Please try again later!')
break
case 505:
message = i18n.global.t('axios.HTTP version is not supported!')
break
default:
message = i18n.global.t('axios.Abnormal problem, please contact the website administrator!')
break
}
}
if (error.message.includes('timeout')) message = i18n.global.t('axios.Network request timeout!')
if (error.message.includes('Network'))
message = window.navigator.onLine ? i18n.global.t('axios.Server exception!') : i18n.global.t('axios.You are disconnected!')
ElNotification({
type: 'error',
message,
zIndex: 9999,
})
}
/**
* 关闭Loading层实例
*/
function closeLoading(options: Options) {
if (options.loading && loadingInstance.count > 0) loadingInstance.count--
if (loadingInstance.count === 0) {
loadingInstance.target.close()
loadingInstance.target = null
}
}
/**
* 储存每个请求的唯一cancel回调, 以此为标识
*/
function addPending(config: AxiosRequestConfig) {
const pendingKey = getPendingKey(config)
config.cancelToken =
config.cancelToken ||
new axios.CancelToken((cancel) => {
if (!pendingMap.has(pendingKey)) {
pendingMap.set(pendingKey, cancel)
}
})
}
/**
* 删除重复的请求
*/
function removePending(config: AxiosRequestConfig) {
const pendingKey = getPendingKey(config)
if (pendingMap.has(pendingKey)) {
const cancelToken = pendingMap.get(pendingKey)
cancelToken(pendingKey)
pendingMap.delete(pendingKey)
}
}
/**
* 生成每个请求的唯一key
*/
function getPendingKey(config: AxiosRequestConfig) {
let {data} = config
const {url, method, params, headers} = config
if (typeof data === 'string') data = JSON.parse(data) // response里面返回的config.data是个字符串对象
return [
url,
method,
headers && (headers as anyObj).batoken ? (headers as anyObj).batoken : '',
headers && (headers as anyObj)['ba-user-token'] ? (headers as anyObj)['ba-user-token'] : '',
JSON.stringify(params),
JSON.stringify(data),
].join('&')
}
/**
* 根据请求方法组装请求数据/参数
*/
export function requestPayload(method: Method, data: anyObj) {
if (method == 'GET') {
return {
params: data,
}
} else if (method == 'POST') {
return {
data: data,
}
}
}
interface LoadingInstance {
target: any
count: number
}
interface Options {
// 是否开启取消重复请求, 默认为 true
CancelDuplicateRequest?: boolean
// 是否开启loading层效果, 默认为false
loading?: boolean
// 是否开启简洁的数据结构响应, 默认为true
reductDataFormat?: boolean
// 是否开启接口错误信息展示,默认为true
showErrorMessage?: boolean
// 是否开启code不为1时的信息提示, 默认为true
showCodeMessage?: boolean
// 是否开启code为1时的信息提示, 默认为false
showSuccessMessage?: boolean
// 当前请求使用另外的用户token
anotherToken?: string
}
/*
* 感谢掘金@橙某人提供的思路和分享
* 本axios封装详细解释请参考:https://juejin.cn/post/6968630178163458084?share_token=7831c9e0-bea0-469e-8028-b587e13681a8#heading-27
*/
最后的 src/api/common.ts 文件有个请求用户数据接口涵盖之前裁剪内容入口, 所以需要改动成以下内容:
import createAxios from '/@/utils/axios'
import {isAdminApp, checkFileMimetype} from '/@/utils/common'
import {getUrl} from '/@/utils/axios'
import {useAdminInfo} from '/@/stores/adminInfo'
import {ElNotification, type UploadRawFile} from 'element-plus'
import {useSiteConfig} from '/@/stores/siteConfig'
import {state as uploadExpandState, fileUpload as uploadExpand} from '/@/components/mixins/baUpload'
import type {AxiosRequestConfig} from 'axios'
import {uuid} from '/@/utils/random'
import {i18n} from '../lang'
import {adminBaseRoutePath} from '/@/router/static/adminBase'
/*
* 公共请求函数和Url定义
*/
// Admin模块
export const adminUploadUrl = '/admin/ajax/upload'
export const adminBuildSuffixSvgUrl = adminBaseRoutePath + '/ajax/buildSuffixSvg'
export const adminAreaUrl = '/admin/ajax/area'
export const getTablePkUrl = '/admin/ajax/getTablePk'
export const getTableListUrl = '/admin/ajax/getTableList'
export const getTableFieldListUrl = '/admin/ajax/getTableFieldList'
export const getDatabaseConnectionListUrl = '/admin/ajax/getDatabaseConnectionList'
export const terminalUrl = adminBaseRoutePath + '/ajax/terminal'
export const changeTerminalConfigUrl = '/admin/ajax/changeTerminalConfig'
export const clearCacheUrl = '/admin/ajax/clearCache'
// 公共
export const captchaUrl = '/api/common/captcha'
export const clickCaptchaUrl = '/api/common/clickCaptcha'
export const checkClickCaptchaUrl = '/api/common/checkClickCaptcha'
export const refreshTokenUrl = '/api/common/refreshToken'
// api模块(前台)
export const apiUploadUrl = '/api/ajax/upload'
export const apiBuildSuffixSvgUrl = '/api/ajax/buildSuffixSvg'
export const apiAreaUrl = '/api/ajax/area'
export const apiSendSms = '/api/Sms/send'
export const apiSendEms = '/api/Ems/send'
/**
* 上传文件
*/
export function fileUpload(fd: FormData, params: anyObj = {}, forceLocal = false, config: AxiosRequestConfig = {}): ApiPromise {
let errorMsg = ''
const file = fd.get('file') as UploadRawFile
const siteConfig = useSiteConfig()
if (!file.name || typeof file.size == 'undefined') {
errorMsg = i18n.global.t('utils.The data of the uploaded file is incomplete!')
} else if (!checkFileMimetype(file.name, file.type)) {
errorMsg = i18n.global.t('utils.The type of uploaded file is not allowed!')
} else if (file.size > siteConfig.upload.maxSize) {
errorMsg = i18n.global.t('utils.The size of the uploaded file exceeds the allowed range!')
}
if (errorMsg) {
return new Promise((resolve, reject) => {
ElNotification({
type: 'error',
message: errorMsg,
zIndex: 9999,
})
reject(errorMsg)
})
}
if (!forceLocal && uploadExpandState() == 'enable') {
return uploadExpand(fd, params, config)
}
return createAxios({
url: isAdminApp() ? adminUploadUrl : apiUploadUrl,
method: 'POST',
data: fd,
params: params,
timeout: 0,
...config,
})
}
/**
* 生成文件后缀icon的svg图片
* @param suffix 后缀名
* @param background 背景色,如:rgb(255,255,255)
*/
export function buildSuffixSvgUrl(suffix: string, background = '') {
const adminInfo = useAdminInfo()
return (
getUrl() +
(isAdminApp() ? adminBuildSuffixSvgUrl : apiBuildSuffixSvgUrl) +
'?batoken=' +
adminInfo.getToken() +
'&suffix=' +
suffix +
(background ? '&background=' + background : '') +
'&server=1'
)
}
/**
* 获取地区数据
*/
export function getArea(values: number[]) {
const params: { province?: number; city?: number; uuid?: string } = {}
if (values[0]) {
params.province = values[0]
}
if (values[1]) {
params.city = values[1]
}
params.uuid = uuid()
return createAxios({
url: isAdminApp() ? adminAreaUrl : apiAreaUrl,
method: 'GET',
params: params,
})
}
/**
* 发送短信
*/
export function sendSms(mobile: string, templateCode: string, extend: anyObj = {}) {
return createAxios(
{
url: apiSendSms,
method: 'POST',
data: {
mobile: mobile,
template_code: templateCode,
...extend,
},
},
{
showSuccessMessage: true,
}
)
}
/**
* 发送邮件
*/
export function sendEms(email: string, event: string, extend: anyObj = {}) {
return createAxios(
{
url: apiSendEms,
method: 'POST',
data: {
email: email,
event: event,
...extend,
},
},
{
showSuccessMessage: true,
}
)
}
/**
* 缓存清理接口
*/
export function postClearCache(type: string) {
return createAxios(
{
url: clearCacheUrl,
method: 'POST',
data: {
type: type,
},
},
{
showSuccessMessage: true,
}
)
}
/**
* 构建命令执行窗口url
*/
export function buildTerminalUrl(commandKey: string, uuid: string, extend: string) {
const adminInfo = useAdminInfo()
return (
getUrl() + terminalUrl + '?command=' + commandKey + '&uuid=' + uuid + '&extend=' + extend + '&batoken=' + adminInfo.getToken() + '&server=1'
)
}
/**
* 请求修改终端配置
*/
export function postChangeTerminalConfig(data: { manager?: string; port?: string }) {
return createAxios({
url: changeTerminalConfigUrl,
method: 'POST',
data: data,
})
}
/**
* 远程下拉框数据获取
*/
export function getSelectData(remoteUrl: string, q: string, params: anyObj = {}) {
return createAxios({
url: remoteUrl,
method: 'get',
params: {
select: true,
quickSearch: q,
...params,
},
})
}
export function buildCaptchaUrl() {
return getUrl() + captchaUrl + '?server=1'
}
export function getCaptchaData(id: string) {
return createAxios({
url: clickCaptchaUrl,
method: 'get',
params: {
id,
},
})
}
export function checkClickCaptcha(id: string, info: string, unset: boolean) {
return createAxios(
{
url: checkClickCaptchaUrl,
method: 'post',
data: {
id,
info,
unset,
},
},
{
showCodeMessage: false,
}
)
}
export function getTablePk(table: string, connection = '') {
return createAxios({
url: getTablePkUrl,
method: 'get',
params: {
table: table,
connection: connection,
},
})
}
/**
* 获取数据表的字段
* @param table 数据表名
* @param clean 只要干净的字段注释(只要字段标题)
*/
export function getTableFieldList(table: string, clean = true, connection = '') {
return createAxios({
url: getTableFieldListUrl,
method: 'get',
params: {
table: table,
clean: clean ? 1 : 0,
connection: connection,
},
})
}
export function refreshToken() {
const adminInfo = useAdminInfo()
return createAxios({
url: refreshTokenUrl,
method: 'POST',
data: {
refreshToken: adminInfo.getToken('refresh'),
},
})
}
/**
* 生成一个控制器的:增、删、改、查、排序的操作url
*/
export class baTableApi {
private controllerUrl
public actionUrl
constructor(controllerUrl: string) {
this.controllerUrl = controllerUrl
this.actionUrl = new Map([
['index', controllerUrl + 'index'],
['add', controllerUrl + 'add'],
['edit', controllerUrl + 'edit'],
['del', controllerUrl + 'del'],
['sortable', controllerUrl + 'sortable'],
])
}
index(filter: anyObj = {}) {
return createAxios<TableDefaultData>({
url: this.actionUrl.get('index'),
method: 'get',
params: filter,
})
}
edit(params: anyObj) {
return createAxios({
url: this.actionUrl.get('edit'),
method: 'get',
params: params,
})
}
del(ids: string[]) {
return createAxios(
{
url: this.actionUrl.get('del'),
method: 'DELETE',
params: {
ids: ids,
},
},
{
showSuccessMessage: true,
}
)
}
postData(action: string, data: anyObj) {
return createAxios(
{
url: this.actionUrl.has(action) ? this.actionUrl.get(action) : this.controllerUrl + action,
method: 'post',
data: data,
},
{
showSuccessMessage: true,
}
)
}
sortable(data: anyObj) {
return createAxios({
url: this.actionUrl.get('sortable'),
method: 'post',
data: data,
})
}
}
启动之后现在最多请求远程接口有问题, 默认都是跳转到对应后台登录页面了, 但是这时候发现 i18 翻译错误,
因为默认配置路径变动之后 src/lang/autoload.ts 内部加载配置涵盖了我们之前的裁剪文件, 所以需要这里设置下:
// src/lang/autoload.ts 文件修改成以下内容
import {adminBaseRoutePath} from '/@/router/static/adminBase'
/*
* 语言包按需加载映射表
* 使用固定字符串 ${lang} 指代当前语言
* key 为页面 path,value 为语言包文件相对路径,访问时,按需自动加载映射表的语言包,同时加载 path 对应的语言包(若存在)
*/
export default {
[adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'],
[adminBaseRoutePath + '/user/rule']: ['./backend/${lang}/auth/rule.ts'],
[adminBaseRoutePath + '/user/scoreLog']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],
}
// src/lang/index.ts 文件修改成以下内容
import type {App} from 'vue'
import {createI18n} from 'vue-i18n'
import type {I18n, Composer} from 'vue-i18n'
import {useConfig} from '/@/stores/config'
import {isEmpty} from 'lodash-es'
/*
* 默认只引入 element-plus 的中英文语言包
* 其他语言包请自行在此 import,并添加到 assignLocale 内
* 动态 import 只支持相对路径,所以无法按需 import element-plus 的语言包
* 但i18n的 messages 内是按需载入的
*/
import elementZhcnLocale from 'element-plus/es/locale/lang/zh-cn'
import elementEnLocale from 'element-plus/es/locale/lang/en'
export let i18n: {
global: Composer
}
// 准备要合并的语言包
const assignLocale: anyObj = {
'zh-cn': [elementZhcnLocale],
en: [elementEnLocale],
}
export async function loadLang(app: App) {
const config = useConfig()
const locale = config.lang.defaultLang
// 加载框架全局语言包
const lang = await import(`./globs-${locale}.ts`)
const message = lang.default ?? {}
// 按需加载语言包文件的句柄
if (locale == 'zh-cn') {
window.loadLangHandle = {
...import.meta.glob('./backend/zh-cn/**/*.ts'),
...import.meta.glob('./backend/zh-cn.ts'),
}
} else {
window.loadLangHandle = {
...import.meta.glob('./backend/en/**/*.ts'),
...import.meta.glob('./backend/en.ts'),
}
}
/*
* 加载页面语言包 import.meta.glob 的路径不能使用变量 import() 在 Vite 中目录名不能使用变量(编译后,文件名可以)
*/
if (locale == 'zh-cn') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/zh-cn/**/*.ts', {eager: true}), locale))
} else if (locale == 'en') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/en/**/*.ts', {eager: true}), locale))
}
const messages = {
[locale]: {
...message,
},
}
// 合并语言包(含element-puls、页面语言包)
Object.assign(messages[locale], ...assignLocale[locale])
i18n = createI18n({
locale: locale,
legacy: false, // 组合式api
globalInjection: true, // 挂载$t,$d等到全局
fallbackLocale: config.lang.fallbackLang,
messages,
})
app.use(i18n as I18n)
return i18n
}
function getLangFileMessage(mList: any, locale: string) {
let msg: anyObj = {}
locale = '/' + locale
for (const path in mList) {
if (mList[path].default) {
// 获取文件名
const pathName = path.slice(path.lastIndexOf(locale) + (locale.length + 1), path.lastIndexOf('.'))
if (pathName.indexOf('/') > 0) {
msg = handleMsglist(msg, mList[path].default, pathName)
} else {
msg[pathName] = mList[path].default
}
}
}
return msg
}
export function mergeMessage(message: anyObj, pathName = '') {
if (isEmpty(message)) return
if (!pathName) {
return i18n.global.mergeLocaleMessage(i18n.global.locale.value, message)
}
let msg: anyObj = {}
if (pathName.indexOf('/') > 0) {
msg = handleMsglist(msg, message, pathName)
} else {
msg[pathName] = message
}
i18n.global.mergeLocaleMessage(i18n.global.locale.value, msg)
}
export function handleMsglist(msg: anyObj, mList: anyObj, pathName: string) {
const pathNameTmp = pathName.split('/')
let obj: anyObj = {}
for (let i = pathNameTmp.length - 1; i >= 0; i--) {
if (i == pathNameTmp.length - 1) {
obj = {
[pathNameTmp[i]]: mList,
}
} else {
obj = {
[pathNameTmp[i]]: obj,
}
}
}
return mergeMsg(msg, obj)
}
export function mergeMsg(msg: anyObj, obj: anyObj) {
for (const key in obj) {
if (typeof msg[key] == 'undefined') {
msg[key] = obj[key]
} else if (typeof msg[key] == 'object') {
msg[key] = mergeMsg(msg[key], obj[key])
}
}
return msg
}
export function editDefaultLang(lang: string): void {
const config = useConfig()
config.setLang(lang)
/*
* 语言包是按需加载的,比如默认语言为中文,则只在app实例内加载了中文语言包,所以切换语言需要进行 reload
*/
location.reload()
}
跳转路由配置 src/router/static.ts 文件重新修改下:
import type {RouteRecordRaw} from 'vue-router'
import {adminBaseRoutePath} from '/@/router/static/adminBase'
const pageTitle = (name: string): string => {
return `pagesTitle.${name}`
}
/*
* 静态路由
* 自动加载 ./static 目录的所有文件,并 push 到以下数组
*/
const staticRoutes: Array<RouteRecordRaw> = [
{
// 首页, 默认跳转登录
path: '/',
redirect: (to) => {
return {
name: 'adminLogin',
params: {
to: JSON.stringify({
path: to.path,
query: to.query,
}),
},
}
},
},
{
// 管理员登录页 - 不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局
path: adminBaseRoutePath + '/login',
name: 'adminLogin',
component: () => import('/@/views/backend/login.vue'),
meta: {
title: pageTitle('adminLogin'),
},
},
{
path: '/:path(.*)*',
redirect: '/404',
},
{
// 404
path: '/404',
name: 'notFound',
component: () => import('/@/views/common/error/404.vue'),
meta: {
title: pageTitle('notFound'), // 页面不存在
},
},
{
// 后台找不到页面了-可能是路由未加载上
path: adminBaseRoutePath + ':path(.*)*',
redirect: (to) => {
return {
name: 'adminMainLoading',
params: {
to: JSON.stringify({
path: to.path,
query: to.query,
}),
},
}
},
},
{
// 无权限访问
path: '/401',
name: 'noPower',
component: () => import('/@/views/common/error/401.vue'),
meta: {
title: pageTitle('noPower'),
},
},
]
const staticFiles: Record<string, Record<string, RouteRecordRaw>> = import.meta.glob('./static/*.ts', {eager: true})
for (const key in staticFiles) {
if (staticFiles[key].default) staticRoutes.push(staticFiles[key].default)
}
export default staticRoutes
另外这里有个瑕疵就是默认请求 avatar.png 登录的时候默认头像, 他是请求自己服务端的的空白头像图片但是 web 前端默认是没有,
所以需要将服务端文件复制到前端 public 路径, 之后在 src/views/backend/login.vue 文件检索
fullUrl('/static/images/avatar.png') 让其改成本地读取即可:
<!-- <img :src="fullUrl('/static/images/avatar.png')" alt="" class="profile-avatar"/> -->
<img :src="'/static/images/avatar.png'" alt="" class="profile-avatar"/>
至此目前界面已经基本搭建完成, 之后就是接口对接的问题了.
接口对接
这里需要说明这个UI模板库的相应结构是将状态封装到返回响应:
// 进入页面初始化会请求在服务端生成会话标识用验证或者验证码信息
// 直接GET无参数请求, 响应结构如下
{
"code": 1,
"msg": "",
"time": 1737872453,
"data": {
// 是否需要启用验证码认证, 如果是则必须要客户都安提供验证码
"captcha": true
}
}
// 登录结构请求对象
{
"username": "admin",
"password": "123456",
"keep": false,
// 下面是第三方图像|自己提供的验证码认证信息
// 如果是自己做的验证兑换码, captchaId 可以为空甚至不需要
// captchaInfo 就是最后提交兑换码信息
"captchaId": "",
"captchaInfo": "39,36-292,152;350;200"
}
// 登录结构响应对象
{
"code": 1,
"msg": "登录成功!",
"time": 1737872481,
"data": {
"userInfo": {
"id": 1,
"username": "admin",
"nickname": "Admin",
"avatar": "/static/images/avatar.png",
"last_login_time": "2025-01-26 14:21:21",
"token": "0dbe6dfd-3449-4b2c-b5b2-1af8502517bd",
"refresh_token": ""
}
}
}
这里我们有自己平台授权高度标准处理的 RestAPI 方式, 数据格式如下:
// 请求 METHOD 代表数据处理方式, GET 是获取数据, POST 是创建, PUT 是修改, DELETE 是删除
// 比如登录实际上是创建会话操作, 需要 axios 来采用 POST 方法提交数据, 需要 application/json 的 raw 直接提交
// 而查询用户数据代表获取数据操作, 需要 axios 来采用 GET 方式并且参数以 QueryParams 的 '?xxx=yyy' 方式提交
// 修改和删除操作则是要在路径追加标识提交, 比如修改 id = 1000 的信息就推送 PUT 方式 '/item/1000' 需要 application/json 的 raw 直接提交
// 分页查询, 我们 SDK 以 0 做起始页码, 第1页传入 0, 第2页传入 1
// GET /search?page=0&limit=5
// 同样我们响应码可以采用HTTP响应码方式让客户端捕获
所以目前整个 API 请求和响应方式都是无法满足的, 需要重新构建出整个接口请求层.
其实更加推荐学习内部流程和目录封装模式, 之后重新构建个项目引入这种方式处理
这就是整套脚手架具体工作流程, 后续就是业务代码和接口对接的事情, 但是如果你本身仅仅做游戏内部系统的话很多东西是不合理的:
i18多语言: 游戏内部系统一般综合语言统一, 外服游戏数据是隔离出来, 也就是单独另外搭建本地化语种从而避开某些风俗和法律问题多系统化集成: 多个系统混合在单一项目内容易被人家通过Web端解包|嗅探出敏感功能响应格式不规范: 响应最好严格按照RestApi设计, 采用响应码获取到数据状态时间戳: 响应数据内部的时间不再是以时间戳, 而是采用格林尼治时间带有时区信息从而方便时间转换
如果时间充足的话, 上面那些项目搭建之后建议只参考规范和请求方法从而重新搭建引入自己私有脚手架.