feat: 完成基础工程编写

This commit is contained in:
Boen_Shi 2026-04-27 15:33:13 +08:00
commit d4bf91868d
52 changed files with 7412 additions and 0 deletions

5
.env.prod Normal file
View 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
View 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
View 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 Tokencookie进行鉴权。
- 通过 `/auth/me` 获取用户权限并控制菜单和按钮显隐。
- 统一处理 `401/403/422` 常见错误场景。

71
auto-imports.d.ts vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View 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
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

22
src/api/accounts.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

167
src/assets/vite.svg Normal file
View 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
View 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
View 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

View 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
View 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
}
})
}

View 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()
}

View 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)
}

View 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')
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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
View 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 前缀
}
}
}
})

2827
yarn.lock Normal file

File diff suppressed because it is too large Load Diff