mirror of
https://github.com/KwiTsukasa/kt-template-online-web.git
synced 2026-05-27 16:35:47 +08:00
feat(web): 接入后台认证流程
This commit is contained in:
parent
9cc0ce21b8
commit
3f57bcb543
@ -1,5 +1,6 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
VITE_APP_PLAY_GROUND=http://localhost:48090
|
VITE_APP_PLAY_GROUND=http://localhost:48090
|
||||||
VITE_APP_PROXY=http://localhost:48085/
|
VITE_APP_PROXY=http://localhost:48085/
|
||||||
|
VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login
|
||||||
VITE_APP_BASE_API=/api
|
VITE_APP_BASE_API=/api
|
||||||
VITE_APP_OSS_DOMAIN=/chart-assets
|
VITE_APP_OSS_DOMAIN=/chart-assets
|
||||||
|
|||||||
19
.eslintrc.cjs
Normal file
19
.eslintrc.cjs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: ['plugin:vue/vue3-essential'],
|
||||||
|
parser: 'vue-eslint-parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint', 'vue'],
|
||||||
|
rules: {
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@ -0,0 +1 @@
|
|||||||
|
node scripts/validate-commit-msg.mjs "$1"
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@ -0,0 +1 @@
|
|||||||
|
pnpm run verify:commit
|
||||||
@ -46,6 +46,7 @@ src
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
VITE_APP_PLAY_GROUND=http://localhost:48090
|
VITE_APP_PLAY_GROUND=http://localhost:48090
|
||||||
VITE_APP_PROXY=http://localhost:48085/
|
VITE_APP_PROXY=http://localhost:48085/
|
||||||
|
VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login
|
||||||
VITE_APP_BASE_API=/api
|
VITE_APP_BASE_API=/api
|
||||||
VITE_APP_OSS_DOMAIN=/chart-assets
|
VITE_APP_OSS_DOMAIN=/chart-assets
|
||||||
```
|
```
|
||||||
@ -56,6 +57,7 @@ VITE_APP_OSS_DOMAIN=/chart-assets
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `VITE_APP_PLAY_GROUND` | Playground 地址,用于新增、编辑和分享链接 |
|
| `VITE_APP_PLAY_GROUND` | Playground 地址,用于新增、编辑和分享链接 |
|
||||||
| `VITE_APP_PROXY` | 后端服务地址,Vite dev server 会把 `/api` 代理到这里 |
|
| `VITE_APP_PROXY` | 后端服务地址,Vite dev server 会把 `/api` 代理到这里 |
|
||||||
|
| `VITE_APP_ADMIN_LOGIN` | 后台登录页地址,组件接口 `401` 时会带 `redirect` 跳转到这里 |
|
||||||
| `VITE_APP_BASE_API` | API 前缀,当前代码使用 `/api` |
|
| `VITE_APP_BASE_API` | API 前缀,当前代码使用 `/api` |
|
||||||
| `VITE_APP_OSS_DOMAIN` | 静态资源域名预留配置 |
|
| `VITE_APP_OSS_DOMAIN` | 静态资源域名预留配置 |
|
||||||
|
|
||||||
@ -82,6 +84,7 @@ pnpm deploy # 构建并执行部署脚本
|
|||||||
接口集中在 `src/api`:
|
接口集中在 `src/api`:
|
||||||
|
|
||||||
- `request.ts`:axios 实例,`baseURL` 来自 `src/config.ts`。
|
- `request.ts`:axios 实例,`baseURL` 来自 `src/config.ts`。
|
||||||
|
- `auth.ts`:复用后台登录态,持久化 `accessToken`、用户信息和权限码,支持通过刷新 token cookie 自动续期。
|
||||||
- `component.ts`:组件列表、详情、删除。
|
- `component.ts`:组件列表、详情、删除。
|
||||||
- `dict.ts`:一级/二级字典查询。
|
- `dict.ts`:一级/二级字典查询。
|
||||||
|
|
||||||
@ -95,6 +98,8 @@ pnpm deploy # 构建并执行部署脚本
|
|||||||
| `GET` | `/dict/getDictByKey` | 查询一级字典 |
|
| `GET` | `/dict/getDictByKey` | 查询一级字典 |
|
||||||
| `GET` | `/dict/getComponentDictByType` | 根据一级类型查询二级类型 |
|
| `GET` | `/dict/getComponentDictByType` | 根据一级类型查询二级类型 |
|
||||||
|
|
||||||
|
组件接口需要后台登录态。请求层会先读取本地持久化的 accessToken;没有 token 时会通过 `/auth/refresh` 使用 cookie 刷新并持久化登录数据;接口返回 `401` 时会跳到 `VITE_APP_ADMIN_LOGIN`,登录成功后回到原页面。
|
||||||
|
|
||||||
## Playground 跳转
|
## Playground 跳转
|
||||||
|
|
||||||
`ChartList.vue` 负责构造 Playground 链接:
|
`ChartList.vue` 负责构造 Playground 链接:
|
||||||
|
|||||||
12
package.json
12
package.json
@ -6,8 +6,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
|
"prepare": "husky",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deploy": "pnpm build && npx shy ftp deploy -f ./deploy"
|
"deploy": "pnpm build && npx shy ftp deploy -f ./deploy",
|
||||||
|
"lint": "eslint \"src/**/*.{ts,vue}\"",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"verify:commit": "pnpm run lint && pnpm run typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
@ -21,11 +25,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^18.11.9",
|
"@types/node": "^18.11.9",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"@vitejs/plugin-vue": "^3.2.0",
|
"@vitejs/plugin-vue": "^3.2.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"sass": "^1.56.0",
|
"sass": "^1.56.0",
|
||||||
"typescript": "^4.6.4",
|
"typescript": "^4.6.4",
|
||||||
"unocss": "^0.61.8",
|
"unocss": "^0.61.8",
|
||||||
"vite": "^3.2.0",
|
"vite": "^3.2.0",
|
||||||
|
"vue-eslint-parser": "^9.4.3",
|
||||||
"vue-tsc": "^1.0.9"
|
"vue-tsc": "^1.0.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
990
pnpm-lock.yaml
990
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
24
scripts/validate-commit-msg.mjs
Normal file
24
scripts/validate-commit-msg.mjs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const messageFile = process.argv[2];
|
||||||
|
const firstLine = readFileSync(messageFile, 'utf8').split(/\r?\n/)[0].trim();
|
||||||
|
|
||||||
|
const releaseMessages = /^(?:Merge|Revert|fixup!|squash!)/;
|
||||||
|
const ktCommitPattern =
|
||||||
|
/^(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\([\w./-]+\))?: .+/;
|
||||||
|
const hasChineseText = /[\u4E00-\u9FFF]/;
|
||||||
|
|
||||||
|
if (releaseMessages.test(firstLine)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ktCommitPattern.test(firstLine) || !hasChineseText.test(firstLine)) {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
'提交信息格式不正确。',
|
||||||
|
'要求:英文类型前缀 + 可选 scope + 冒号空格 + 中文描述。',
|
||||||
|
'示例:feat(web): 增加提交校验',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
101
src/api/auth.ts
Normal file
101
src/api/auth.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
type VbenResponse<T = any> = {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersistedAuth = {
|
||||||
|
accessCodes?: string[];
|
||||||
|
accessToken: string;
|
||||||
|
userInfo?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = "kt-admin-access-token";
|
||||||
|
const ACCESS_CODES_KEY = "kt-admin-access-codes";
|
||||||
|
const USER_INFO_KEY = "kt-admin-user-info";
|
||||||
|
|
||||||
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
const authClient = axios.create({
|
||||||
|
baseURL: config.axiosBase,
|
||||||
|
timeout: 1000 * 30,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getStoredAccessToken = () => {
|
||||||
|
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearPersistedAuth = () => {
|
||||||
|
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
window.localStorage.removeItem(ACCESS_CODES_KEY);
|
||||||
|
window.localStorage.removeItem(USER_INFO_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const persistAuthData = ({
|
||||||
|
accessCodes,
|
||||||
|
accessToken,
|
||||||
|
userInfo,
|
||||||
|
}: PersistedAuth) => {
|
||||||
|
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||||
|
if (accessCodes) {
|
||||||
|
window.localStorage.setItem(ACCESS_CODES_KEY, JSON.stringify(accessCodes));
|
||||||
|
}
|
||||||
|
if (userInfo) {
|
||||||
|
window.localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectToAdminLogin = () => {
|
||||||
|
const loginUrl = new URL(config.adminLogin);
|
||||||
|
loginUrl.searchParams.set("redirect", encodeURIComponent(window.location.href));
|
||||||
|
window.location.href = loginUrl.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshPersistedAuth = async () => {
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const refreshResponse = await authClient.post<string>("/auth/refresh", {});
|
||||||
|
const accessToken = refreshResponse.data;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
clearPersistedAuth();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
};
|
||||||
|
const [userInfoResult, accessCodesResult] = await Promise.allSettled([
|
||||||
|
authClient.get<VbenResponse>("/user/info", { headers }),
|
||||||
|
authClient.get<VbenResponse<string[]>>("/auth/codes", { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
persistAuthData({
|
||||||
|
accessCodes:
|
||||||
|
accessCodesResult.status === "fulfilled"
|
||||||
|
? accessCodesResult.value.data.data
|
||||||
|
: undefined,
|
||||||
|
accessToken,
|
||||||
|
userInfo:
|
||||||
|
userInfoResult.status === "fulfilled"
|
||||||
|
? userInfoResult.value.data.data
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
|
} catch {
|
||||||
|
clearPersistedAuth();
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
};
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import axios, { AxiosRequestConfig } from "axios";
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
|
import {
|
||||||
|
clearPersistedAuth,
|
||||||
|
getStoredAccessToken,
|
||||||
|
redirectToAdminLogin,
|
||||||
|
refreshPersistedAuth,
|
||||||
|
} from "@/api/auth";
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
code: number;
|
code: number;
|
||||||
@ -10,6 +16,7 @@ export interface ApiResponse<T = any> {
|
|||||||
const request = axios.create({
|
const request = axios.create({
|
||||||
baseURL: config.axiosBase,
|
baseURL: config.axiosBase,
|
||||||
timeout: 1000 * 30,
|
timeout: 1000 * 30,
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const get = <T = any>(url: string, config?: AxiosRequestConfig) => {
|
export const get = <T = any>(url: string, config?: AxiosRequestConfig) => {
|
||||||
@ -20,6 +27,38 @@ export const post = <T = any>(url: string, data?: any, config?: AxiosRequestConf
|
|||||||
return request.post<any, ApiResponse<T>>(url, data, config);
|
return request.post<any, ApiResponse<T>>(url, data, config);
|
||||||
};
|
};
|
||||||
|
|
||||||
request.interceptors.response.use((response) => response.data);
|
request.interceptors.request.use(async (requestConfig) => {
|
||||||
|
let accessToken = getStoredAccessToken();
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
accessToken = await refreshPersistedAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
if (response.data?.code === 401) {
|
||||||
|
clearPersistedAuth();
|
||||||
|
redirectToAdminLogin();
|
||||||
|
return Promise.reject(new Error(response.data?.msg || "登录已过期"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
clearPersistedAuth();
|
||||||
|
redirectToAdminLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default request;
|
export default request;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { os } from "@/utils/detect";
|
import { os } from "@/utils/detect";
|
||||||
|
|
||||||
const config = (() => ({
|
const config = (() => ({
|
||||||
|
adminLogin: import.meta.env.VITE_APP_ADMIN_LOGIN || `${window.location.protocol}//${window.location.hostname}:5999/auth/login`,
|
||||||
isMobileApp: !os.desktop,
|
isMobileApp: !os.desktop,
|
||||||
axiosBase: "/api",
|
axiosBase: "/api",
|
||||||
playground: import.meta.env.VITE_APP_PLAY_GROUND,
|
playground: import.meta.env.VITE_APP_PLAY_GROUND,
|
||||||
}))();
|
}))();
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user