diff --git a/.env.example b/.env.example index 457efd9..4bfa60a 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VITE_APP_API_BASE=/api VITE_APP_PROXY=http://localhost:48085/ +VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..5c056bb --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +node scripts/validate-commit-msg.mjs "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..4331a63 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +if grep -q '"lint-staged"' package.json; then + pnpm exec lint-staged --concurrent false +fi + +pnpm run verify:commit diff --git a/README.md b/README.md index 85dd87d..f568bc7 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,16 @@ test/main.ts # 本地 Playground 页面入口 ```env VITE_APP_API_BASE=/api VITE_APP_PROXY=http://localhost:48085/ +VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login ``` 关键变量: -| 变量 | 说明 | -| --- | --- | -| `VITE_APP_API_BASE` | 前端请求前缀,默认 `/api` | -| `VITE_APP_PROXY` | 后端服务地址,Vite dev server 会把 `/api` 代理到这里 | +| 变量 | 说明 | +| ---------------------- | --------------------------------------------------------------- | +| `VITE_APP_API_BASE` | 前端请求前缀,默认 `/api` | +| `VITE_APP_PROXY` | 后端服务地址,Vite dev server 会把 `/api` 代理到这里 | +| `VITE_APP_ADMIN_LOGIN` | 后台登录页地址,保存组件接口 `401` 时会带 `redirect` 跳转到这里 | ## 启动 @@ -103,20 +105,23 @@ http://localhost:48090/?id=xxx&name=基础折线图&type=1&componentType=1#... 接口集中在 `src/api`: -- `request.ts`:axios 实例,统一处理 `code !== 200` 的错误。 +- `request.ts`:axios 实例,统一处理 `code !== 200` 的错误,并在组件接口 `401` 时跳后台登录。 +- `auth.ts`:复用后台登录态,持久化 `accessToken`、用户信息和权限码,支持通过刷新 token cookie 自动续期。 - `component.ts`:新增和编辑组件。 - `dict.ts`:组件类型字典。 - `minio.ts`:截图文件上传。 当前主要接口: -| 方法 | 地址 | 用途 | -| --- | --- | --- | -| `GET` | `/dict/getDictByKey` | 查询一级类型 | -| `GET` | `/dict/getComponentDictByType` | 查询二级类型 | -| `POST` | `/minio/upload` | 上传预览截图 | -| `POST` | `/component/save` | 新增组件 | -| `POST` | `/component/update` | 编辑组件 | +| 方法 | 地址 | 用途 | +| ------ | ------------------------------ | ------------ | +| `GET` | `/dict/getDictByKey` | 查询一级类型 | +| `GET` | `/dict/getComponentDictByType` | 查询二级类型 | +| `POST` | `/minio/upload` | 上传预览截图 | +| `POST` | `/component/save` | 新增组件 | +| `POST` | `/component/update` | 编辑组件 | + +保存组件需要后台登录态。请求层会先读取本地持久化的 accessToken;没有 token 时会通过 `/auth/refresh` 使用 cookie 刷新并持久化登录数据;接口返回 `401` 时会跳到 `VITE_APP_ADMIN_LOGIN`,登录成功后回到原页面继续保存流程。 ## 开发约定 diff --git a/package.json b/package.json index d54eecf..ba709f5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "build-preview": "vite build -c vite.preview.config.ts", "format": "prettier --write .", "lint": "eslint .", + "prepare": "husky", "typecheck": "vue-tsc --noEmit", + "verify:commit": "pnpm run lint && pnpm run typecheck", "release": "bumpp --all", "version": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "prepublishOnly": "npm run build" @@ -104,6 +106,7 @@ "eslint-plugin-vue": "^10.4.0", "fflate": "^0.8.2", "hash-sum": "^2.0.0", + "husky": "^9.1.7", "lint-staged": "^16.1.4", "monaco-editor-core": "^0.52.2", "prettier": "^3.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31c9f9d..1664c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: hash-sum: specifier: ^2.0.0 version: 2.0.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 lint-staged: specifier: ^16.1.4 version: 16.1.4 @@ -1570,6 +1573,11 @@ packages: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4204,6 +4212,8 @@ snapshots: css-line-break: 2.1.0 text-segmentation: 1.0.3 + husky@9.1.7: {} + ignore@5.3.2: {} ignore@7.0.5: {} diff --git a/scripts/validate-commit-msg.mjs b/scripts/validate-commit-msg.mjs new file mode 100644 index 0000000..36720bd --- /dev/null +++ b/scripts/validate-commit-msg.mjs @@ -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(playground): 增加提交校验', + ].join('\n'), + ) + process.exit(1) +} diff --git a/src/PlaygroundHeader.vue b/src/PlaygroundHeader.vue index a4c7267..1725227 100644 --- a/src/PlaygroundHeader.vue +++ b/src/PlaygroundHeader.vue @@ -2,11 +2,11 @@ import html2canvas from 'html2canvas' import { computed, onMounted, reactive, ref } from 'vue' import { + type ComponentPayload, saveComponent, updateComponent, - type ComponentPayload, } from './api/component' -import { getComponentDictByType, getDictByKey, type DictItem } from './api/dict' +import { type DictItem, getComponentDictByType, getDictByKey } from './api/dict' import { getResourceProxyEndpoint, uploadFile } from './api/minio' import type { ReplStore } from './store' diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..1e2d5b9 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,114 @@ +import axios from 'axios' + +type VbenResponse = { + 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 | null = null + +function getApiBase() { + return import.meta.env.VITE_APP_API_BASE || '/api' +} + +function getAdminLogin() { + return ( + import.meta.env.VITE_APP_ADMIN_LOGIN || + `${window.location.protocol}//${window.location.hostname}:5999/auth/login` + ) +} + +const authClient = axios.create({ + baseURL: getApiBase(), + timeout: 1000 * 30, + withCredentials: true, +}) + +export function getStoredAccessToken() { + return window.localStorage.getItem(ACCESS_TOKEN_KEY) +} + +export function clearPersistedAuth() { + window.localStorage.removeItem(ACCESS_TOKEN_KEY) + window.localStorage.removeItem(ACCESS_CODES_KEY) + window.localStorage.removeItem(USER_INFO_KEY) +} + +export function 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 function redirectToAdminLogin() { + const loginUrl = new URL(getAdminLogin()) + loginUrl.searchParams.set( + 'redirect', + encodeURIComponent(window.location.href), + ) + window.location.href = loginUrl.toString() +} + +export async function refreshPersistedAuth() { + if (refreshPromise) return refreshPromise + + refreshPromise = (async () => { + try { + const refreshResponse = await authClient.post('/auth/refresh', {}) + const accessToken = refreshResponse.data + + if (!accessToken) { + clearPersistedAuth() + return null + } + + const headers = { + Authorization: `Bearer ${accessToken}`, + } + const [userInfoResult, accessCodesResult] = await Promise.allSettled([ + authClient.get('/user/info', { headers }), + authClient.get>('/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 +} diff --git a/src/api/request.ts b/src/api/request.ts index ef3b03f..4f30d7a 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -1,4 +1,10 @@ import axios, { type AxiosRequestConfig } from 'axios' +import { + clearPersistedAuth, + getStoredAccessToken, + redirectToAdminLogin, + refreshPersistedAuth, +} from './auth' export type ApiResponse = { code: number @@ -9,6 +15,7 @@ export type ApiResponse = { const request = axios.create({ baseURL: import.meta.env.VITE_APP_API_BASE || '/api', timeout: 1000 * 30, + withCredentials: true, }) export function getApiUrl(url: string) { @@ -19,10 +26,30 @@ export function getApiUrl(url: string) { return `${normalizedBase}${normalizedUrl}` } +request.interceptors.request.use(async (config) => { + let accessToken = getStoredAccessToken() + + if (!accessToken) { + accessToken = await refreshPersistedAuth() + } + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}` + } + + return config +}) + request.interceptors.response.use( (response) => { const data = response.data as ApiResponse + if (response.status === 401 || data.code === 401) { + clearPersistedAuth() + redirectToAdminLogin() + return Promise.reject(new Error(data.msg || '登录已过期')) + } + if (data.code !== 200) { return Promise.reject(new Error(data.msg || '请求失败')) } @@ -31,6 +58,11 @@ request.interceptors.response.use( }, (error) => { if (axios.isAxiosError(error)) { + if (error.response?.status === 401) { + clearPersistedAuth() + redirectToAdminLogin() + } + return Promise.reject( new Error(error.response?.data?.msg || error.message || '请求失败'), )