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_API_BASE=/api
|
||||||
VITE_APP_PROXY=http://localhost:48085/
|
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
|
```env
|
||||||
VITE_APP_API_BASE=/api
|
VITE_APP_API_BASE=/api
|
||||||
VITE_APP_PROXY=http://localhost:48085/
|
VITE_APP_PROXY=http://localhost:48085/
|
||||||
|
VITE_APP_ADMIN_LOGIN=http://localhost:5999/auth/login
|
||||||
```
|
```
|
||||||
|
|
||||||
关键变量:
|
关键变量:
|
||||||
|
|
||||||
| 变量 | 说明 |
|
| 变量 | 说明 |
|
||||||
| --- | --- |
|
| ---------------------- | --------------------------------------------------------------- |
|
||||||
| `VITE_APP_API_BASE` | 前端请求前缀,默认 `/api` |
|
| `VITE_APP_API_BASE` | 前端请求前缀,默认 `/api` |
|
||||||
| `VITE_APP_PROXY` | 后端服务地址,Vite dev server 会把 `/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`:
|
接口集中在 `src/api`:
|
||||||
|
|
||||||
- `request.ts`:axios 实例,统一处理 `code !== 200` 的错误。
|
- `request.ts`:axios 实例,统一处理 `code !== 200` 的错误,并在组件接口 `401` 时跳后台登录。
|
||||||
|
- `auth.ts`:复用后台登录态,持久化 `accessToken`、用户信息和权限码,支持通过刷新 token cookie 自动续期。
|
||||||
- `component.ts`:新增和编辑组件。
|
- `component.ts`:新增和编辑组件。
|
||||||
- `dict.ts`:组件类型字典。
|
- `dict.ts`:组件类型字典。
|
||||||
- `minio.ts`:截图文件上传。
|
- `minio.ts`:截图文件上传。
|
||||||
|
|
||||||
当前主要接口:
|
当前主要接口:
|
||||||
|
|
||||||
| 方法 | 地址 | 用途 |
|
| 方法 | 地址 | 用途 |
|
||||||
| --- | --- | --- |
|
| ------ | ------------------------------ | ------------ |
|
||||||
| `GET` | `/dict/getDictByKey` | 查询一级类型 |
|
| `GET` | `/dict/getDictByKey` | 查询一级类型 |
|
||||||
| `GET` | `/dict/getComponentDictByType` | 查询二级类型 |
|
| `GET` | `/dict/getComponentDictByType` | 查询二级类型 |
|
||||||
| `POST` | `/minio/upload` | 上传预览截图 |
|
| `POST` | `/minio/upload` | 上传预览截图 |
|
||||||
| `POST` | `/component/save` | 新增组件 |
|
| `POST` | `/component/save` | 新增组件 |
|
||||||
| `POST` | `/component/update` | 编辑组件 |
|
| `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",
|
"build-preview": "vite build -c vite.preview.config.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"prepare": "husky",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"verify:commit": "pnpm run lint && pnpm run typecheck",
|
||||||
"release": "bumpp --all",
|
"release": "bumpp --all",
|
||||||
"version": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
|
"version": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
@ -104,6 +106,7 @@
|
|||||||
"eslint-plugin-vue": "^10.4.0",
|
"eslint-plugin-vue": "^10.4.0",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"hash-sum": "^2.0.0",
|
"hash-sum": "^2.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.1.4",
|
"lint-staged": "^16.1.4",
|
||||||
"monaco-editor-core": "^0.52.2",
|
"monaco-editor-core": "^0.52.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
|||||||
@ -99,6 +99,9 @@ importers:
|
|||||||
hash-sum:
|
hash-sum:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
|
husky:
|
||||||
|
specifier: ^9.1.7
|
||||||
|
version: 9.1.7
|
||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^16.1.4
|
specifier: ^16.1.4
|
||||||
version: 16.1.4
|
version: 16.1.4
|
||||||
@ -1570,6 +1573,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||||
engines: {node: '>=8.0.0'}
|
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:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@ -4204,6 +4212,8 @@ snapshots:
|
|||||||
css-line-break: 2.1.0
|
css-line-break: 2.1.0
|
||||||
text-segmentation: 1.0.3
|
text-segmentation: 1.0.3
|
||||||
|
|
||||||
|
husky@9.1.7: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
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 html2canvas from 'html2canvas'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
|
type ComponentPayload,
|
||||||
saveComponent,
|
saveComponent,
|
||||||
updateComponent,
|
updateComponent,
|
||||||
type ComponentPayload,
|
|
||||||
} from './api/component'
|
} 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 { getResourceProxyEndpoint, uploadFile } from './api/minio'
|
||||||
import type { ReplStore } from './store'
|
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 axios, { type AxiosRequestConfig } from 'axios'
|
||||||
|
import {
|
||||||
|
clearPersistedAuth,
|
||||||
|
getStoredAccessToken,
|
||||||
|
redirectToAdminLogin,
|
||||||
|
refreshPersistedAuth,
|
||||||
|
} from './auth'
|
||||||
|
|
||||||
export type ApiResponse<T = unknown> = {
|
export type ApiResponse<T = unknown> = {
|
||||||
code: number
|
code: number
|
||||||
@ -9,6 +15,7 @@ export type ApiResponse<T = unknown> = {
|
|||||||
const request = axios.create({
|
const request = axios.create({
|
||||||
baseURL: import.meta.env.VITE_APP_API_BASE || '/api',
|
baseURL: import.meta.env.VITE_APP_API_BASE || '/api',
|
||||||
timeout: 1000 * 30,
|
timeout: 1000 * 30,
|
||||||
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function getApiUrl(url: string) {
|
export function getApiUrl(url: string) {
|
||||||
@ -19,10 +26,30 @@ export function getApiUrl(url: string) {
|
|||||||
return `${normalizedBase}${normalizedUrl}`
|
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(
|
request.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
const data = response.data as ApiResponse<any>
|
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) {
|
if (data.code !== 200) {
|
||||||
return Promise.reject(new Error(data.msg || '请求失败'))
|
return Promise.reject(new Error(data.msg || '请求失败'))
|
||||||
}
|
}
|
||||||
@ -31,6 +58,11 @@ request.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (axios.isAxiosError<ApiResponse>(error)) {
|
if (axios.isAxiosError<ApiResponse>(error)) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
clearPersistedAuth()
|
||||||
|
redirectToAdminLogin()
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error(error.response?.data?.msg || error.message || '请求失败'),
|
new Error(error.response?.data?.msg || error.message || '请求失败'),
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user