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

This commit is contained in:
sunlei 2026-05-16 20:14:40 +08:00
parent 99236ba861
commit 21a7e8dbfc
10 changed files with 209 additions and 14 deletions

View File

@ -1,2 +1,3 @@
VITE_APP_API_BASE=/api
VITE_APP_PROXY=http://localhost:48085/
VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login

1
.husky/commit-msg Normal file
View File

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

5
.husky/pre-commit Normal file
View File

@ -0,0 +1,5 @@
if grep -q '"lint-staged"' package.json; then
pnpm exec lint-staged --concurrent false
fi
pnpm run verify:commit

View File

@ -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_ADMIN_LOGIN` | 后台登录页地址,保存组件接口 `401` 时会带 `redirect` 跳转到这里 |
## 启动
@ -103,7 +105,8 @@ 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`:截图文件上传。
@ -111,13 +114,15 @@ http://localhost:48090/?id=xxx&name=基础折线图&type=1&componentType=1#...
当前主要接口:
| 方法 | 地址 | 用途 |
| --- | --- | --- |
| ------ | ------------------------------ | ------------ |
| `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`,登录成功后回到原页面继续保存流程。
## 开发约定
- 保持 query 和 hash 分工,不要让 `store.serialize()` 覆盖 `location.search`

View File

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

View File

@ -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: {}

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(playground): 增加提交校验',
].join('\n'),
)
process.exit(1)
}

View File

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

114
src/api/auth.ts Normal file
View File

@ -0,0 +1,114 @@
import axios from 'axios'
type VbenResponse<T = unknown> = {
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
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<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,4 +1,10 @@
import axios, { type AxiosRequestConfig } from 'axios'
import {
clearPersistedAuth,
getStoredAccessToken,
redirectToAdminLogin,
refreshPersistedAuth,
} from './auth'
export type ApiResponse<T = unknown> = {
code: number
@ -9,6 +15,7 @@ export type ApiResponse<T = unknown> = {
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<any>
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<ApiResponse>(error)) {
if (error.response?.status === 401) {
clearPersistedAuth()
redirectToAdminLogin()
}
return Promise.reject(
new Error(error.response?.data?.msg || error.message || '请求失败'),
)