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
|
||||
VITE_APP_PLAY_GROUND=http://localhost:48090
|
||||
VITE_APP_PROXY=http://localhost:48085/
|
||||
VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login
|
||||
VITE_APP_BASE_API=/api
|
||||
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
|
||||
VITE_APP_PLAY_GROUND=http://localhost:48090
|
||||
VITE_APP_PROXY=http://localhost:48085/
|
||||
VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login
|
||||
VITE_APP_BASE_API=/api
|
||||
VITE_APP_OSS_DOMAIN=/chart-assets
|
||||
```
|
||||
@ -56,6 +57,7 @@ VITE_APP_OSS_DOMAIN=/chart-assets
|
||||
| --- | --- |
|
||||
| `VITE_APP_PLAY_GROUND` | Playground 地址,用于新增、编辑和分享链接 |
|
||||
| `VITE_APP_PROXY` | 后端服务地址,Vite dev server 会把 `/api` 代理到这里 |
|
||||
| `VITE_APP_ADMIN_LOGIN` | 后台登录页地址,组件接口 `401` 时会带 `redirect` 跳转到这里 |
|
||||
| `VITE_APP_BASE_API` | API 前缀,当前代码使用 `/api` |
|
||||
| `VITE_APP_OSS_DOMAIN` | 静态资源域名预留配置 |
|
||||
|
||||
@ -82,6 +84,7 @@ pnpm deploy # 构建并执行部署脚本
|
||||
接口集中在 `src/api`:
|
||||
|
||||
- `request.ts`:axios 实例,`baseURL` 来自 `src/config.ts`。
|
||||
- `auth.ts`:复用后台登录态,持久化 `accessToken`、用户信息和权限码,支持通过刷新 token cookie 自动续期。
|
||||
- `component.ts`:组件列表、详情、删除。
|
||||
- `dict.ts`:一级/二级字典查询。
|
||||
|
||||
@ -95,6 +98,8 @@ pnpm deploy # 构建并执行部署脚本
|
||||
| `GET` | `/dict/getDictByKey` | 查询一级字典 |
|
||||
| `GET` | `/dict/getComponentDictByType` | 根据一级类型查询二级类型 |
|
||||
|
||||
组件接口需要后台登录态。请求层会先读取本地持久化的 accessToken;没有 token 时会通过 `/auth/refresh` 使用 cookie 刷新并持久化登录数据;接口返回 `401` 时会跳到 `VITE_APP_ADMIN_LOGIN`,登录成功后回到原页面。
|
||||
|
||||
## Playground 跳转
|
||||
|
||||
`ChartList.vue` 负责构造 Playground 链接:
|
||||
|
||||
14
package.json
14
package.json
@ -6,8 +6,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"prepare": "husky",
|
||||
"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": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
@ -21,11 +25,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.11.9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.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",
|
||||
"typescript": "^4.6.4",
|
||||
"unocss": "^0.61.8",
|
||||
"vite": "^3.2.0",
|
||||
"vue-eslint-parser": "^9.4.3",
|
||||
"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 config from "@/config";
|
||||
import {
|
||||
clearPersistedAuth,
|
||||
getStoredAccessToken,
|
||||
redirectToAdminLogin,
|
||||
refreshPersistedAuth,
|
||||
} from "@/api/auth";
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
@ -10,6 +16,7 @@ export interface ApiResponse<T = any> {
|
||||
const request = axios.create({
|
||||
baseURL: config.axiosBase,
|
||||
timeout: 1000 * 30,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { os } from "@/utils/detect";
|
||||
|
||||
const config = (() => ({
|
||||
adminLogin: import.meta.env.VITE_APP_ADMIN_LOGIN || `${window.location.protocol}//${window.location.hostname}:5999/auth/login`,
|
||||
isMobileApp: !os.desktop,
|
||||
axiosBase: "/api",
|
||||
playground: import.meta.env.VITE_APP_PLAY_GROUND,
|
||||
}))();
|
||||
|
||||
export default config;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user