feat: 完成基础工程编写
This commit is contained in:
commit
d4bf91868d
5
.env.prod
Normal file
5
.env.prod
Normal file
@ -0,0 +1,5 @@
|
||||
VITE_API_URL='api'
|
||||
VITE_DEV_API_URL='api'
|
||||
VITE_STATE='dev' # dev or prod
|
||||
VITE_PROD_API_URL=''
|
||||
VITE_BCRYPT_KEY=''
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
BACKAPI.md
|
||||
LOG.md
|
||||
REQUIRE.md
|
||||
SKILL.md
|
||||
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Bastion SSO Frontend
|
||||
|
||||
基于 `Vite + Vue3 + TypeScript + Element Plus` 的统一 SSO 登录系统前端。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 页面模块
|
||||
|
||||
- 登录
|
||||
- 仪表盘
|
||||
- 用户管理
|
||||
- 角色管理
|
||||
- 权限管理
|
||||
- 服务器资源管理
|
||||
- 堡垒机账号管理
|
||||
- 访问日志查询
|
||||
|
||||
## 说明
|
||||
|
||||
- 使用 JWT Bearer Token(cookie)进行鉴权。
|
||||
- 通过 `/auth/me` 获取用户权限并控制菜单和按钮显隐。
|
||||
- 统一处理 `401/403/422` 常见错误场景。
|
||||
71
auto-imports.d.ts
vendored
Normal file
71
auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
51
components.d.ts
vendored
Normal file
51
components.d.ts
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascaderPanel: typeof import('element-plus/es')['ElCascaderPanel']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bastion_sso</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1246
package-lock.json
generated
Normal file
1246
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "bastion_sso",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/integrations": "^13.5.0",
|
||||
"axios": "^1.10.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"element-plus": "^2.10.3",
|
||||
"pinia": "^3.0.3",
|
||||
"universal-cookie": "^8.0.1",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.5.1",
|
||||
"vuex": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"sass-embedded": "^1.90.0",
|
||||
"typescript": "~6.0.2",
|
||||
"unocss": "^66.6.8",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^8.0.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.7",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
3
src/App.vue
Normal file
3
src/App.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
22
src/api/accounts.ts
Normal file
22
src/api/accounts.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import request from '@/axios'
|
||||
|
||||
export const accountsApi = {
|
||||
list(params: Record<string, unknown> = {}) {
|
||||
return request.get('/accounts', { params })
|
||||
},
|
||||
create(data: Record<string, unknown>) {
|
||||
return request.post('/accounts', data)
|
||||
},
|
||||
update(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/accounts/${id}`, data)
|
||||
},
|
||||
remove(id: number) {
|
||||
return request.delete(`/accounts/${id}`)
|
||||
},
|
||||
refreshToken(id: number) {
|
||||
return request.post(`/accounts/${id}/refresh-token`)
|
||||
},
|
||||
refreshTokenStatus(id: number, taskId: string) {
|
||||
return request.get(`/accounts/${id}/refresh-token/${taskId}`)
|
||||
},
|
||||
}
|
||||
42
src/api/auth.ts
Normal file
42
src/api/auth.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import request, { type ApiResponse } from '@/axios'
|
||||
|
||||
interface LoginPayload {
|
||||
email?: string
|
||||
phone?: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface LoginData {
|
||||
token: string
|
||||
type: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
interface MeData {
|
||||
user: {
|
||||
id: number
|
||||
nickname: string
|
||||
email: string
|
||||
phone?: string
|
||||
roles: Array<{ id: number; name: string; guard_name: string }>
|
||||
}
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login(data: LoginPayload) {
|
||||
return request.post<unknown, ApiResponse<LoginData>>('/auth/login', data)
|
||||
},
|
||||
me() {
|
||||
return request.get<unknown, ApiResponse<MeData>>('/auth/me')
|
||||
},
|
||||
updateProfile(data: { nickname?: string; email?: string; phone?: string }) {
|
||||
return request.put('/auth/profile', data)
|
||||
},
|
||||
updatePassword(data: { current_password: string; password: string; password_confirmation: string }) {
|
||||
return request.put('/auth/password', data)
|
||||
},
|
||||
logout() {
|
||||
return request.post<unknown, ApiResponse<null>>('/auth/logout')
|
||||
},
|
||||
}
|
||||
10
src/api/logs.ts
Normal file
10
src/api/logs.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import request from '@/axios'
|
||||
|
||||
export const logsApi = {
|
||||
list(params: Record<string, unknown> = {}) {
|
||||
return request.get('/logs', { params })
|
||||
},
|
||||
create(data: Record<string, unknown>) {
|
||||
return request.post('/logs', data)
|
||||
},
|
||||
}
|
||||
16
src/api/permissions.ts
Normal file
16
src/api/permissions.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import request from '@/axios'
|
||||
|
||||
export const permissionsApi = {
|
||||
list(params: Record<string, unknown> = {}) {
|
||||
return request.get('/permissions', { params })
|
||||
},
|
||||
create(data: Record<string, unknown>) {
|
||||
return request.post('/permissions', data)
|
||||
},
|
||||
update(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/permissions/${id}`, data)
|
||||
},
|
||||
remove(id: number) {
|
||||
return request.delete(`/permissions/${id}`)
|
||||
},
|
||||
}
|
||||
19
src/api/roles.ts
Normal file
19
src/api/roles.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import request from '@/axios'
|
||||
|
||||
export const rolesApi = {
|
||||
list(params: Record<string, unknown> = {}) {
|
||||
return request.get('/roles', { params })
|
||||
},
|
||||
create(data: Record<string, unknown>) {
|
||||
return request.post('/roles', data)
|
||||
},
|
||||
update(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/roles/${id}`, data)
|
||||
},
|
||||
remove(id: number) {
|
||||
return request.delete(`/roles/${id}`)
|
||||
},
|
||||
syncPermissions(id: number, permissionIds: number[]) {
|
||||
return request.put(`/roles/${id}/permissions`, { permission_ids: permissionIds })
|
||||
},
|
||||
}
|
||||
52
src/api/servers.ts
Normal file
52
src/api/servers.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import request from '@/axios'
|
||||
|
||||
export const serversApi = {
|
||||
list(params: Record<string, unknown> = {}) {
|
||||
return request.get('/servers', { params })
|
||||
},
|
||||
create(data: Record<string, unknown>) {
|
||||
return request.post('/servers', data)
|
||||
},
|
||||
update(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/servers/${id}`, data)
|
||||
},
|
||||
remove(id: number) {
|
||||
return request.delete(`/servers/${id}`)
|
||||
},
|
||||
userPermissions(id: number, params: Record<string, unknown> = {}) {
|
||||
return request.get(`/servers/${id}/user-permissions`, { params })
|
||||
},
|
||||
syncUserPermissions(id: number, users: Array<Record<string, unknown>>, partial = false) {
|
||||
return request.put(`/servers/${id}/user-permissions`, { users, partial })
|
||||
},
|
||||
useResource(id: number, data: Record<string, unknown>) {
|
||||
return request.post(`/servers/${id}/use`, data)
|
||||
},
|
||||
opsMeta() {
|
||||
return request.get('/ops-clients/meta')
|
||||
},
|
||||
createOpsProtocol(data: Record<string, unknown>) {
|
||||
return request.post('/ops-clients/protocols', data)
|
||||
},
|
||||
updateOpsProtocol(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/ops-clients/protocols/${id}`, data)
|
||||
},
|
||||
removeOpsProtocol(id: number) {
|
||||
return request.delete(`/ops-clients/protocols/${id}`)
|
||||
},
|
||||
createOpsSoftware(protocolId: number, data: Record<string, unknown>) {
|
||||
return request.post(`/ops-clients/protocols/${protocolId}/softwares`, data)
|
||||
},
|
||||
updateOpsSoftware(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/ops-clients/softwares/${id}`, data)
|
||||
},
|
||||
removeOpsSoftware(id: number) {
|
||||
return request.delete(`/ops-clients/softwares/${id}`)
|
||||
},
|
||||
saveOpsPreferences(items: Array<Record<string, unknown>>) {
|
||||
return request.put('/ops-clients/preferences', { items })
|
||||
},
|
||||
generateOpsLink(data: Record<string, unknown>) {
|
||||
return request.post('/ops-clients/link', data)
|
||||
},
|
||||
}
|
||||
22
src/api/users.ts
Normal file
22
src/api/users.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import request from '@/axios'
|
||||
|
||||
export const usersApi = {
|
||||
list(params: Record<string, unknown> = {}) {
|
||||
return request.get('/users', { params })
|
||||
},
|
||||
detail(id: number) {
|
||||
return request.get(`/users/${id}`)
|
||||
},
|
||||
create(data: Record<string, unknown>) {
|
||||
return request.post('/users', data)
|
||||
},
|
||||
update(id: number, data: Record<string, unknown>) {
|
||||
return request.put(`/users/${id}`, data)
|
||||
},
|
||||
remove(id: number) {
|
||||
return request.delete(`/users/${id}`)
|
||||
},
|
||||
syncPermissions(id: number, permissionIds: number[]) {
|
||||
return request.put(`/users/${id}/permissions`, { permission_ids: permissionIds })
|
||||
},
|
||||
}
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
167
src/assets/vite.svg
Normal file
167
src/assets/vite.svg
Normal file
@ -0,0 +1,167 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title"
|
||||
viewBox="0 0 77 47">
|
||||
<title id="vite-logo-title">Vite</title>
|
||||
<style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style>
|
||||
<path fill="#9135ff"
|
||||
d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/>
|
||||
<mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha">
|
||||
<path fill="#000"
|
||||
d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/>
|
||||
</mask>
|
||||
<g mask="url(#a)">
|
||||
<g filter="url(#b)">
|
||||
<ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704"
|
||||
transform="rotate(269.814 20.96 11.29)scale(-1 1)"/>
|
||||
</g>
|
||||
<g filter="url(#c)">
|
||||
<ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851"
|
||||
transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/>
|
||||
</g>
|
||||
<g filter="url(#d)">
|
||||
<ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487"
|
||||
transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/>
|
||||
</g>
|
||||
<g filter="url(#e)">
|
||||
<ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599"
|
||||
transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/>
|
||||
</g>
|
||||
<g filter="url(#f)">
|
||||
<ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599"
|
||||
transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/>
|
||||
</g>
|
||||
<g filter="url(#g)">
|
||||
<ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078"
|
||||
transform="rotate(93.35 31.245 55.578)scale(-1 1)"/>
|
||||
</g>
|
||||
<g filter="url(#h)">
|
||||
<ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501"
|
||||
transform="rotate(89.009 35.419 55.202)scale(-1 1)"/>
|
||||
</g>
|
||||
<g filter="url(#i)">
|
||||
<ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501"
|
||||
transform="rotate(89.009 35.419 55.202)scale(-1 1)"/>
|
||||
</g>
|
||||
<g filter="url(#j)">
|
||||
<ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108"
|
||||
transform="rotate(39.51 14.592 9.743)"/>
|
||||
</g>
|
||||
<g filter="url(#k)">
|
||||
<ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108"
|
||||
transform="rotate(37.892 61.728 -5.32)"/>
|
||||
</g>
|
||||
<g filter="url(#l)">
|
||||
<ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665"
|
||||
transform="rotate(37.892 55.618 7.104)"/>
|
||||
</g>
|
||||
<g filter="url(#m)">
|
||||
<ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108"
|
||||
transform="rotate(37.892 12.326 39.103)"/>
|
||||
</g>
|
||||
<g filter="url(#n)">
|
||||
<ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108"
|
||||
transform="rotate(37.892 12.326 39.103)"/>
|
||||
</g>
|
||||
<g filter="url(#o)">
|
||||
<ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108"
|
||||
transform="rotate(37.892 49.857 30.678)"/>
|
||||
</g>
|
||||
<g filter="url(#p)">
|
||||
<ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297"
|
||||
transform="rotate(37.892 52.623 33.17)"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789"
|
||||
class="parenthesis"/>
|
||||
<defs>
|
||||
<filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/>
|
||||
</filter>
|
||||
<filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/>
|
||||
</filter>
|
||||
<filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/>
|
||||
</filter>
|
||||
<filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
<filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB"
|
||||
filterUnits="userSpaceOnUse">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
6
src/assets/vue.svg
Normal file
6
src/assets/vue.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img"
|
||||
class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198">
|
||||
<path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path>
|
||||
<path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path>
|
||||
<path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
51
src/axios.ts
Normal file
51
src/axios.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import axios, { type AxiosError, type AxiosInstance } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getToken, removeToken } from '@/composables/token'
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_STATE === 'dev' ? import.meta.env.VITE_DEV_API_URL : import.meta.env.VITE_PROD_API_URL,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
service.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error: AxiosError<ApiResponse>) => {
|
||||
const status = error.response?.status
|
||||
const payload = error.response?.data
|
||||
|
||||
if (status === 401) {
|
||||
removeToken()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
if (location.hash !== '#/login') {
|
||||
location.href = '/#/login'
|
||||
}
|
||||
} else if (status === 403) {
|
||||
ElMessage.error(payload?.message || '无权限执行此操作')
|
||||
} else if (status && status >= 500) {
|
||||
ElMessage.error(payload?.message || '服务异常,请稍后重试')
|
||||
}
|
||||
|
||||
return Promise.reject(payload || error)
|
||||
}
|
||||
)
|
||||
|
||||
export default service
|
||||
93
src/components/HelloWorld.vue
Normal file
93
src/components/HelloWorld.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt=""/>
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo"/>
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo"/>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt=""/>
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt=""/>
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
62
src/composables/config.ts
Normal file
62
src/composables/config.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 定义配置对象的类型,允许任意键值对
|
||||
export interface AppConfig {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 使用泛型指定 storage 的类型为 AppConfig
|
||||
export const configRef = useStorage<AppConfig>('app_config', {}, localStorage, {
|
||||
serializer: {
|
||||
read: (v: string | null): AppConfig => {
|
||||
if (!v) return {}
|
||||
try {
|
||||
return JSON.parse(v) as AppConfig
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
write: (v: AppConfig): string => JSON.stringify(v)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取配置项
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
*/
|
||||
export function getConfig<T = any>(key: string, defaultValue: T | null = null): T | null {
|
||||
return (configRef.value[key] as T) ?? defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置项
|
||||
* @param key 配置键
|
||||
* @param value 配置值
|
||||
*/
|
||||
export function setConfig(key: string, value: any): void {
|
||||
configRef.value[key] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除配置项
|
||||
* @param key 配置键
|
||||
*/
|
||||
export function removeConfig(key: string): void {
|
||||
delete configRef.value[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式访问 app_config 某个 key
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
*/
|
||||
export function useConfigKey<T = any>(key: string, defaultValue: T | null = null) {
|
||||
return computed<T | null>({
|
||||
get: () => (configRef.value[key] as T) ?? defaultValue,
|
||||
set: (val: T | null) => {
|
||||
configRef.value[key] = val
|
||||
}
|
||||
})
|
||||
}
|
||||
9
src/composables/crypto.ts
Normal file
9
src/composables/crypto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
export function sha256(str: string): string {
|
||||
return CryptoJS.SHA256(str).toString()
|
||||
}
|
||||
|
||||
export function bcrypt(str: string): string {
|
||||
return CryptoJS.AES.encrypt(str, import.meta.env.VITE_BCRYPT_KEY).toString()
|
||||
}
|
||||
20
src/composables/datetime.ts
Normal file
20
src/composables/datetime.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value.replace(' ', 'T'))
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
37
src/composables/permission-tree.ts
Normal file
37
src/composables/permission-tree.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export interface PermissionItem {
|
||||
id: number
|
||||
name: string
|
||||
category?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CascaderNode {
|
||||
label: string
|
||||
value: number | string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
export function buildPermissionCascader(items: PermissionItem[]): CascaderNode[] {
|
||||
const groups = new Map<string, PermissionItem[]>()
|
||||
|
||||
items.forEach((item) => {
|
||||
const category = item.category || item.name.split('.')[1] || 'general'
|
||||
if (!groups.has(category)) {
|
||||
groups.set(category, [])
|
||||
}
|
||||
groups.get(category)?.push(item)
|
||||
})
|
||||
|
||||
return Array.from(groups.entries()).map(([category, groupItems]) => ({
|
||||
label: category,
|
||||
value: `category:${category}`,
|
||||
children: groupItems.map((item) => ({
|
||||
label: item.description ? `${item.name}(${item.description})` : item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractPermissionIds(values: Array<number | string>): number[] {
|
||||
return values.filter((item): item is number => typeof item === 'number')
|
||||
}
|
||||
13
src/composables/permission.ts
Normal file
13
src/composables/permission.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export function usePermission() {
|
||||
const authStore = useAuthStore()
|
||||
const { permissions, user } = storeToRefs(authStore)
|
||||
|
||||
return {
|
||||
user,
|
||||
permissions,
|
||||
hasPermission: authStore.hasPermission,
|
||||
}
|
||||
}
|
||||
16
src/composables/token.ts
Normal file
16
src/composables/token.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useCookies } from "@vueuse/integrations/useCookies"
|
||||
|
||||
const TOKEN_KEY = "token"
|
||||
const cookie = useCookies()
|
||||
|
||||
export function getToken(): string | undefined {
|
||||
return cookie.get(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
cookie.set(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function removeToken(): void {
|
||||
cookie.remove(TOKEN_KEY)
|
||||
}
|
||||
16
src/composables/util.ts
Normal file
16
src/composables/util.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ElNotification } from 'element-plus'
|
||||
import type { NotificationType } from 'element-plus'
|
||||
|
||||
// 消息提示
|
||||
export function toast(
|
||||
message: string,
|
||||
type: NotificationType = 'success',
|
||||
dangerouslyUseHTMLString: boolean = true
|
||||
) {
|
||||
ElNotification({
|
||||
message,
|
||||
type,
|
||||
dangerouslyUseHTMLString,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
9
src/env.d.ts
vendored
Normal file
9
src/env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module 'crypto-js'
|
||||
108
src/layouts/MainLayout.vue
Normal file
108
src/layouts/MainLayout.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<el-container class='app-layout'>
|
||||
<el-header class='topbar'>
|
||||
<div class='brand'>
|
||||
<div class='brand-left'>
|
||||
<div class='brand-logo'>SSO</div>
|
||||
<div>
|
||||
<div class='brand-title'>Bastion 控制台</div>
|
||||
<div class='brand-sub'>安全访问管理</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dropdown>
|
||||
<div class='user-entry'>
|
||||
<el-avatar class='avatar'>{{ userInitial }}</el-avatar>
|
||||
<div class='user-meta'>
|
||||
<div class='name'>{{ authStore.user?.nickname || '未登录' }}</div>
|
||||
<div class='email'>{{ authStore.user?.email || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click='router.push("/profile")'>个人信息</el-dropdown-item>
|
||||
<el-dropdown-item divided @click='handleLogout'>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
|
||||
<el-container class='content-shell'>
|
||||
<el-aside width='240px' class='aside'>
|
||||
<el-menu router :default-active='route.path' class='menu'>
|
||||
<el-menu-item index='/'><el-icon><House /></el-icon>仪表盘</el-menu-item>
|
||||
<el-menu-item index='/profile'><el-icon><User /></el-icon>个人信息</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.users.view")' index='/users'><el-icon><UserFilled /></el-icon>用户管理</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.roles.view")' index='/roles'><el-icon><Checked /></el-icon>角色管理</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.permissions.view")' index='/permissions'><el-icon><Lock /></el-icon>权限管理</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.servers.view") || hasPermission("resource.servers.use")' index='/servers'><el-icon><Monitor /></el-icon>服务器资源</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.accounts.view")' index='/accounts'><el-icon><Key /></el-icon>堡垒机账号</el-menu-item>
|
||||
<el-menu-item v-if='hasPermission("platform.logs.view")' index='/logs'><el-icon><Document /></el-icon>访问日志</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main class='main'>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { Checked, Document, House, Key, Lock, Monitor, User, UserFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { removeToken } from '@/composables/token'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
|
||||
const userInitial = computed(() => authStore.user?.nickname?.slice(0, 1)?.toUpperCase() || 'U')
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前登录状态吗?', '提示', { type: 'warning' })
|
||||
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
removeToken()
|
||||
authStore.clearAuth()
|
||||
ElMessage.success('已退出登录')
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout { height: 100vh; overflow: hidden; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 0 20px;
|
||||
height: 68px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 20px; }
|
||||
.brand-left { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-logo { width: 36px; height: 36px; border-radius: 10px; background: #0f766e; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; }
|
||||
.brand-title { font-size: 15px; font-weight: 700; color: #0f172a; }
|
||||
.brand-sub { font-size: 12px; color: #64748b; }
|
||||
.content-shell { height: calc(100vh - 68px); overflow: hidden; }
|
||||
.aside { border-right: 1px solid #e5e7eb; background: linear-gradient(180deg, #fff 0%, #f8fafc 100%); padding-top: 8px; }
|
||||
.menu { border-right: 0; }
|
||||
.user-entry { display: flex; gap: 10px; align-items: center; cursor: pointer; padding: 8px 10px; border-radius: 10px; }
|
||||
.user-entry:hover { background: #f8fafc; }
|
||||
.avatar { background: #0f766e; }
|
||||
.user-meta { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; gap: 2px; min-width: 140px; }
|
||||
.name { font-size: 14px; font-weight: 600; color: #0f172a; }
|
||||
.email { font-size: 12px; color: #64748b; }
|
||||
.main { background: #f1f5f9; overflow: auto; }
|
||||
</style>
|
||||
10
src/main.ts
Normal file
10
src/main.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'uno.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app')
|
||||
187
src/pages/AccountsPage.vue
Normal file
187
src/pages/AccountsPage.vue
Normal file
@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-700'>堡垒机账号管理</span>
|
||||
<el-button v-if='hasPermission("platform.accounts.manage")' type='primary' @click='openCreate'>新增账号</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data='rows' v-loading='loading'>
|
||||
<el-table-column prop='id' label='ID' width='70' sortable />
|
||||
<el-table-column prop='name' label='名称' min-width='140' sortable />
|
||||
<el-table-column prop='username' label='用户名' min-width='140' sortable />
|
||||
<el-table-column label='USM-AUTHENTICATION' min-width='260' prop='usm_authentication' sortable>
|
||||
<template #default='{ row }'><el-input :model-value='row.usm_authentication || ""' readonly><template #append><el-button @click='copyText(row.usm_authentication)'>复制</el-button></template></el-input></template>
|
||||
</el-table-column>
|
||||
<el-table-column label='USM' min-width='260' prop='usm' sortable>
|
||||
<template #default='{ row }'><el-input :model-value='row.usm || ""' readonly><template #append><el-button @click='copyText(row.usm)'>复制</el-button></template></el-input></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop='last_token_refreshed_at' label='刷新时间' min-width='170' sortable>
|
||||
<template #default='{ row }'>{{ formatDateTime(row.last_token_refreshed_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' width='230'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEdit(row)'>编辑</el-button>
|
||||
<el-button size='small' type='warning' :loading='!!refreshingMap[row.id]' :disabled='!!refreshingMap[row.id]' @click='refreshToken(row)'>刷新Token</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑账号" : "新增账号"' width='560px'>
|
||||
<el-form :model='form' label-width='100px'>
|
||||
<el-form-item label='名称'><el-input v-model='form.name' /></el-form-item>
|
||||
<el-form-item label='用户名'><el-input v-model='form.username' /></el-form-item>
|
||||
<el-form-item label='密码'><el-input v-model='form.password' type='password' show-password /></el-form-item>
|
||||
<el-form-item label='启用'><el-switch v-model='form.is_active' /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='dialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import { formatDateTime } from '@/composables/datetime'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const rows = ref<any[]>([])
|
||||
const refreshingMap = ref<Record<number, boolean>>({})
|
||||
const form = reactive<any>({ name: '', username: '', password: '', is_active: true })
|
||||
const refreshPollAttempts = 90
|
||||
const refreshPollIntervalMs = 1000
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await accountsApi.list({ page: 1, per_page: 200 })
|
||||
rows.value = response.data.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, { name: '', username: '', password: '', is_active: true })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, { name: row.name, username: row.username, password: '', is_active: row.is_active })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = { ...form }
|
||||
if (!payload.password) {
|
||||
delete payload.password
|
||||
}
|
||||
if (editingId.value) {
|
||||
await accountsApi.update(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await accountsApi.create(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshToken(row: any): Promise<void> {
|
||||
if (refreshingMap.value[row.id]) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshingMap.value[row.id] = true
|
||||
ElMessage.info('Token 刷新任务已提交,正在同步状态...')
|
||||
|
||||
try {
|
||||
const submitResponse: any = await accountsApi.refreshToken(row.id)
|
||||
const taskId = submitResponse?.data?.task_id
|
||||
|
||||
if (!taskId) {
|
||||
throw new Error('任务提交成功但未返回任务ID')
|
||||
}
|
||||
|
||||
await waitTokenRefreshFinished(row.id, taskId)
|
||||
ElMessage.success('Token 刷新成功')
|
||||
await fetchList()
|
||||
} catch (error: any) {
|
||||
if (!error?.message) {
|
||||
ElMessage.error(resolveErrorMessage(error))
|
||||
}
|
||||
} finally {
|
||||
refreshingMap.value[row.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除账号 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
await accountsApi.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function copyText(text?: string): Promise<void> {
|
||||
if (!text) {
|
||||
ElMessage.warning('暂无可复制内容')
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: any): string {
|
||||
const message = error?.message
|
||||
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
return message
|
||||
}
|
||||
|
||||
return 'Token 刷新失败,请稍后重试'
|
||||
}
|
||||
|
||||
async function waitTokenRefreshFinished(accountId: number, taskId: string): Promise<void> {
|
||||
for (let attempt = 1; attempt <= refreshPollAttempts; attempt += 1) {
|
||||
const response: any = await accountsApi.refreshTokenStatus(accountId, taskId)
|
||||
const status = response?.data?.status
|
||||
|
||||
if (status === 'success') {
|
||||
return
|
||||
}
|
||||
|
||||
await delay(refreshPollIntervalMs)
|
||||
}
|
||||
|
||||
throw new Error('等待 Token 刷新结果超时,请稍后重试')
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
70
src/pages/DashboardPage.vue
Normal file
70
src/pages/DashboardPage.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class='dashboard-hero'>
|
||||
<div class='hero-pattern'></div>
|
||||
<div class='hero-content'>
|
||||
<div class='hero-badge'>Bastion SSO Console</div>
|
||||
<h2>欢迎回来,{{ authStore.user?.nickname || '同学' }}</h2>
|
||||
<p>从左侧菜单进入用户、角色、资源和审计日志模块,继续完成你的访问控制配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-hero {
|
||||
min-height: calc(100vh - 180px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #ecfeff 0%, #f8fafc 45%, #eef2ff 100%);
|
||||
border: 1px solid #dbeafe;
|
||||
}
|
||||
.hero-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 20%, rgba(15, 118, 110, 0.16) 0, rgba(15, 118, 110, 0.02) 30%),
|
||||
radial-gradient(circle at 75% 60%, rgba(30, 64, 175, 0.16) 0, rgba(30, 64, 175, 0.02) 26%);
|
||||
}
|
||||
.hero-content {
|
||||
position: relative;
|
||||
max-width: 780px;
|
||||
text-align: center;
|
||||
padding: 36px 22px;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hero-content h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.hero-content p {
|
||||
margin: 0 auto;
|
||||
max-width: 640px;
|
||||
color: #475569;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
225
src/pages/LoginPage.vue
Normal file
225
src/pages/LoginPage.vue
Normal file
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class='login-page'>
|
||||
<div class='bg-geometry'></div>
|
||||
<div class='login-stack'>
|
||||
<el-card class='login-card' shadow='never'>
|
||||
<div class='brand-row'>
|
||||
<div class='brand-chip'>B</div>
|
||||
<span class='brand-name'>Bastion SSO</span>
|
||||
</div>
|
||||
<h1 class='login-title'>登录</h1>
|
||||
<p class='login-subtitle'>使用邮箱或手机号继续</p>
|
||||
<el-form :model='form' :rules='rules' ref='formRef' @submit.prevent='handleLogin'>
|
||||
<el-form-item prop='account'>
|
||||
<el-input v-model='form.account' placeholder='邮箱、手机号' class='login-input' />
|
||||
</el-form-item>
|
||||
<el-form-item prop='password'>
|
||||
<el-input v-model='form.password' type='password' show-password placeholder='密码' class='login-input' />
|
||||
</el-form-item>
|
||||
<div class='assist-links'>
|
||||
<a href='javascript:void(0)'>无法访问您的帐户?</a>
|
||||
</div>
|
||||
<div class='btn-row'>
|
||||
<el-button :loading='submitting' type='primary' class='login-btn' @click='handleLogin'>登录</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { setToken } from '@/composables/token'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({ account: '', password: '' })
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const phoneRegex = /^1\d{10}$/
|
||||
const rules: FormRules = {
|
||||
account: [{
|
||||
required: true,
|
||||
trigger: 'blur',
|
||||
validator: (_rule, value, callback) => {
|
||||
const account = String(value || '').trim()
|
||||
if (!account) {
|
||||
callback(new Error('请输入邮箱或手机号'))
|
||||
return
|
||||
}
|
||||
if (!emailRegex.test(account) && !phoneRegex.test(account)) {
|
||||
callback(new Error('请输入正确的邮箱或手机号'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
}],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const account = form.account.trim()
|
||||
const isEmail = emailRegex.test(account)
|
||||
const payload = {
|
||||
email: isEmail ? account : undefined,
|
||||
phone: isEmail ? undefined : account,
|
||||
password: form.password,
|
||||
}
|
||||
const response = await authApi.login(payload)
|
||||
setToken(response.data.token)
|
||||
const me = await authApi.me()
|
||||
authStore.setAuth(me.data.user, me.data.permissions)
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace('/')
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '登录失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f4f6f9;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 40px 18px;
|
||||
}
|
||||
.bg-geometry {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(179, 186, 197, 0.2) 0 36%, transparent 36% 100%),
|
||||
linear-gradient(330deg, rgba(180, 184, 210, 0.18) 0 28%, transparent 28% 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.login-stack {
|
||||
width: min(560px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.login-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.brand-chip {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.brand-name {
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.login-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.login-subtitle {
|
||||
margin: 0 0 22px;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
.assist-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.assist-links a {
|
||||
color: #0067b8;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.assist-links a:hover { text-decoration: underline; }
|
||||
.btn-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.login-btn {
|
||||
min-width: 110px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
:deep(.el-card__body) {
|
||||
padding: 34px 38px 30px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
:deep(.login-input .el-input__wrapper) {
|
||||
box-shadow: none !important;
|
||||
border-radius: 6px;
|
||||
border-bottom: 1px solid #737373;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
:deep(.login-input .el-input__wrapper.is-focus) {
|
||||
border-bottom: 1px solid #0067b8;
|
||||
}
|
||||
:deep(.login-input .el-input__inner) {
|
||||
height: 34px;
|
||||
font-size: 14px;
|
||||
}
|
||||
:deep(.el-form-item__error) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-page {
|
||||
padding: 24px 14px;
|
||||
}
|
||||
:deep(.el-card__body) {
|
||||
padding: 24px 22px;
|
||||
}
|
||||
.login-title { font-size: 24px; }
|
||||
.login-subtitle { font-size: 13px; }
|
||||
.assist-links a { font-size: 13px; }
|
||||
.login-btn { font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
139
src/pages/LogsPage.vue
Normal file
139
src/pages/LogsPage.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header><div class='font-700'>访问日志</div></template>
|
||||
|
||||
<el-form :inline='true' :model='filters' class='mb-4'>
|
||||
<el-form-item label='开始日期'><el-date-picker v-model='filters.from' type='date' value-format='YYYY-MM-DD' placeholder='请选择开始日期' class='filter-control' /></el-form-item>
|
||||
<el-form-item label='结束日期'><el-date-picker v-model='filters.to' type='date' value-format='YYYY-MM-DD' placeholder='请选择结束日期' class='filter-control' /></el-form-item>
|
||||
<el-form-item label='动作'>
|
||||
<el-select v-model='filters.actions' multiple collapse-tags collapse-tags-tooltip filterable clearable placeholder='请选择动作' class='filter-control'>
|
||||
<el-option v-for='action in actionOptions' :key='action' :label='action' :value='action' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='用户'>
|
||||
<el-select v-model='filters.user_ids' multiple collapse-tags collapse-tags-tooltip filterable clearable placeholder='请选择用户' class='filter-control'>
|
||||
<el-option
|
||||
v-for='user in userOptions'
|
||||
:key='user.id'
|
||||
:label='`${user.nickname || "未命名"} (${user.email || "-"})`'
|
||||
:value='user.id'
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='资源ID'>
|
||||
<el-select v-model='filters.server_resource_ids' multiple collapse-tags collapse-tags-tooltip filterable clearable placeholder='请选择资源ID' class='filter-control'>
|
||||
<el-option
|
||||
v-for='resource in serverResourceOptions'
|
||||
:key='resource.id'
|
||||
:label='`#${resource.id} ${resource.name || "-"} ${resource.internal_ip ? "(" + resource.internal_ip + ")" : ""}`'
|
||||
:value='resource.id'
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='协议'>
|
||||
<el-select v-model='filters.protocol' clearable placeholder='请选择协议' class='filter-control'>
|
||||
<el-option v-for='protocol in protocolOptions' :key='protocol' :label='protocol' :value='protocol' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type='primary' @click='fetchList'>查询</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data='rows' v-loading='loading' @sort-change='handleSortChange'>
|
||||
<el-table-column prop='id' label='ID' width='70' sortable='custom' />
|
||||
<el-table-column prop='requested_at' label='请求时间' min-width='170' sortable='custom'><template #default='{ row }'>{{ formatDateTime(row.requested_at) }}</template></el-table-column>
|
||||
<el-table-column prop='action' label='动作' min-width='170' sortable='custom' />
|
||||
<el-table-column prop='protocol' label='协议' width='90' sortable='custom' />
|
||||
<el-table-column prop='user' label='用户' min-width='120' sortable='custom'><template #default='{ row }'>{{ row.user?.nickname || '-' }}</template></el-table-column>
|
||||
<el-table-column prop='resource' label='资源' min-width='150' sortable='custom'><template #default='{ row }'>{{ row.server_resource?.name || row.serverResource?.name || '-' }}</template></el-table-column>
|
||||
<el-table-column prop='account' label='堡垒机账号' min-width='140' sortable='custom'><template #default='{ row }'>{{ row.bastion_account?.name || row.bastionAccount?.name || '-' }}</template></el-table-column>
|
||||
<el-table-column label='元数据' min-width='320'>
|
||||
<template #default='{ row }'>
|
||||
<el-collapse class='meta-collapse'>
|
||||
<el-collapse-item title='查看元数据' name='meta'>
|
||||
<pre class='meta'>{{ JSON.stringify(row.metadata || {}, null, 2) }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination class='mt-4' layout='total, prev, pager, next' :total='total' :current-page='page' :page-size='perPage' @current-change='handlePageChange' />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { formatDateTime } from '@/composables/datetime'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const filters = reactive<any>({ from: '', to: '', actions: [], user_ids: [], server_resource_ids: [], protocol: '' })
|
||||
const actionOptions = ref<string[]>([])
|
||||
const userOptions = ref<any[]>([])
|
||||
const serverResourceOptions = ref<any[]>([])
|
||||
const protocolOptions = ref<string[]>(['SSH', 'SFTP', 'RDP'])
|
||||
const sortBy = ref('requested_at')
|
||||
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await logsApi.list({
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
from: filters.from || undefined,
|
||||
to: filters.to || undefined,
|
||||
actions: filters.actions?.length ? filters.actions : undefined,
|
||||
user_ids: filters.user_ids?.length ? filters.user_ids : undefined,
|
||||
server_resource_ids: filters.server_resource_ids?.length ? filters.server_resource_ids : undefined,
|
||||
protocol: filters.protocol || undefined,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value,
|
||||
})
|
||||
rows.value = response.data.data || []
|
||||
total.value = response.data.total || 0
|
||||
|
||||
const options = response.filter_options || {}
|
||||
actionOptions.value = options.actions || []
|
||||
userOptions.value = options.users || []
|
||||
serverResourceOptions.value = options.server_resources || []
|
||||
protocolOptions.value = options.protocols?.length ? options.protocols : ['SSH', 'SFTP', 'RDP']
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number): void {
|
||||
page.value = nextPage
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleSortChange(payload: { prop: string, order: 'ascending' | 'descending' | null }): void {
|
||||
if (!payload.prop || !payload.order) {
|
||||
sortBy.value = 'requested_at'
|
||||
sortOrder.value = 'desc'
|
||||
page.value = 1
|
||||
fetchList()
|
||||
return
|
||||
}
|
||||
|
||||
sortBy.value = payload.prop
|
||||
sortOrder.value = payload.order === 'ascending' ? 'asc' : 'desc'
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.meta { margin: 0; white-space: pre-wrap; word-break: break-all; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; font-size: 12px; }
|
||||
.meta-collapse { border-top: 0; border-bottom: 0; }
|
||||
:deep(.meta-collapse .el-collapse-item__header) { height: 30px; border-bottom: 0; color: #0f766e; }
|
||||
:deep(.meta-collapse .el-collapse-item__wrap) { border-bottom: 0; }
|
||||
.filter-control { width: 220px; }
|
||||
</style>
|
||||
13
src/pages/NotFoundPage.vue
Normal file
13
src/pages/NotFoundPage.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class='p-6'>
|
||||
<el-empty description='页面不存在'>
|
||||
<el-button type='primary' @click='router.replace("/")'>返回首页</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
123
src/pages/PermissionsPage.vue
Normal file
123
src/pages/PermissionsPage.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-700'>权限管理</span>
|
||||
<el-button v-if='hasPermission("platform.permissions.manage")' type='primary' @click='openCreate'>新增权限</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data='rows' v-loading='loading'>
|
||||
<el-table-column prop='id' label='ID' width='80' sortable />
|
||||
<el-table-column prop='name' label='权限标识' min-width='220' sortable />
|
||||
<el-table-column prop='category' label='分类' width='150' sortable />
|
||||
<el-table-column prop='description' label='描述' min-width='220' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.permissions.manage")' label='操作' width='180'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEdit(row)'>编辑</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑权限" : "新增权限"' width='560px'>
|
||||
<el-form :model='form' label-width='95px'>
|
||||
<el-form-item label='权限标识'><el-input v-model='form.name' /></el-form-item>
|
||||
<el-form-item label='权限分类'>
|
||||
<el-select
|
||||
v-model='form.category'
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder='请选择或输入分类'
|
||||
class='w-full'
|
||||
>
|
||||
<el-option v-for='item in categoryOptions' :key='item' :label='item' :value='item' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='描述'><el-input v-model='form.description' type='textarea' :rows='3' /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='dialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { permissionsApi } from '@/api/permissions'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const rows = ref<any[]>([])
|
||||
const form = reactive({ name: '', category: 'general', description: '', guard_name: 'api' })
|
||||
const categoryOptions = computed<string[]>(() => {
|
||||
const categories = rows.value
|
||||
.map((item: any) => String(item.category || '').trim())
|
||||
.filter((item: string) => item.length > 0)
|
||||
|
||||
return [...new Set(categories)]
|
||||
})
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await permissionsApi.list({ per_page: 200 })
|
||||
rows.value = response.data.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, { name: '', category: 'general', description: '', guard_name: 'api' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, { name: row.name, category: row.category || 'general', description: row.description || '', guard_name: 'api' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await permissionsApi.update(editingId.value, form)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await permissionsApi.create(form)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除权限 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
await permissionsApi.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
71
src/pages/ProfilePage.vue
Normal file
71
src/pages/ProfilePage.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class='grid'>
|
||||
<el-card>
|
||||
<template #header><div class='font-700'>基本信息</div></template>
|
||||
<el-form :model='profileForm' label-width='100px'>
|
||||
<el-form-item label='昵称'><el-input v-model='profileForm.nickname' /></el-form-item>
|
||||
<el-form-item label='邮箱'><el-input v-model='profileForm.email' /></el-form-item>
|
||||
<el-form-item label='手机号'><el-input v-model='profileForm.phone' /></el-form-item>
|
||||
<el-button type='primary' :loading='savingProfile' @click='saveProfile'>保存信息</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header><div class='font-700'>修改密码</div></template>
|
||||
<el-form :model='passwordForm' label-width='110px'>
|
||||
<el-form-item label='当前密码'><el-input v-model='passwordForm.current_password' type='password' show-password /></el-form-item>
|
||||
<el-form-item label='新密码'><el-input v-model='passwordForm.password' type='password' show-password /></el-form-item>
|
||||
<el-form-item label='确认新密码'><el-input v-model='passwordForm.password_confirmation' type='password' show-password /></el-form-item>
|
||||
<el-button type='warning' :loading='savingPassword' @click='savePassword'>更新密码</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const profileForm = reactive({ nickname: authStore.user?.nickname || '', email: authStore.user?.email || '', phone: (authStore.user as any)?.phone || '' })
|
||||
const passwordForm = reactive({ current_password: '', password: '', password_confirmation: '' })
|
||||
const savingProfile = ref(false)
|
||||
const savingPassword = ref(false)
|
||||
|
||||
async function saveProfile(): Promise<void> {
|
||||
savingProfile.value = true
|
||||
try {
|
||||
await authApi.updateProfile(profileForm)
|
||||
const me = await authApi.me()
|
||||
authStore.setAuth(me.data.user, me.data.permissions)
|
||||
ElMessage.success('个人信息已更新')
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '更新失败')
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword(): Promise<void> {
|
||||
savingPassword.value = true
|
||||
try {
|
||||
await authApi.updatePassword(passwordForm)
|
||||
ElMessage.success('密码已更新')
|
||||
passwordForm.current_password = ''
|
||||
passwordForm.password = ''
|
||||
passwordForm.password_confirmation = ''
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '修改失败')
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
</style>
|
||||
145
src/pages/RolesPage.vue
Normal file
145
src/pages/RolesPage.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-700'>角色管理</span>
|
||||
<el-button v-if='hasPermission("platform.roles.manage")' type='primary' @click='openCreate'>新增角色</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data='rows' v-loading='loading'>
|
||||
<el-table-column prop='id' label='ID' width='80' sortable />
|
||||
<el-table-column prop='name' label='角色名' min-width='160' sortable />
|
||||
<el-table-column label='权限' min-width='420' sortable :sort-method='sortByPermissions'>
|
||||
<template #default='{ row }'>
|
||||
<div class='perm-wrap'>
|
||||
<el-tag v-for='item in row.permissions || []' :key='item.id' size='small' class='perm-tag'>{{ item.name }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if='hasPermission("platform.roles.manage")' label='操作' width='180'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEdit(row)'>编辑</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑角色" : "新增角色"' width='760px'>
|
||||
<el-form :model='form' label-width='95px'>
|
||||
<el-form-item label='角色名'><el-input v-model='form.name' /></el-form-item>
|
||||
<el-form-item label='权限选择'>
|
||||
<div class='permission-panel-wrap'>
|
||||
<el-cascader-panel v-model='selectedPermissionNodes' :options='permissionCascader' :props='cascaderProps' class='permission-panel' />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='dialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { permissionsApi } from '@/api/permissions'
|
||||
import { rolesApi } from '@/api/roles'
|
||||
import { buildPermissionCascader, extractPermissionIds } from '@/composables/permission-tree'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const rows = ref<any[]>([])
|
||||
const permissionOptions = ref<any[]>([])
|
||||
const selectedPermissionNodes = ref<Array<number | string>>([])
|
||||
|
||||
const form = reactive<any>({ name: '' })
|
||||
const cascaderProps = { multiple: true, emitPath: false, checkStrictly: false }
|
||||
const permissionCascader = computed(() => buildPermissionCascader(permissionOptions.value))
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await rolesApi.list({ page: 1, per_page: 200 })
|
||||
rows.value = response.data.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPermissions(): Promise<void> {
|
||||
const response: any = await permissionsApi.list({ page: 1, per_page: 500 })
|
||||
permissionOptions.value = response.data.data || []
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
selectedPermissionNodes.value = []
|
||||
Object.assign(form, { name: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any): void {
|
||||
editingId.value = row.id
|
||||
selectedPermissionNodes.value = (row.permissions || []).map((item: any) => item.id)
|
||||
Object.assign(form, { name: row.name })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { name: form.name, permission_ids: extractPermissionIds(selectedPermissionNodes.value) }
|
||||
|
||||
if (editingId.value) {
|
||||
await rolesApi.update(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await rolesApi.create(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除角色 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
await rolesApi.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchPermissions(), fetchList()])
|
||||
})
|
||||
|
||||
function sortByPermissions(a: any, b: any): number {
|
||||
return ((a.permissions || []).map((item: any) => item.name).join(',')).localeCompare((b.permissions || []).map((item: any) => item.name).join(','))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.perm-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.perm-tag { margin: 0; }
|
||||
.permission-panel-wrap { width: 100%; overflow-x: auto; }
|
||||
.permission-panel { min-width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-menu) { min-width: 220px; }
|
||||
</style>
|
||||
|
||||
811
src/pages/ServersPage.vue
Normal file
811
src/pages/ServersPage.vue
Normal file
@ -0,0 +1,811 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-700'>服务器资源管理</span>
|
||||
<div class='top-actions btn-gap-8' v-if='hasPermission("platform.servers.manage")'>
|
||||
<el-button type='primary' @click='openCreateServer'>新增服务器</el-button>
|
||||
<el-button type='success' @click='openCreateResource()'>新增资源</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-collapse v-model='activeServers' class='server-collapse'>
|
||||
<el-collapse-item
|
||||
v-for='(server, index) in serverGroups'
|
||||
:key='server.id'
|
||||
:name='String(server.id)'
|
||||
:class='{ "with-divider": index > 0 }'
|
||||
>
|
||||
<template #title>
|
||||
<div class='server-title'>
|
||||
<span class='server-name'>{{ server.display_name || server.name }}</span>
|
||||
<span class='server-code'>{{ server.name }}</span>
|
||||
<span class='server-ip'>{{ server.internal_ip }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class='mb-3 server-actions btn-gap-8' v-if='hasPermission("platform.servers.manage")'>
|
||||
<el-button size='small' @click='openEdit(server)'>编辑服务器</el-button>
|
||||
<el-button size='small' type='primary' plain @click='openCreateResource(server.id)'>添加资源</el-button>
|
||||
<el-button size='small' type='success' plain @click='openServerPermissionDialog(server)'>分配用户权限</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(server)'>删除服务器</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data='server.children || []' border>
|
||||
<el-table-column prop='display_name' label='显示名称' min-width='150' sortable />
|
||||
<el-table-column prop='name' label='名称' min-width='150' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='id' label='ID' width='70' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='asset_id' label='asset_id' width='110' sortable />
|
||||
<el-table-column v-if='hasPermission("platform.servers.manage")' prop='account_id' label='account_id' width='110' sortable />
|
||||
<el-table-column label='协议' width='120' sortable :sort-method='sortByProtocol'>
|
||||
<template #default='{ row }'>{{ row.protocols?.[0] || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='状态' width='90' prop='is_active' sortable>
|
||||
<template #default='{ row }'><el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '启用' : '停用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' min-width='420'>
|
||||
<template #default='{ row }'>
|
||||
<div class='row-actions btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEdit(row)' v-if='hasPermission("platform.servers.manage")'>编辑</el-button>
|
||||
<el-button size='small' type='primary' plain @click='openPermissionDialog(row)' v-if='hasPermission("platform.servers.manage")'>选择拥有权限用户</el-button>
|
||||
<el-button size='small' type='warning' @click='openUseResourceDialog(row)' v-if='hasPermission("resource.servers.use") || hasPermission("platform.servers.view")'>使用</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)' v-if='hasPermission("platform.servers.manage")'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<el-empty v-if='!serverGroups.length' description='暂无服务器' />
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑" : (form.type === "server" ? "新增服务器" : "新增资源")' width='640px'>
|
||||
<el-form :model='form' label-width='100px'>
|
||||
<el-form-item label='类型'>
|
||||
<el-segmented v-model='form.type' :options='typeOptions' />
|
||||
</el-form-item>
|
||||
<el-form-item label='所属服务器' v-if='form.type === "resource"'>
|
||||
<el-select v-model='form.parent_id'><el-option v-for='item in serverOnlyList' :key='item.id' :label='item.display_name || item.name' :value='item.id' /></el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label='名称' v-if='form.type === "server" || form.type === "resource"'>
|
||||
<el-input v-model='form.name' placeholder='纯英文,如 server_prod_01' />
|
||||
</el-form-item>
|
||||
<el-form-item label='显示名称' v-if='form.type === "server" || form.type === "resource"'>
|
||||
<el-input v-model='form.display_name' placeholder='可显示中文,如 生产服务器01' />
|
||||
</el-form-item>
|
||||
<el-form-item label='内网IP' v-if='form.type === "server"'><el-input v-model='form.internal_ip' /></el-form-item>
|
||||
<el-form-item label='asset_id' v-if='form.type === "server"'><el-input-number v-model='form.asset_id' :min='1' class='w-full' /></el-form-item>
|
||||
<el-form-item label='account_id' v-if='form.type === "resource"'><el-input-number v-model='form.account_id' :min='1' class='w-full' /></el-form-item>
|
||||
<el-form-item label='协议' v-if='form.type === "resource"'>
|
||||
<el-select v-model='form.protocol' placeholder='请选择协议'>
|
||||
<el-option v-for='item in resourceProtocolOptions' :key='item' :label='item' :value='item' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label='描述'><el-input v-model='form.description' type='textarea' :rows='2' /></el-form-item>
|
||||
<el-form-item label='启用'><el-switch v-model='form.is_active' /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='dialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='permissionDialogVisible' :title='permissionDialogTitle' width='760px'>
|
||||
<el-form label-width='90px' class='mb-3' v-if='permissionMode === "server_assign"'>
|
||||
<el-form-item label='资源'>
|
||||
<el-select v-model='permissionTargetId' placeholder='请选择资源' class='w-full' @change='loadPermissionUsers(false)'>
|
||||
<el-option v-for='item in permissionResourceOptions' :key='item.id' :label='item.display_name || item.name || `资源#${item.id}`' :value='item.id' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form label-width='120px' class='mb-3' v-if='permissionMode === "resource_edit"'>
|
||||
<el-form-item label='拥有权限用户'>
|
||||
<el-select v-model='selectedPermissionUserIds' multiple filterable clearable collapse-tags collapse-tags-tooltip class='w-full' placeholder='请选择可拥有该资源权限的用户'>
|
||||
<el-option
|
||||
v-for='user in permissionRows'
|
||||
:key='user.id'
|
||||
:label='`${user.nickname || "未命名"} (${user.email || "-"})`'
|
||||
:value='user.id'
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data='permissionMode === "resource_edit" ? permissionRows.filter((user) => selectedPermissionUserIds.includes(user.id)) : permissionRows'>
|
||||
<el-table-column prop='nickname' label='用户' min-width='160' />
|
||||
<el-table-column prop='email' label='邮箱' min-width='220' />
|
||||
<el-table-column v-if='permissionMode === "server_assign"' label='SSH' width='90'><template #default='{ row }'><el-switch v-model='row.can_ssh' /></template></el-table-column>
|
||||
<el-table-column v-if='permissionMode === "server_assign"' label='SFTP' width='90'><template #default='{ row }'><el-switch v-model='row.can_sftp' /></template></el-table-column>
|
||||
<el-table-column v-if='permissionMode === "server_assign"' label='RDP' width='90'><template #default='{ row }'><el-switch v-model='row.can_rdp' /></template></el-table-column>
|
||||
<el-table-column v-if='permissionMode === "resource_edit"' label='状态' width='120'>
|
||||
<template #default><el-tag type='success'>已拥有权限</el-tag></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click='permissionDialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='savingPermissions' @click='submitPermissions'>保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='useDialogVisible' title='使用服务器资源' width='520px'>
|
||||
<el-form :model='useForm' label-width='110px'>
|
||||
<el-form-item label='目标资源'>
|
||||
<div>{{ useTargetRow?.display_name || useTargetRow?.name || '-' }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label='协议'>
|
||||
<el-select v-model='useForm.protocol' class='w-full'>
|
||||
<el-option v-for='item in useProtocolOptions' :key='item' :label='item' :value='item' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label='访问用户名'>
|
||||
<el-input v-model='useForm.account_name' placeholder='可为空(ACCOUNT_NAME)' clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label='访问密码'>
|
||||
<el-input v-model='useForm.password' type='password' show-password placeholder='可为空(PASSWORD)' clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model='useForm.remember'>记住账号密码(仅本地)</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='useDialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='usingResource' @click='submitUseResource'>连接并访问</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-divider content-position='left'>运维协议与软件</el-divider>
|
||||
<el-card shadow='never' v-loading='opsLoading'>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-600'>运维协议软件配置</span>
|
||||
<div class='btn-gap-8'>
|
||||
<el-button @click='fetchOpsMeta'>刷新</el-button>
|
||||
<el-button v-if='hasPermission("platform.servers.manage")' type='primary' @click='openCreateProtocol'>新增协议</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data='opsProtocols' border>
|
||||
<el-table-column prop='name' label='协议' min-width='140' />
|
||||
<el-table-column label='支持软件' min-width='260'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8'>
|
||||
<el-tag v-for='software in row.softwares || []' :key='software.id' :type='software.is_active ? "success" : "info"'>
|
||||
{{ software.name }}
|
||||
</el-tag>
|
||||
<span v-if='!(row.softwares || []).length' class='text-gray-400'>暂无</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='我的软件选择' min-width='280'>
|
||||
<template #default='{ row }'>
|
||||
<el-select
|
||||
v-model='opsSelections[String(row.id)]'
|
||||
clearable
|
||||
filterable
|
||||
placeholder='请选择软件'
|
||||
class='w-full'
|
||||
>
|
||||
<el-option
|
||||
v-for='software in (row.softwares || []).filter((item: any) => item.is_active)'
|
||||
:key='software.id'
|
||||
:label='software.name'
|
||||
:value='software.id'
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' width='320'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' type='success' :loading='!!opsLinkLoading[row.id]' @click='runOpsLink(row)'>发送并执行</el-button>
|
||||
<el-button size='small' v-if='hasPermission("platform.servers.manage")' @click='openEditProtocol(row)'>编辑协议</el-button>
|
||||
<el-button size='small' type='primary' plain v-if='hasPermission("platform.servers.manage")' @click='openSoftwareDialog(row)'>管理软件</el-button>
|
||||
<el-button size='small' type='danger' v-if='hasPermission("platform.servers.manage")' @click='removeProtocol(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class='mt-3 btn-gap-8 btn-gap-8--end'>
|
||||
<el-button type='primary' :loading='opsSaving' @click='saveOpsPreferences'>保存我的软件选择</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model='protocolDialogVisible' :title='editingProtocolId ? "编辑协议" : "新增协议"' width='520px'>
|
||||
<el-form :model='protocolForm' label-width='90px'>
|
||||
<el-form-item label='协议名称'><el-input v-model='protocolForm.name' /></el-form-item>
|
||||
<el-form-item label='协议ID'><el-input-number v-model='protocolForm.bastion_protocol_id' :min='1' class='w-full' /></el-form-item>
|
||||
<el-form-item label='描述'><el-input v-model='protocolForm.description' /></el-form-item>
|
||||
<el-form-item label='排序'><el-input-number v-model='protocolForm.sort' :min='0' class='w-full' /></el-form-item>
|
||||
<el-form-item label='启用'><el-switch v-model='protocolForm.is_active' /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='protocolDialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='opsSaving' @click='submitProtocol'>保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='softwareDialogVisible' :title='`管理软件 - ${softwareProtocol?.name || ""}`' width='760px'>
|
||||
<div class='mb-3 btn-gap-8'>
|
||||
<el-button type='primary' @click='openCreateSoftware'>新增软件</el-button>
|
||||
</div>
|
||||
<el-table :data='softwareProtocol?.softwares || []' border>
|
||||
<el-table-column prop='name' label='软件名称' min-width='160' />
|
||||
<el-table-column prop='client_path' label='ClientPath' min-width='220' />
|
||||
<el-table-column prop='sort' label='排序' width='80' />
|
||||
<el-table-column label='启用' width='80'>
|
||||
<template #default='{ row }'><el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '是' : '否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label='操作' width='180'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEditSoftware(row)'>编辑</el-button>
|
||||
<el-button size='small' type='danger' @click='removeSoftware(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider />
|
||||
<el-form :model='softwareForm' label-width='90px'>
|
||||
<el-form-item label='软件名称'><el-input v-model='softwareForm.name' /></el-form-item>
|
||||
<el-form-item label='ClientPath'><el-input v-model='softwareForm.client_path' placeholder='可为空' /></el-form-item>
|
||||
<el-form-item label='排序'><el-input-number v-model='softwareForm.sort' :min='0' class='w-full' /></el-form-item>
|
||||
<el-form-item label='启用'><el-switch v-model='softwareForm.is_active' /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='softwareDialogVisible = false'>关闭</el-button>
|
||||
<el-button type='primary' :loading='opsSaving' @click='submitSoftware'>{{ editingSoftwareId ? '更新软件' : '新增软件' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { serversApi } from '@/api/servers'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const permissionDialogVisible = ref(false)
|
||||
const useDialogVisible = ref(false)
|
||||
const protocolDialogVisible = ref(false)
|
||||
const softwareDialogVisible = ref(false)
|
||||
const savingPermissions = ref(false)
|
||||
const usingResource = ref(false)
|
||||
const opsLoading = ref(false)
|
||||
const opsSaving = ref(false)
|
||||
const permissionMode = ref<'server_assign' | 'resource_edit'>('server_assign')
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingProtocolId = ref<number | null>(null)
|
||||
const editingSoftwareId = ref<number | null>(null)
|
||||
const permissionTargetId = ref<number | null>(null)
|
||||
const rows = ref<any[]>([])
|
||||
const permissionRows = ref<any[]>([])
|
||||
const permissionResourceOptions = ref<any[]>([])
|
||||
const selectedPermissionUserIds = ref<number[]>([])
|
||||
const activeServers = ref<string[]>([])
|
||||
const useTargetRow = ref<any | null>(null)
|
||||
const opsProtocols = ref<any[]>([])
|
||||
const softwareProtocol = ref<any | null>(null)
|
||||
const opsLinkLoading = ref<Record<number, boolean>>({})
|
||||
|
||||
const typeOptions = [{ label: '服务器', value: 'server' }, { label: '资源', value: 'resource' }]
|
||||
const form = reactive<any>({ type: 'server', name: '', display_name: '', parent_id: null, internal_ip: '', asset_id: null, account_id: null, protocol: '', description: '', is_active: true })
|
||||
const useForm = reactive<any>({ protocol: 'SSH', account_name: '', password: '', remember: false })
|
||||
const protocolForm = reactive<any>({ name: '', bastion_protocol_id: 2, description: '', sort: 0, is_active: true })
|
||||
const softwareForm = reactive<any>({ name: '', client_path: '', sort: 0, is_active: true })
|
||||
const opsSelections = reactive<Record<string, number | null>>({})
|
||||
const permissionDialogTitle = computed(() => (permissionMode.value === 'server_assign' ? '服务器资源用户权限分配' : '资源已有用户权限修改'))
|
||||
const useProtocolOptions = computed<string[]>(() => {
|
||||
const values = useTargetRow.value?.protocols
|
||||
if (Array.isArray(values) && values.length > 0) {
|
||||
return values
|
||||
}
|
||||
|
||||
return ['SSH']
|
||||
})
|
||||
const resourceProtocolOptions = computed<string[]>(() => {
|
||||
return opsProtocols.value
|
||||
.filter((item: any) => item.is_active)
|
||||
.map((item: any) => String(item.name))
|
||||
.filter((item: string) => item !== '')
|
||||
})
|
||||
|
||||
const serverOnlyList = computed(() => rows.value.filter((item) => !item.parent_id))
|
||||
const serverGroups = computed(() => {
|
||||
const servers = serverOnlyList.value.map((server) => ({ ...server, children: [] as any[] }))
|
||||
const serverMap = new Map<number, any>(servers.map((item) => [item.id, item]))
|
||||
rows.value.forEach((item) => {
|
||||
if (item.parent_id && serverMap.has(item.parent_id)) {
|
||||
serverMap.get(item.parent_id).children.push(item)
|
||||
}
|
||||
})
|
||||
return servers
|
||||
})
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await serversApi.list({ page: 1, per_page: 500 })
|
||||
rows.value = response.data.data || []
|
||||
activeServers.value = serverOnlyList.value.map((item: any) => String(item.id))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
Object.assign(form, {
|
||||
type: 'server',
|
||||
name: '',
|
||||
display_name: '',
|
||||
parent_id: null,
|
||||
internal_ip: '',
|
||||
asset_id: null,
|
||||
account_id: null,
|
||||
protocol: resourceProtocolOptions.value[0] || '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
})
|
||||
}
|
||||
|
||||
function openCreateServer(): void {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
form.type = 'server'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openCreateResource(parentId?: number): void {
|
||||
editingId.value = null
|
||||
resetForm()
|
||||
form.type = 'resource'
|
||||
form.parent_id = typeof parentId === 'number' ? parentId : null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
type: row.parent_id ? 'resource' : 'server',
|
||||
name: row.name,
|
||||
display_name: row.display_name || '',
|
||||
parent_id: row.parent_id,
|
||||
internal_ip: row.internal_ip,
|
||||
asset_id: row.asset_id,
|
||||
account_id: row.account_id,
|
||||
protocol: row.protocols?.[0] || 'SSH',
|
||||
description: row.description,
|
||||
is_active: row.is_active,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (form.type === 'resource' && !form.parent_id) {
|
||||
ElMessage.warning('请选择所属服务器')
|
||||
return
|
||||
}
|
||||
if (form.type === 'resource' && !form.protocol) {
|
||||
ElMessage.warning('请选择协议')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
parent_id: form.type === 'resource' ? form.parent_id : null,
|
||||
name: form.name,
|
||||
display_name: form.display_name || form.name,
|
||||
internal_ip: form.type === 'server' ? form.internal_ip : null,
|
||||
asset_id: form.type === 'server' ? form.asset_id : null,
|
||||
account_id: form.type === 'resource' ? form.account_id : null,
|
||||
protocol: form.type === 'resource' ? form.protocol : null,
|
||||
description: form.description,
|
||||
is_active: form.is_active,
|
||||
}
|
||||
|
||||
if (editingId.value) {
|
||||
await serversApi.update(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await serversApi.create(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openServerPermissionDialog(server: any): Promise<void> {
|
||||
const resources = (server.children || []) as any[]
|
||||
if (!resources.length) {
|
||||
ElMessage.warning('该服务器暂无资源,请先添加资源')
|
||||
return
|
||||
}
|
||||
|
||||
permissionMode.value = 'server_assign'
|
||||
permissionResourceOptions.value = resources
|
||||
permissionTargetId.value = resources[0]?.id || null
|
||||
await loadPermissionUsers(false)
|
||||
permissionDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openPermissionDialog(resource: any): Promise<void> {
|
||||
permissionMode.value = 'resource_edit'
|
||||
permissionResourceOptions.value = [resource]
|
||||
permissionTargetId.value = resource.id
|
||||
await loadPermissionUsers(false)
|
||||
selectedPermissionUserIds.value = permissionRows.value
|
||||
.filter((user) => hasPermissionByResourceProtocol(user, resource))
|
||||
.map((user) => Number(user.id))
|
||||
permissionDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function loadPermissionUsers(assignedOnly: boolean): Promise<void> {
|
||||
if (!permissionTargetId.value) {
|
||||
permissionRows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response: any = await serversApi.userPermissions(permissionTargetId.value, { assigned_only: assignedOnly ? 1 : 0 })
|
||||
permissionRows.value = response.data.users || []
|
||||
}
|
||||
|
||||
async function submitPermissions(): Promise<void> {
|
||||
if (!permissionTargetId.value) {
|
||||
return
|
||||
}
|
||||
savingPermissions.value = true
|
||||
try {
|
||||
if (permissionMode.value === 'resource_edit') {
|
||||
const resource = permissionResourceOptions.value[0]
|
||||
const protocolKey = protocolPivotField(resource?.protocols?.[0])
|
||||
const payload = permissionRows.value.map((user) => {
|
||||
const hasPermission = selectedPermissionUserIds.value.includes(Number(user.id))
|
||||
return {
|
||||
id: user.id,
|
||||
can_ssh: protocolKey === 'can_ssh' ? hasPermission : Boolean(user.can_ssh),
|
||||
can_sftp: protocolKey === 'can_sftp' ? hasPermission : Boolean(user.can_sftp),
|
||||
can_rdp: protocolKey === 'can_rdp' ? hasPermission : Boolean(user.can_rdp),
|
||||
}
|
||||
})
|
||||
await serversApi.syncUserPermissions(permissionTargetId.value, payload)
|
||||
} else {
|
||||
await serversApi.syncUserPermissions(permissionTargetId.value, permissionRows.value, false)
|
||||
}
|
||||
ElMessage.success('资源权限更新成功')
|
||||
permissionDialogVisible.value = false
|
||||
} finally {
|
||||
savingPermissions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function protocolPivotField(protocol: string | undefined): 'can_ssh' | 'can_sftp' | 'can_rdp' {
|
||||
const normalized = (protocol || 'SSH').toUpperCase()
|
||||
if (normalized === 'SFTP') {
|
||||
return 'can_sftp'
|
||||
}
|
||||
if (normalized === 'RDP') {
|
||||
return 'can_rdp'
|
||||
}
|
||||
|
||||
return 'can_ssh'
|
||||
}
|
||||
|
||||
function hasPermissionByResourceProtocol(user: any, resource: any): boolean {
|
||||
const key = protocolPivotField(resource?.protocols?.[0])
|
||||
return Boolean(user?.[key])
|
||||
}
|
||||
|
||||
function sortByProtocol(a: any, b: any): number {
|
||||
return String(a.protocols?.[0] || '').localeCompare(String(b.protocols?.[0] || ''))
|
||||
}
|
||||
|
||||
function openUseResourceDialog(row: any): void {
|
||||
useTargetRow.value = row
|
||||
const saved = readSavedCredentials(row.id)
|
||||
useForm.protocol = saved?.protocol || row.protocols?.[0] || 'SSH'
|
||||
useForm.account_name = saved?.account_name || ''
|
||||
useForm.password = saved?.password || ''
|
||||
useForm.remember = Boolean(saved)
|
||||
useDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitUseResource(): Promise<void> {
|
||||
if (!useTargetRow.value?.id) {
|
||||
ElMessage.warning('未选择资源')
|
||||
return
|
||||
}
|
||||
|
||||
usingResource.value = true
|
||||
try {
|
||||
const response: any = await serversApi.useResource(useTargetRow.value.id, {
|
||||
protocol: useForm.protocol,
|
||||
account_name: useForm.account_name || '',
|
||||
password: useForm.password || '',
|
||||
})
|
||||
|
||||
const ssoUrl = response?.data?.url
|
||||
if (!ssoUrl) {
|
||||
ElMessage.error('未获取到 SSO 地址')
|
||||
return
|
||||
}
|
||||
|
||||
useDialogVisible.value = false
|
||||
ElMessage.success('已获取访问地址,正在拉起客户端...')
|
||||
persistCredentials(useTargetRow.value.id, {
|
||||
protocol: useForm.protocol,
|
||||
account_name: useForm.account_name || '',
|
||||
password: useForm.password || '',
|
||||
remember: Boolean(useForm.remember),
|
||||
})
|
||||
window.location.href = ssoUrl
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '资源访问失败')
|
||||
} finally {
|
||||
usingResource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function credentialStorageKey(resourceId: number): string {
|
||||
return `bastion.resource.credentials.${resourceId}`
|
||||
}
|
||||
|
||||
function readSavedCredentials(resourceId: number): { protocol: string; account_name: string; password: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(credentialStorageKey(resourceId))
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
protocol: String(parsed.protocol || ''),
|
||||
account_name: String(parsed.account_name || ''),
|
||||
password: String(parsed.password || ''),
|
||||
}
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function persistCredentials(resourceId: number, payload: { protocol: string; account_name: string; password: string; remember: boolean }): void {
|
||||
const key = credentialStorageKey(resourceId)
|
||||
const existing = localStorage.getItem(key)
|
||||
if (!payload.remember) {
|
||||
if (existing) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
protocol: payload.protocol,
|
||||
account_name: payload.account_name,
|
||||
password: payload.password,
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchOpsMeta(): Promise<void> {
|
||||
opsLoading.value = true
|
||||
try {
|
||||
const response: any = await serversApi.opsMeta()
|
||||
opsProtocols.value = response.data.protocols || []
|
||||
const preferences = response.data.preferences || {}
|
||||
for (const protocol of opsProtocols.value) {
|
||||
const key = String(protocol.id)
|
||||
opsSelections[key] = preferences[key] ? Number(preferences[key]) : null
|
||||
}
|
||||
if (!form.protocol) {
|
||||
form.protocol = resourceProtocolOptions.value[0] || ''
|
||||
}
|
||||
} finally {
|
||||
opsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetProtocolForm(): void {
|
||||
Object.assign(protocolForm, { name: '', bastion_protocol_id: 2, description: '', sort: 0, is_active: true })
|
||||
}
|
||||
|
||||
function openCreateProtocol(): void {
|
||||
editingProtocolId.value = null
|
||||
resetProtocolForm()
|
||||
protocolDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditProtocol(row: any): void {
|
||||
editingProtocolId.value = row.id
|
||||
Object.assign(protocolForm, {
|
||||
name: row.name,
|
||||
bastion_protocol_id: row.bastion_protocol_id || 2,
|
||||
description: row.description || '',
|
||||
sort: row.sort || 0,
|
||||
is_active: Boolean(row.is_active),
|
||||
})
|
||||
protocolDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitProtocol(): Promise<void> {
|
||||
opsSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: protocolForm.name,
|
||||
bastion_protocol_id: Number(protocolForm.bastion_protocol_id || 2),
|
||||
description: protocolForm.description || '',
|
||||
sort: protocolForm.sort || 0,
|
||||
is_active: Boolean(protocolForm.is_active),
|
||||
}
|
||||
if (editingProtocolId.value) {
|
||||
await serversApi.updateOpsProtocol(editingProtocolId.value, payload)
|
||||
ElMessage.success('协议更新成功')
|
||||
} else {
|
||||
await serversApi.createOpsProtocol(payload)
|
||||
ElMessage.success('协议创建成功')
|
||||
}
|
||||
protocolDialogVisible.value = false
|
||||
await fetchOpsMeta()
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '协议保存失败')
|
||||
} finally {
|
||||
opsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProtocol(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除协议 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
await serversApi.removeOpsProtocol(row.id)
|
||||
ElMessage.success('协议删除成功')
|
||||
await fetchOpsMeta()
|
||||
}
|
||||
|
||||
function resetSoftwareForm(): void {
|
||||
editingSoftwareId.value = null
|
||||
Object.assign(softwareForm, { name: '', client_path: '', sort: 0, is_active: true })
|
||||
}
|
||||
|
||||
function openSoftwareDialog(row: any): void {
|
||||
softwareProtocol.value = row
|
||||
resetSoftwareForm()
|
||||
softwareDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openCreateSoftware(): void {
|
||||
resetSoftwareForm()
|
||||
}
|
||||
|
||||
function openEditSoftware(row: any): void {
|
||||
editingSoftwareId.value = row.id
|
||||
Object.assign(softwareForm, {
|
||||
name: row.name,
|
||||
client_path: row.client_path || '',
|
||||
sort: row.sort || 0,
|
||||
is_active: Boolean(row.is_active),
|
||||
})
|
||||
}
|
||||
|
||||
async function submitSoftware(): Promise<void> {
|
||||
if (!softwareProtocol.value?.id) {
|
||||
ElMessage.warning('未选择协议')
|
||||
return
|
||||
}
|
||||
opsSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: softwareForm.name,
|
||||
client_path: softwareForm.client_path || '',
|
||||
sort: softwareForm.sort || 0,
|
||||
is_active: Boolean(softwareForm.is_active),
|
||||
}
|
||||
if (editingSoftwareId.value) {
|
||||
await serversApi.updateOpsSoftware(editingSoftwareId.value, payload)
|
||||
ElMessage.success('软件更新成功')
|
||||
} else {
|
||||
await serversApi.createOpsSoftware(softwareProtocol.value.id, payload)
|
||||
ElMessage.success('软件创建成功')
|
||||
}
|
||||
resetSoftwareForm()
|
||||
await fetchOpsMeta()
|
||||
softwareProtocol.value = opsProtocols.value.find((item) => item.id === softwareProtocol.value?.id) || null
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '软件保存失败')
|
||||
} finally {
|
||||
opsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSoftware(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除软件 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
await serversApi.removeOpsSoftware(row.id)
|
||||
ElMessage.success('软件删除成功')
|
||||
await fetchOpsMeta()
|
||||
softwareProtocol.value = opsProtocols.value.find((item) => item.id === softwareProtocol.value?.id) || null
|
||||
}
|
||||
|
||||
async function saveOpsPreferences(): Promise<void> {
|
||||
opsSaving.value = true
|
||||
try {
|
||||
const items = opsProtocols.value.map((protocol) => ({
|
||||
protocol_id: protocol.id,
|
||||
software_id: opsSelections[String(protocol.id)] || null,
|
||||
}))
|
||||
await serversApi.saveOpsPreferences(items)
|
||||
ElMessage.success('运维软件选择已保存')
|
||||
} finally {
|
||||
opsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runOpsLink(row: any): Promise<void> {
|
||||
const protocolId = Number(row.id)
|
||||
const softwareId = opsSelections[String(protocolId)] || null
|
||||
if (!softwareId) {
|
||||
ElMessage.warning('请先为该协议选择软件')
|
||||
return
|
||||
}
|
||||
|
||||
opsLinkLoading.value[protocolId] = true
|
||||
try {
|
||||
const response: any = await serversApi.generateOpsLink({
|
||||
protocol_id: protocolId,
|
||||
software_id: softwareId,
|
||||
})
|
||||
const link = response?.data?.link
|
||||
if (!link) {
|
||||
ElMessage.error('未获取到运维连接')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已发送连接,正在执行...')
|
||||
window.location.href = link
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '生成连接失败')
|
||||
} finally {
|
||||
opsLinkLoading.value[protocolId] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除 ${row.name || row.id} 吗?`, '提示', { type: 'warning' })
|
||||
await serversApi.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchList(), fetchOpsMeta()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-title { display: flex; align-items: center; gap: 14px; font-weight: 600; }
|
||||
.server-name { font-size: 16px; color: #0f172a; }
|
||||
.server-code { color: #475569; font-size: 13px; font-weight: 600; }
|
||||
.server-ip { color: #64748b; font-size: 14px; }
|
||||
:deep(.server-collapse) { border-top: 0; border-bottom: 0; }
|
||||
:deep(.server-collapse .el-collapse-item__header) { border-bottom: 0; height: 52px; }
|
||||
:deep(.server-collapse .el-collapse-item__wrap) { border-bottom: 0; }
|
||||
:deep(.server-collapse .with-divider .el-collapse-item__header) { border-top: 1px solid #e2e8f0; }
|
||||
</style>
|
||||
|
||||
227
src/pages/UsersPage.vue
Normal file
227
src/pages/UsersPage.vue
Normal file
@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class='flex justify-between items-center'>
|
||||
<span class='font-700'>用户管理</span>
|
||||
<el-button v-if='hasPermission("platform.users.manage")' type='primary' @click='openCreate'>新增用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data='rows' v-loading='loading' @sort-change='handleSortChange'>
|
||||
<el-table-column prop='id' label='ID' width='70' sortable='custom' />
|
||||
<el-table-column prop='nickname' label='昵称' min-width='130' sortable='custom' />
|
||||
<el-table-column prop='email' label='邮箱' min-width='180' sortable='custom' />
|
||||
<el-table-column prop='phone' label='手机号' min-width='140' sortable='custom' />
|
||||
<el-table-column label='角色' min-width='160'>
|
||||
<template #default='{ row }'>{{ (row.roles || []).map((r:any) => r.name).join(', ') || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label='直授权限' min-width='220'>
|
||||
<template #default='{ row }'>{{ (row.permissions || []).map((p:any) => p.name).join(', ') || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if='hasPermission("platform.users.manage")' label='操作' width='220'>
|
||||
<template #default='{ row }'>
|
||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
||||
<el-button size='small' @click='openEdit(row)'>编辑</el-button>
|
||||
<el-button size='small' type='warning' @click='openPermissionDialog(row)'>分配权限</el-button>
|
||||
<el-button size='small' type='danger' @click='removeRow(row)'>删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination class='mt-4' layout='total, prev, pager, next' :total='total' :current-page='page' :page-size='perPage' @current-change='handlePageChange' />
|
||||
|
||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑用户" : "新增用户"' width='560px'>
|
||||
<el-form :model='form' label-width='95px'>
|
||||
<el-form-item label='昵称'><el-input v-model='form.nickname' /></el-form-item>
|
||||
<el-form-item label='邮箱'><el-input v-model='form.email' /></el-form-item>
|
||||
<el-form-item label='手机号'><el-input v-model='form.phone' /></el-form-item>
|
||||
<el-form-item label='密码'><el-input v-model='form.password' type='password' show-password /></el-form-item>
|
||||
<el-form-item label='角色'>
|
||||
<el-select v-model='form.role_ids' multiple collapse-tags>
|
||||
<el-option v-for='item in roleOptions' :key='item.id' :label='item.name' :value='item.id' />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click='dialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model='permissionDialogVisible' title='用户直授权限分配' width='760px'>
|
||||
<div class='permission-panel-wrap'>
|
||||
<el-cascader-panel v-model='selectedPermissionNodes' :options='permissionCascader' :props='cascaderProps' class='permission-panel' />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click='permissionDialogVisible = false'>取消</el-button>
|
||||
<el-button type='primary' :loading='savingPermissions' @click='submitPermissions'>保存权限</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts'>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { permissionsApi } from '@/api/permissions'
|
||||
import { rolesApi } from '@/api/roles'
|
||||
import { usersApi } from '@/api/users'
|
||||
import { buildPermissionCascader, extractPermissionIds } from '@/composables/permission-tree'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = authStore
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const savingPermissions = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const permissionDialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const permissionTargetUserId = ref<number | null>(null)
|
||||
const rows = ref<any[]>([])
|
||||
const roleOptions = ref<any[]>([])
|
||||
const permissionOptions = ref<any[]>([])
|
||||
const selectedPermissionNodes = ref<Array<number | string>>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const sortBy = ref('created_at')
|
||||
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const form = reactive<any>({ nickname: '', email: '', phone: '', password: '', role_ids: [] })
|
||||
const cascaderProps = { multiple: true, emitPath: false, checkStrictly: false }
|
||||
const permissionCascader = computed(() => buildPermissionCascader(permissionOptions.value))
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await usersApi.list({
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: sortOrder.value,
|
||||
})
|
||||
const list = response.data.data || []
|
||||
rows.value = await Promise.all(list.map(async (item: any) => (await usersApi.detail(item.id) as any).data))
|
||||
total.value = response.data.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOptions(): Promise<void> {
|
||||
const [rolesResponse, permissionsResponse]: any = await Promise.all([
|
||||
rolesApi.list({ page: 1, per_page: 200 }),
|
||||
permissionsApi.list({ page: 1, per_page: 500 }),
|
||||
])
|
||||
roleOptions.value = rolesResponse.data.data || []
|
||||
permissionOptions.value = permissionsResponse.data.data || []
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number): void {
|
||||
page.value = nextPage
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleSortChange(payload: { prop: string, order: 'ascending' | 'descending' | null }): void {
|
||||
if (!payload.prop || !payload.order) {
|
||||
sortBy.value = 'created_at'
|
||||
sortOrder.value = 'desc'
|
||||
page.value = 1
|
||||
fetchList()
|
||||
return
|
||||
}
|
||||
|
||||
sortBy.value = payload.prop
|
||||
sortOrder.value = payload.order === 'ascending' ? 'asc' : 'desc'
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, { nickname: '', email: '', phone: '', password: '', role_ids: [] })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
nickname: row.nickname,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
password: '',
|
||||
role_ids: (row.roles || []).map((item: any) => item.id),
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openPermissionDialog(row: any): void {
|
||||
permissionTargetUserId.value = row.id
|
||||
selectedPermissionNodes.value = (row.permissions || []).map((item: any) => item.id)
|
||||
permissionDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = { ...form }
|
||||
if (!payload.password) {
|
||||
delete payload.password
|
||||
}
|
||||
|
||||
if (editingId.value) {
|
||||
await usersApi.update(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await usersApi.create(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch (error: any) {
|
||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||
ElMessage.error(first || error?.message || '提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPermissions(): Promise<void> {
|
||||
if (!permissionTargetUserId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
savingPermissions.value = true
|
||||
try {
|
||||
const permissionIds = extractPermissionIds(selectedPermissionNodes.value)
|
||||
await usersApi.syncPermissions(permissionTargetUserId.value, permissionIds)
|
||||
ElMessage.success('权限更新成功')
|
||||
permissionDialogVisible.value = false
|
||||
await fetchList()
|
||||
} finally {
|
||||
savingPermissions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row: any): Promise<void> {
|
||||
await ElMessageBox.confirm(`确认删除用户 ${row.nickname} 吗?`, '提示', { type: 'warning' })
|
||||
await usersApi.remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchOptions(), fetchList()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.permission-panel-wrap { width: 100%; overflow-x: auto; }
|
||||
.permission-panel { min-width: 100%; }
|
||||
:deep(.permission-panel .el-cascader-menu) { min-width: 220px; }
|
||||
</style>
|
||||
|
||||
66
src/router/index.ts
Normal file
66
src/router/index.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { getToken, removeToken } from '@/composables/token'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', component: () => import('@/pages/DashboardPage.vue') },
|
||||
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
||||
{ path: 'users', component: () => import('@/pages/UsersPage.vue') },
|
||||
{ path: 'roles', component: () => import('@/pages/RolesPage.vue') },
|
||||
{ path: 'permissions', component: () => import('@/pages/PermissionsPage.vue') },
|
||||
{ path: 'servers', component: () => import('@/pages/ServersPage.vue') },
|
||||
{ path: 'accounts', component: () => import('@/pages/AccountsPage.vue') },
|
||||
{ path: 'logs', component: () => import('@/pages/LogsPage.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', component: () => import('@/pages/NotFoundPage.vue') },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
let loaded = false
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const token = getToken()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (!token && !to.meta.guest) {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
if (token && !loaded) {
|
||||
try {
|
||||
const response = await authApi.me()
|
||||
authStore.setAuth(response.data.user, response.data.permissions)
|
||||
loaded = true
|
||||
} catch (_error) {
|
||||
removeToken()
|
||||
authStore.clearAuth()
|
||||
loaded = false
|
||||
ElMessage.error('登录状态无效,请重新登录')
|
||||
return '/login'
|
||||
}
|
||||
}
|
||||
|
||||
if (token && to.meta.guest) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
50
src/stores/auth.ts
Normal file
50
src/stores/auth.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface AuthUser {
|
||||
id: number
|
||||
nickname: string
|
||||
email: string
|
||||
phone?: string
|
||||
roles: Array<{ id: number; name: string; guard_name: string }>
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const permissions = ref<string[]>([])
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.value?.roles?.some((role) => role.name === 'admin') ?? false
|
||||
})
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(user.value))
|
||||
|
||||
function setAuth(nextUser: AuthUser, nextPermissions: string[]): void {
|
||||
user.value = nextUser
|
||||
permissions.value = nextPermissions
|
||||
}
|
||||
|
||||
function clearAuth(): void {
|
||||
user.value = null
|
||||
permissions.value = []
|
||||
}
|
||||
|
||||
function hasPermission(permission: string): boolean {
|
||||
if (isAdmin.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
return permissions.value.includes(permission)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
permissions,
|
||||
isAdmin,
|
||||
isLoggedIn,
|
||||
setAuth,
|
||||
clearAuth,
|
||||
hasPermission,
|
||||
}
|
||||
})
|
||||
|
||||
51
src/style.css
Normal file
51
src/style.css
Normal file
@ -0,0 +1,51 @@
|
||||
html {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #0f172a;
|
||||
background: #f8fafc;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.btn-gap-8 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-gap-8--nowrap {
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-gap-8--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-gap-8 > .el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.el-dialog__footer .el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
16
tsconfig.app.json
Normal file
16
tsconfig.app.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0",
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
|
||||
}
|
||||
1
tsconfig.app.tsbuildinfo
Normal file
1
tsconfig.app.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts","./src/app.vue","./src/axios.ts","./src/env.d.ts","./src/main.ts","./src/api/accounts.ts","./src/api/auth.ts","./src/api/logs.ts","./src/api/permissions.ts","./src/api/roles.ts","./src/api/servers.ts","./src/api/users.ts","./src/components/helloworld.vue","./src/composables/config.ts","./src/composables/crypto.ts","./src/composables/permission.ts","./src/composables/token.ts","./src/composables/util.ts","./src/layouts/mainlayout.vue","./src/pages/accountspage.vue","./src/pages/dashboardpage.vue","./src/pages/loginpage.vue","./src/pages/logspage.vue","./src/pages/notfoundpage.vue","./src/pages/permissionspage.vue","./src/pages/rolespage.vue","./src/pages/serverspage.vue","./src/pages/userspage.vue","./src/router/index.ts","./src/stores/auth.ts"],"errors":true,"version":"6.0.3"}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
42
vite.config.ts
Normal file
42
vite.config.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vueDevTools from 'vite-plugin-vue-devtools';
|
||||
import UnoCSS from 'unocss/vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
UnoCSS(),
|
||||
AutoImport({
|
||||
imports: ['vue'],
|
||||
resolvers: [
|
||||
ElementPlusResolver(),
|
||||
],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
ElementPlusResolver(),
|
||||
],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // 后端地址
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, '') // 去掉 /api 前缀
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user