feat(web): 接入后台认证流程

This commit is contained in:
sunlei 2026-05-16 20:14:32 +08:00
parent 9cc0ce21b8
commit 3f57bcb543
11 changed files with 1195 additions and 4 deletions

View File

@ -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
View 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
View File

@ -0,0 +1 @@
node scripts/validate-commit-msg.mjs "$1"

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
pnpm run verify:commit

View File

@ -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 链接:

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

View 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
View 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;
};

View File

@ -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;

View File

@ -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;