mirror of
https://github.com/KwiTsukasa/kt-template-online-playground.git
synced 2026-05-27 16:45:45 +08:00
feat(playground): 接入后台认证流程
This commit is contained in:
parent
99236ba861
commit
21a7e8dbfc
@ -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
1
.husky/commit-msg
Normal file
@ -0,0 +1 @@
|
||||
node scripts/validate-commit-msg.mjs "$1"
|
||||
5
.husky/pre-commit
Normal file
5
.husky/pre-commit
Normal file
@ -0,0 +1,5 @@
|
||||
if grep -q '"lint-staged"' package.json; then
|
||||
pnpm exec lint-staged --concurrent false
|
||||
fi
|
||||
|
||||
pnpm run verify:commit
|
||||
29
README.md
29
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`,登录成功后回到原页面继续保存流程。
|
||||
|
||||
## 开发约定
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {}
|
||||
|
||||
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(playground): 增加提交校验',
|
||||
].join('\n'),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
@ -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
114
src/api/auth.ts
Normal 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
|
||||
}
|
||||
@ -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 || '请求失败'),
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user