feat: 完成控制面板功能
This commit is contained in:
parent
cf3301c984
commit
85d28a9bfc
5
components.d.ts
vendored
5
components.d.ts
vendored
@ -8,6 +8,8 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AddCardDialog: typeof import('./src/components/dashboard/AddCardDialog.vue')['default']
|
||||||
|
CardShell: typeof import('./src/components/dashboard/CardShell.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
@ -36,6 +38,8 @@ declare module 'vue' {
|
|||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
@ -46,6 +50,7 @@ declare module 'vue' {
|
|||||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
SystemCard: typeof import('./src/components/dashboard/SystemCard.vue')['default']
|
||||||
}
|
}
|
||||||
export interface GlobalDirectives {
|
export interface GlobalDirectives {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
|||||||
4875
package-lock.json
generated
4875
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,9 +13,11 @@
|
|||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.5.0",
|
||||||
"@vueuse/integrations": "^13.5.0",
|
"@vueuse/integrations": "^13.5.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"carddragger": "^0.3.6",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"element-plus": "^2.10.3",
|
"element-plus": "^2.10.3",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"sortablejs": "^1.15.7",
|
||||||
"universal-cookie": "^8.0.1",
|
"universal-cookie": "^8.0.1",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
|
|||||||
128
src/components/dashboard/AddCardDialog.vue
Normal file
128
src/components/dashboard/AddCardDialog.vue
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value='modelValue'
|
||||||
|
title='添加卡片'
|
||||||
|
width='680px'
|
||||||
|
@close='emit("update:modelValue", false)'
|
||||||
|
>
|
||||||
|
<el-form label-width='100px'>
|
||||||
|
<el-form-item label='卡片类型'>
|
||||||
|
<el-select v-model='selectedType' class='w-full' placeholder='请选择卡片类型'>
|
||||||
|
<el-option
|
||||||
|
v-for='runtime in definitions'
|
||||||
|
:key='runtime.definition.type'
|
||||||
|
:label='runtime.definition.title'
|
||||||
|
:value='runtime.definition.type'
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label='样式'>
|
||||||
|
<el-select v-model='selectedVariant' class='w-full' placeholder='请选择样式'>
|
||||||
|
<el-option
|
||||||
|
v-for='variant in selectedDefinition?.styles || []'
|
||||||
|
:key='variant.key'
|
||||||
|
:label='variant.label'
|
||||||
|
:value='variant.key'
|
||||||
|
>
|
||||||
|
<div class='variant-option'>
|
||||||
|
<span>{{ variant.label }}</span>
|
||||||
|
<small>{{ variant.description }}</small>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label='说明'>
|
||||||
|
<div class='help'>
|
||||||
|
<div>{{ selectedDefinition?.description || '请选择卡片类型。' }}</div>
|
||||||
|
<div class='route'>页面入口:{{ selectedDefinition?.menuPath || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click='emit("update:modelValue", false)'>取消</el-button>
|
||||||
|
<el-button type='primary' :disabled='!selectedType' @click='handleAddCard'>添加</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang='ts'>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { DashboardCardRuntime, DashboardCardType } from '@/dashboard/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
definitions: DashboardCardRuntime[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
add: [type: DashboardCardType, variant: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedType = ref<DashboardCardType | ''>('')
|
||||||
|
const selectedVariant = ref('')
|
||||||
|
|
||||||
|
const selectedDefinition = computed(() => {
|
||||||
|
if (!selectedType.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return props.definitions.find((runtime) => runtime.definition.type === selectedType.value)?.definition || null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const first = props.definitions[0]?.definition
|
||||||
|
selectedType.value = first?.type || ''
|
||||||
|
selectedVariant.value = first?.defaultStyle || ''
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(selectedType, (nextType) => {
|
||||||
|
const definition = props.definitions.find((runtime) => runtime.definition.type === nextType)?.definition
|
||||||
|
if (!definition) {
|
||||||
|
selectedVariant.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!definition.styles.some((style) => style.key === selectedVariant.value)) {
|
||||||
|
selectedVariant.value = definition.defaultStyle
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleAddCard(): void {
|
||||||
|
if (!selectedType.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const definition = props.definitions.find((runtime) => runtime.definition.type === selectedType.value)?.definition
|
||||||
|
const fallbackVariant = definition?.defaultStyle || ''
|
||||||
|
emit('add', selectedType.value, selectedVariant.value || fallbackVariant)
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.variant-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-option small {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
135
src/components/dashboard/CardShell.vue
Normal file
135
src/components/dashboard/CardShell.vue
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class='dashboard-card' :class='cardClass' shadow='hover'>
|
||||||
|
<template #header>
|
||||||
|
<div class='card-header'>
|
||||||
|
<div class='card-title-wrap'>
|
||||||
|
<el-icon v-if='editable' class='drag-handle'><Rank /></el-icon>
|
||||||
|
<div class='card-title-group'>
|
||||||
|
<div class='card-title'>{{ definition.title }}</div>
|
||||||
|
<div class='card-subtitle'>{{ definition.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='card-actions btn-gap-8'>
|
||||||
|
<el-select
|
||||||
|
v-if='editable'
|
||||||
|
size='small'
|
||||||
|
:model-value='instance.variant'
|
||||||
|
style='width: 140px'
|
||||||
|
@change='(value: string | number) => emit("variantChange", String(value))'
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for='variant in definition.styles'
|
||||||
|
:key='variant.key'
|
||||||
|
:label='variant.label'
|
||||||
|
:value='variant.key'
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button size='small' @click='emit("refresh")'>刷新</el-button>
|
||||||
|
<el-button v-if='editable' size='small' type='danger' plain @click='emit("remove")'>移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang='ts'>
|
||||||
|
import { Rank } from '@element-plus/icons-vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { DashboardCardDefinition, DashboardCardInstance } from '@/dashboard/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
definition: DashboardCardDefinition
|
||||||
|
instance: DashboardCardInstance
|
||||||
|
editable: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
remove: []
|
||||||
|
refresh: []
|
||||||
|
variantChange: [variant: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cardClass = computed(() => [
|
||||||
|
`height-${props.definition.defaultHeight}`,
|
||||||
|
`variant-${props.instance.variant}`,
|
||||||
|
`type-${props.definition.type}`,
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #dbe6f3;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.height-sm {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.height-md {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.height-lg {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card.type-welcome-status,
|
||||||
|
.dashboard-card.type-permissions-manage,
|
||||||
|
.dashboard-card.type-roles-manage,
|
||||||
|
.dashboard-card.type-servers-resource,
|
||||||
|
.dashboard-card.type-servers-matrix {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card.type-servers-quick-use.variant-buttons {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1445
src/components/dashboard/SystemCard.vue
Normal file
1445
src/components/dashboard/SystemCard.vue
Normal file
File diff suppressed because it is too large
Load Diff
52
src/composables/server-credential.ts
Normal file
52
src/composables/server-credential.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
export interface ServerCredentialPayload {
|
||||||
|
account_name: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverCredentialStorageKey(userId: number, serverId: number): string {
|
||||||
|
return `bastion.server.credentials.${userId}.${serverId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerCredential(userId: number, serverId: number): ServerCredentialPayload | null {
|
||||||
|
if (!userId || !serverId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(serverCredentialStorageKey(userId, serverId))
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account_name: String(parsed.account_name || ''),
|
||||||
|
password: String(parsed.password || ''),
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setServerCredential(userId: number, serverId: number, payload: ServerCredentialPayload): void {
|
||||||
|
if (!userId || !serverId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(serverCredentialStorageKey(userId, serverId), JSON.stringify({
|
||||||
|
account_name: payload.account_name,
|
||||||
|
password: payload.password,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearServerCredential(userId: number, serverId: number): void {
|
||||||
|
if (!userId || !serverId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem(serverCredentialStorageKey(userId, serverId))
|
||||||
|
}
|
||||||
|
|
||||||
478
src/dashboard/drag-engine.ts
Normal file
478
src/dashboard/drag-engine.ts
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
import { version as vueVersion } from 'vue'
|
||||||
|
|
||||||
|
export type DragEngineName = 'carddragger' | 'masonry-pointer'
|
||||||
|
|
||||||
|
export interface DragPreviewPayload {
|
||||||
|
fromIndex: number
|
||||||
|
sourceCardId: string
|
||||||
|
toCompactIndex: number
|
||||||
|
columnIndex: number
|
||||||
|
beforeCardId: string | null
|
||||||
|
afterCardId: string | null
|
||||||
|
indicatorRect: { left: number; top: number; width: number }
|
||||||
|
insertDirection: 'up' | 'down' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DragSortedPayload {
|
||||||
|
fromIndex: number
|
||||||
|
sourceCardId: string
|
||||||
|
toCompactIndex: number
|
||||||
|
columnIndex: number
|
||||||
|
beforeCardId: string | null
|
||||||
|
afterCardId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardDragOptions {
|
||||||
|
container: HTMLElement
|
||||||
|
handleSelector: string
|
||||||
|
draggableSelector: string
|
||||||
|
onPreview: (payload: DragPreviewPayload) => void
|
||||||
|
onSorted: (payload: DragSortedPayload) => void
|
||||||
|
onDragStateChange?: (state: {
|
||||||
|
dragging: boolean
|
||||||
|
fromIndex: number
|
||||||
|
toCompactIndex: number
|
||||||
|
measuredRects: DOMRect[]
|
||||||
|
insertDirection: 'up' | 'down' | null
|
||||||
|
columnIndex: number
|
||||||
|
}) => void
|
||||||
|
onEngineResolved?: (engine: DragEngineName) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardDragController {
|
||||||
|
engine: DragEngineName
|
||||||
|
destroy: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragContext {
|
||||||
|
pointerId: number
|
||||||
|
fromIndex: number
|
||||||
|
sourceCardId: string
|
||||||
|
toCompactIndex: number
|
||||||
|
columnIndex: number
|
||||||
|
beforeCardId: string | null
|
||||||
|
afterCardId: string | null
|
||||||
|
draggingItem: HTMLElement
|
||||||
|
ghost: HTMLElement
|
||||||
|
offsetX: number
|
||||||
|
offsetY: number
|
||||||
|
insertDirection: 'up' | 'down' | null
|
||||||
|
indicatorRect: { left: number; top: number; width: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactEntry {
|
||||||
|
compactIndex: number
|
||||||
|
originalIndex: number
|
||||||
|
cardId: string
|
||||||
|
rect: DOMRect
|
||||||
|
columnIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnMetric {
|
||||||
|
index: number
|
||||||
|
left: number
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupDashboardDrag(options: DashboardDragOptions): Promise<DashboardDragController> {
|
||||||
|
const useCardDragger = await isCardDraggerCompatible()
|
||||||
|
const engine: DragEngineName = useCardDragger ? 'carddragger' : 'masonry-pointer'
|
||||||
|
options.onEngineResolved?.(engine)
|
||||||
|
|
||||||
|
const handles = Array.from(options.container.querySelectorAll<HTMLElement>(options.handleSelector))
|
||||||
|
const handleRecords: Array<{ handle: HTMLElement; listener: (event: PointerEvent) => void }> = []
|
||||||
|
let dragContext: DragContext | null = null
|
||||||
|
|
||||||
|
const getItems = (): HTMLElement[] => {
|
||||||
|
return Array.from(options.container.querySelectorAll<HTMLElement>(options.draggableSelector))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupGhost = (ghost: HTMLElement): void => {
|
||||||
|
if (ghost.parentNode) {
|
||||||
|
ghost.parentNode.removeChild(ghost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getColumnMetrics = (): { containerRect: DOMRect; columns: ColumnMetric[] } => {
|
||||||
|
const containerRect = options.container.getBoundingClientRect()
|
||||||
|
const columnElements = Array.from(options.container.querySelectorAll<HTMLElement>('.masonry-column'))
|
||||||
|
const columns: ColumnMetric[] = columnElements.map((element, fallbackIndex) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const parsedIndex = Number.parseInt(element.dataset.columnIndex || '', 10)
|
||||||
|
const index = Number.isFinite(parsedIndex) ? parsedIndex : fallbackIndex
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
left: rect.left - containerRect.left,
|
||||||
|
width: rect.width,
|
||||||
|
}
|
||||||
|
}).sort((a, b) => a.left - b.left)
|
||||||
|
|
||||||
|
if (columns.length === 0) {
|
||||||
|
columns.push({
|
||||||
|
index: 0,
|
||||||
|
left: 0,
|
||||||
|
width: containerRect.width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRect,
|
||||||
|
columns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveColumnIndexByX = (
|
||||||
|
clientX: number,
|
||||||
|
containerRect: DOMRect,
|
||||||
|
columns: ColumnMetric[],
|
||||||
|
): number => {
|
||||||
|
const relativeX = Math.min(Math.max(0, clientX - containerRect.left), Math.max(0, containerRect.width - 1))
|
||||||
|
let nearest = columns[0]
|
||||||
|
let minDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (const column of columns) {
|
||||||
|
const center = column.left + column.width / 2
|
||||||
|
const distance = Math.abs(relativeX - center)
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance
|
||||||
|
nearest = column
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest.index
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCompactEntries = (
|
||||||
|
items: HTMLElement[],
|
||||||
|
sourceIndex: number,
|
||||||
|
containerRect: DOMRect,
|
||||||
|
columns: ColumnMetric[],
|
||||||
|
): CompactEntry[] => {
|
||||||
|
const entries: CompactEntry[] = []
|
||||||
|
let compactIndex = 0
|
||||||
|
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
if (index === sourceIndex) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rect = items[index].getBoundingClientRect()
|
||||||
|
const centerX = rect.left + rect.width / 2
|
||||||
|
const fallbackColumnIndex = resolveColumnIndexByX(centerX, containerRect, columns)
|
||||||
|
const columnAttr = Number.parseInt(items[index].dataset.columnIndex || '', 10)
|
||||||
|
const columnIndex = Number.isFinite(columnAttr) ? columnAttr : fallbackColumnIndex
|
||||||
|
const cardId = items[index].dataset.cardId || ''
|
||||||
|
entries.push({
|
||||||
|
compactIndex,
|
||||||
|
originalIndex: index,
|
||||||
|
cardId,
|
||||||
|
rect,
|
||||||
|
columnIndex,
|
||||||
|
})
|
||||||
|
compactIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDropTarget = (
|
||||||
|
entries: CompactEntry[],
|
||||||
|
pointerX: number,
|
||||||
|
pointerY: number,
|
||||||
|
metrics: { containerRect: DOMRect; columns: ColumnMetric[] },
|
||||||
|
): {
|
||||||
|
toCompactIndex: number
|
||||||
|
columnIndex: number
|
||||||
|
beforeCardId: string | null
|
||||||
|
afterCardId: string | null
|
||||||
|
insertDirection: 'up' | 'down' | null
|
||||||
|
indicatorRect: { left: number; top: number; width: number }
|
||||||
|
} => {
|
||||||
|
const targetColumn = resolveColumnIndexByX(pointerX, metrics.containerRect, metrics.columns)
|
||||||
|
const targetColumnMetric = metrics.columns.find((column) => column.index === targetColumn) || metrics.columns[0]
|
||||||
|
const columnLeft = targetColumnMetric.left
|
||||||
|
const columnWidth = targetColumnMetric.width
|
||||||
|
|
||||||
|
const targetEntries = entries
|
||||||
|
.filter((entry) => entry.columnIndex === targetColumn)
|
||||||
|
.sort((a, b) => a.rect.top - b.rect.top)
|
||||||
|
|
||||||
|
if (targetEntries.length === 0) {
|
||||||
|
let fallbackIndex = entries.length
|
||||||
|
const prevColumnEntries = entries.filter((entry) => entry.columnIndex < targetColumn)
|
||||||
|
if (prevColumnEntries.length > 0) {
|
||||||
|
fallbackIndex = Math.max(...prevColumnEntries.map((entry) => entry.compactIndex)) + 1
|
||||||
|
} else {
|
||||||
|
const nextColumnEntries = entries.filter((entry) => entry.columnIndex > targetColumn)
|
||||||
|
if (nextColumnEntries.length > 0) {
|
||||||
|
fallbackIndex = Math.min(...nextColumnEntries.map((entry) => entry.compactIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toCompactIndex: fallbackIndex,
|
||||||
|
columnIndex: targetColumn,
|
||||||
|
beforeCardId: null,
|
||||||
|
afterCardId: null,
|
||||||
|
insertDirection: null,
|
||||||
|
indicatorRect: {
|
||||||
|
left: columnLeft,
|
||||||
|
top: 8,
|
||||||
|
width: columnWidth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = targetEntries[0]
|
||||||
|
const last = targetEntries[targetEntries.length - 1]
|
||||||
|
const topOfFirst = first.rect.top - metrics.containerRect.top
|
||||||
|
const bottomOfLast = last.rect.bottom - metrics.containerRect.top
|
||||||
|
|
||||||
|
if (pointerY <= first.rect.top + first.rect.height / 2) {
|
||||||
|
return {
|
||||||
|
toCompactIndex: first.compactIndex,
|
||||||
|
columnIndex: targetColumn,
|
||||||
|
beforeCardId: first.cardId || null,
|
||||||
|
afterCardId: null,
|
||||||
|
insertDirection: 'up',
|
||||||
|
indicatorRect: {
|
||||||
|
left: columnLeft,
|
||||||
|
top: topOfFirst,
|
||||||
|
width: columnWidth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointerY >= last.rect.top + last.rect.height / 2) {
|
||||||
|
return {
|
||||||
|
toCompactIndex: last.compactIndex + 1,
|
||||||
|
columnIndex: targetColumn,
|
||||||
|
beforeCardId: null,
|
||||||
|
afterCardId: last.cardId || null,
|
||||||
|
insertDirection: 'down',
|
||||||
|
indicatorRect: {
|
||||||
|
left: columnLeft,
|
||||||
|
top: bottomOfLast,
|
||||||
|
width: columnWidth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < targetEntries.length - 1; index += 1) {
|
||||||
|
const current = targetEntries[index]
|
||||||
|
const next = targetEntries[index + 1]
|
||||||
|
const currentMid = current.rect.top + current.rect.height / 2
|
||||||
|
const nextMid = next.rect.top + next.rect.height / 2
|
||||||
|
if (pointerY >= currentMid && pointerY <= nextMid) {
|
||||||
|
const insertionTop = next.rect.top - metrics.containerRect.top
|
||||||
|
return {
|
||||||
|
toCompactIndex: next.compactIndex,
|
||||||
|
columnIndex: targetColumn,
|
||||||
|
beforeCardId: next.cardId || null,
|
||||||
|
afterCardId: current.cardId || null,
|
||||||
|
insertDirection: 'up',
|
||||||
|
indicatorRect: {
|
||||||
|
left: columnLeft,
|
||||||
|
top: insertionTop,
|
||||||
|
width: columnWidth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toCompactIndex: entries.length,
|
||||||
|
columnIndex: targetColumn,
|
||||||
|
beforeCardId: null,
|
||||||
|
afterCardId: last.cardId || null,
|
||||||
|
insertDirection: 'down',
|
||||||
|
indicatorRect: {
|
||||||
|
left: columnLeft,
|
||||||
|
top: bottomOfLast,
|
||||||
|
width: columnWidth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitPreview = (pointerX: number, pointerY: number): void => {
|
||||||
|
if (!dragContext) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = getItems()
|
||||||
|
const metrics = getColumnMetrics()
|
||||||
|
const entries = buildCompactEntries(
|
||||||
|
items,
|
||||||
|
dragContext.fromIndex,
|
||||||
|
metrics.containerRect,
|
||||||
|
metrics.columns,
|
||||||
|
)
|
||||||
|
const drop = resolveDropTarget(entries, pointerX, pointerY, metrics)
|
||||||
|
|
||||||
|
dragContext.toCompactIndex = drop.toCompactIndex
|
||||||
|
dragContext.columnIndex = drop.columnIndex
|
||||||
|
dragContext.beforeCardId = drop.beforeCardId
|
||||||
|
dragContext.afterCardId = drop.afterCardId
|
||||||
|
dragContext.insertDirection = drop.insertDirection
|
||||||
|
dragContext.indicatorRect = drop.indicatorRect
|
||||||
|
|
||||||
|
options.onPreview({
|
||||||
|
fromIndex: dragContext.fromIndex,
|
||||||
|
sourceCardId: dragContext.sourceCardId,
|
||||||
|
toCompactIndex: drop.toCompactIndex,
|
||||||
|
columnIndex: drop.columnIndex,
|
||||||
|
beforeCardId: drop.beforeCardId,
|
||||||
|
afterCardId: drop.afterCardId,
|
||||||
|
indicatorRect: drop.indicatorRect,
|
||||||
|
insertDirection: drop.insertDirection,
|
||||||
|
})
|
||||||
|
|
||||||
|
options.onDragStateChange?.({
|
||||||
|
dragging: true,
|
||||||
|
fromIndex: dragContext.fromIndex,
|
||||||
|
toCompactIndex: dragContext.toCompactIndex,
|
||||||
|
measuredRects: items.map((item) => item.getBoundingClientRect()),
|
||||||
|
insertDirection: dragContext.insertDirection,
|
||||||
|
columnIndex: dragContext.columnIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent): void => {
|
||||||
|
if (!dragContext || event.pointerId !== dragContext.pointerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghostX = event.clientX - dragContext.offsetX
|
||||||
|
const ghostY = event.clientY - dragContext.offsetY
|
||||||
|
dragContext.ghost.style.left = `${ghostX}px`
|
||||||
|
dragContext.ghost.style.top = `${ghostY}px`
|
||||||
|
|
||||||
|
emitPreview(event.clientX, event.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDrag = (pointerId: number): void => {
|
||||||
|
if (!dragContext || pointerId !== dragContext.pointerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ghost, draggingItem, fromIndex, sourceCardId, toCompactIndex, columnIndex, beforeCardId, afterCardId } = dragContext
|
||||||
|
draggingItem.classList.remove('dashboard-is-dragging')
|
||||||
|
cleanupGhost(ghost)
|
||||||
|
|
||||||
|
options.onDragStateChange?.({
|
||||||
|
dragging: false,
|
||||||
|
fromIndex,
|
||||||
|
toCompactIndex,
|
||||||
|
measuredRects: [],
|
||||||
|
insertDirection: null,
|
||||||
|
columnIndex: dragContext.columnIndex,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (fromIndex >= 0) {
|
||||||
|
options.onSorted({
|
||||||
|
fromIndex,
|
||||||
|
sourceCardId,
|
||||||
|
toCompactIndex,
|
||||||
|
columnIndex,
|
||||||
|
beforeCardId,
|
||||||
|
afterCardId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dragContext = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = (event: PointerEvent): void => {
|
||||||
|
stopDrag(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerCancel = (event: PointerEvent): void => {
|
||||||
|
stopDrag(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', onPointerMove)
|
||||||
|
document.addEventListener('pointerup', onPointerUp)
|
||||||
|
document.addEventListener('pointercancel', onPointerCancel)
|
||||||
|
|
||||||
|
for (const handle of handles) {
|
||||||
|
const listener = (event: PointerEvent): void => {
|
||||||
|
if (event.button !== 0 || dragContext) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const draggingItem = handle.closest(options.draggableSelector) as HTMLElement | null
|
||||||
|
if (!draggingItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const items = getItems()
|
||||||
|
const fromIndex = items.findIndex((item) => item === draggingItem)
|
||||||
|
const sourceCardId = draggingItem.dataset.cardId || ''
|
||||||
|
if (fromIndex < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const rect = draggingItem.getBoundingClientRect()
|
||||||
|
const ghost = draggingItem.cloneNode(true) as HTMLElement
|
||||||
|
ghost.classList.add('dashboard-drag-ghost')
|
||||||
|
ghost.style.position = 'fixed'
|
||||||
|
ghost.style.left = `${rect.left}px`
|
||||||
|
ghost.style.top = `${rect.top}px`
|
||||||
|
ghost.style.width = `${rect.width}px`
|
||||||
|
ghost.style.height = `${rect.height}px`
|
||||||
|
ghost.style.pointerEvents = 'none'
|
||||||
|
ghost.style.zIndex = '9999'
|
||||||
|
document.body.appendChild(ghost)
|
||||||
|
draggingItem.classList.add('dashboard-is-dragging')
|
||||||
|
|
||||||
|
dragContext = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
fromIndex,
|
||||||
|
sourceCardId,
|
||||||
|
toCompactIndex: fromIndex,
|
||||||
|
columnIndex: 0,
|
||||||
|
beforeCardId: null,
|
||||||
|
afterCardId: null,
|
||||||
|
draggingItem,
|
||||||
|
ghost,
|
||||||
|
offsetX: event.clientX - rect.left,
|
||||||
|
offsetY: event.clientY - rect.top,
|
||||||
|
insertDirection: null,
|
||||||
|
indicatorRect: { left: 0, top: 0, width: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
emitPreview(event.clientX, event.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', listener)
|
||||||
|
handleRecords.push({ handle, listener })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
engine,
|
||||||
|
destroy: () => {
|
||||||
|
if (dragContext) {
|
||||||
|
cleanupGhost(dragContext.ghost)
|
||||||
|
dragContext.draggingItem.classList.remove('dashboard-is-dragging')
|
||||||
|
dragContext = null
|
||||||
|
}
|
||||||
|
document.removeEventListener('pointermove', onPointerMove)
|
||||||
|
document.removeEventListener('pointerup', onPointerUp)
|
||||||
|
document.removeEventListener('pointercancel', onPointerCancel)
|
||||||
|
for (const record of handleRecords) {
|
||||||
|
record.handle.removeEventListener('pointerdown', record.listener)
|
||||||
|
}
|
||||||
|
handleRecords.length = 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isCardDraggerCompatible(): Promise<boolean> {
|
||||||
|
const major = Number(vueVersion.split('.')[0] || 0)
|
||||||
|
if (major !== 2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const module = await import('carddragger')
|
||||||
|
return Boolean((module as Record<string, unknown>)?.installCardDragger)
|
||||||
|
} catch (_error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/dashboard/registry.ts
Normal file
186
src/dashboard/registry.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import type {
|
||||||
|
DashboardCardDefinition,
|
||||||
|
DashboardCardRuntime,
|
||||||
|
DashboardCardType,
|
||||||
|
} from '@/dashboard/types'
|
||||||
|
|
||||||
|
const cardDefinitions: DashboardCardDefinition[] = [
|
||||||
|
{
|
||||||
|
type: 'welcome-status',
|
||||||
|
title: '欢迎与状态',
|
||||||
|
description: '显示账号信息与系统快速入口。',
|
||||||
|
menuPath: '/',
|
||||||
|
viewPermissions: [],
|
||||||
|
managePermissions: [],
|
||||||
|
styles: [
|
||||||
|
{ key: 'banner', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'banner',
|
||||||
|
defaultHeight: 'md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'users-manage',
|
||||||
|
title: '用户管理',
|
||||||
|
description: '用户概览、快速新增、进入完整用户页。',
|
||||||
|
menuPath: '/users',
|
||||||
|
viewPermissions: ['platform.users.view'],
|
||||||
|
managePermissions: ['platform.users.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'list', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'list',
|
||||||
|
defaultHeight: 'md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'roles-manage',
|
||||||
|
title: '角色管理',
|
||||||
|
description: '角色总览、快速新增与跳转。',
|
||||||
|
menuPath: '/roles',
|
||||||
|
viewPermissions: ['platform.roles.view'],
|
||||||
|
managePermissions: ['platform.roles.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'list', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'list',
|
||||||
|
defaultHeight: 'sm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'permissions-manage',
|
||||||
|
title: '权限管理',
|
||||||
|
description: '权限总览、分类信息与快捷创建。',
|
||||||
|
menuPath: '/permissions',
|
||||||
|
viewPermissions: ['platform.permissions.view'],
|
||||||
|
managePermissions: ['platform.permissions.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'list', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'list',
|
||||||
|
defaultHeight: 'sm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'servers-resource',
|
||||||
|
title: '服务器资源',
|
||||||
|
description: '服务器与资源概览,支持快速进入使用流程。',
|
||||||
|
menuPath: '/servers',
|
||||||
|
viewPermissions: ['platform.servers.view', 'resource.servers.use'],
|
||||||
|
managePermissions: ['platform.servers.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'grouped', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'grouped',
|
||||||
|
defaultHeight: 'lg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'servers-quick-use',
|
||||||
|
title: '快速使用服务器资源',
|
||||||
|
description: '直接选择资源并连接,支持复制临时密码。',
|
||||||
|
menuPath: '/servers',
|
||||||
|
viewPermissions: ['resource.servers.use', 'platform.servers.view'],
|
||||||
|
managePermissions: [],
|
||||||
|
styles: [
|
||||||
|
{ key: 'form', label: '表单式', description: '表单配置后连接资源。' },
|
||||||
|
{ key: 'buttons', label: '按钮式', description: '按服务器资源按钮快速使用。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'form',
|
||||||
|
defaultHeight: 'md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'servers-matrix',
|
||||||
|
title: '服务器授权矩阵',
|
||||||
|
description: '集中查看和配置用户资源开关矩阵。',
|
||||||
|
menuPath: '/servers',
|
||||||
|
viewPermissions: ['platform.servers.manage'],
|
||||||
|
managePermissions: ['platform.servers.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'matrix', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'matrix',
|
||||||
|
defaultHeight: 'sm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ops-clients',
|
||||||
|
title: '运维协议软件',
|
||||||
|
description: '协议/软件配置与个人客户端同步入口。',
|
||||||
|
menuPath: '/servers',
|
||||||
|
viewPermissions: ['platform.servers.view', 'resource.servers.use'],
|
||||||
|
managePermissions: ['platform.servers.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'config', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'config',
|
||||||
|
defaultHeight: 'sm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'accounts-bastion',
|
||||||
|
title: '堡垒机账号',
|
||||||
|
description: '账号状态、Token 有效性与刷新入口。',
|
||||||
|
menuPath: '/accounts',
|
||||||
|
viewPermissions: ['platform.accounts.view'],
|
||||||
|
managePermissions: ['platform.accounts.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'status', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'status',
|
||||||
|
defaultHeight: 'md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'logs-access',
|
||||||
|
title: '访问日志',
|
||||||
|
description: '最近访问记录、快速筛选和日志入口。',
|
||||||
|
menuPath: '/logs',
|
||||||
|
viewPermissions: ['platform.logs.view'],
|
||||||
|
managePermissions: ['platform.logs.manage'],
|
||||||
|
styles: [
|
||||||
|
{ key: 'timeline', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'timeline',
|
||||||
|
defaultHeight: 'md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'profile-security',
|
||||||
|
title: '个人信息',
|
||||||
|
description: '个人资料、密码安全与账号相关快捷操作。',
|
||||||
|
menuPath: '/profile',
|
||||||
|
viewPermissions: [],
|
||||||
|
managePermissions: [],
|
||||||
|
styles: [
|
||||||
|
{ key: 'profile', label: '默认样式', description: '统一默认样式。' },
|
||||||
|
],
|
||||||
|
defaultStyle: 'profile',
|
||||||
|
defaultHeight: 'sm',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function listDashboardDefinitions(): DashboardCardDefinition[] {
|
||||||
|
return cardDefinitions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDashboardDefinition(type: DashboardCardType): DashboardCardDefinition | undefined {
|
||||||
|
return cardDefinitions.find((item) => item.type === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCardRuntime(
|
||||||
|
definition: DashboardCardDefinition,
|
||||||
|
hasPermission: (permission: string) => boolean,
|
||||||
|
): DashboardCardRuntime {
|
||||||
|
const canView = definition.viewPermissions.length === 0
|
||||||
|
? true
|
||||||
|
: definition.viewPermissions.some((permission) => hasPermission(permission))
|
||||||
|
const canManage = definition.managePermissions.length === 0
|
||||||
|
? true
|
||||||
|
: definition.managePermissions.some((permission) => hasPermission(permission))
|
||||||
|
|
||||||
|
return {
|
||||||
|
definition,
|
||||||
|
canView,
|
||||||
|
canManage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listVisibleCardRuntimes(
|
||||||
|
hasPermission: (permission: string) => boolean,
|
||||||
|
): DashboardCardRuntime[] {
|
||||||
|
return cardDefinitions
|
||||||
|
.map((definition) => buildCardRuntime(definition, hasPermission))
|
||||||
|
.filter((runtime) => runtime.canView)
|
||||||
|
}
|
||||||
150
src/dashboard/storage.ts
Normal file
150
src/dashboard/storage.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { findDashboardDefinition, listVisibleCardRuntimes } from '@/dashboard/registry'
|
||||||
|
import type {
|
||||||
|
DashboardCardInstance,
|
||||||
|
DashboardCardRuntime,
|
||||||
|
DashboardWorkspaceState,
|
||||||
|
} from '@/dashboard/types'
|
||||||
|
|
||||||
|
const WORKSPACE_VERSION = 1
|
||||||
|
const STORAGE_PREFIX = 'dashboard.workspace'
|
||||||
|
const MIN_COLUMNS = 2
|
||||||
|
const MAX_COLUMNS = 4
|
||||||
|
const DEFAULT_COLUMNS = 3
|
||||||
|
|
||||||
|
export function getDashboardStorageKey(userId: number | string): string {
|
||||||
|
return `${STORAGE_PREFIX}.v${WORKSPACE_VERSION}.user.${String(userId)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeColumns(value: number): number {
|
||||||
|
const nextValue = Number(value)
|
||||||
|
if (!Number.isFinite(nextValue)) {
|
||||||
|
return DEFAULT_COLUMNS
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.round(nextValue)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCardInstance(
|
||||||
|
type: DashboardCardRuntime['definition']['type'],
|
||||||
|
variant?: string,
|
||||||
|
): DashboardCardInstance {
|
||||||
|
const definition = findDashboardDefinition(type)
|
||||||
|
const defaultVariant = definition?.defaultStyle || 'default'
|
||||||
|
return {
|
||||||
|
id: createCardInstanceId(type),
|
||||||
|
type,
|
||||||
|
variant: variant || defaultVariant,
|
||||||
|
settings: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultWorkspace(visibleCards: DashboardCardRuntime[]): DashboardWorkspaceState {
|
||||||
|
const cards: DashboardCardInstance[] = []
|
||||||
|
for (const runtime of visibleCards) {
|
||||||
|
if (runtime.definition.type === 'servers-quick-use') {
|
||||||
|
const supportsForm = runtime.definition.styles.some((style) => style.key === 'form')
|
||||||
|
const supportsButtons = runtime.definition.styles.some((style) => style.key === 'buttons')
|
||||||
|
if (supportsForm) {
|
||||||
|
cards.push(createCardInstance(runtime.definition.type, 'form'))
|
||||||
|
}
|
||||||
|
if (supportsButtons) {
|
||||||
|
cards.push(createCardInstance(runtime.definition.type, 'buttons'))
|
||||||
|
}
|
||||||
|
if (!supportsForm && !supportsButtons) {
|
||||||
|
cards.push(createCardInstance(runtime.definition.type))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cards.push(createCardInstance(runtime.definition.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: WORKSPACE_VERSION,
|
||||||
|
columns: DEFAULT_COLUMNS,
|
||||||
|
cards,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWorkspace(
|
||||||
|
userId: number | string,
|
||||||
|
visibleCards: DashboardCardRuntime[],
|
||||||
|
): DashboardWorkspaceState {
|
||||||
|
const storageKey = getDashboardStorageKey(userId)
|
||||||
|
const raw = localStorage.getItem(storageKey)
|
||||||
|
const visibleTypeSet = new Set(visibleCards.map((runtime) => runtime.definition.type))
|
||||||
|
const defaultWorkspace = createDefaultWorkspace(visibleCards)
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return defaultWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<DashboardWorkspaceState>
|
||||||
|
const normalizedCards = Array.isArray(parsed.cards)
|
||||||
|
? parsed.cards
|
||||||
|
.filter((item): item is DashboardCardInstance => {
|
||||||
|
return Boolean(item && item.type && visibleTypeSet.has(item.type))
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
const definition = findDashboardDefinition(item.type)
|
||||||
|
const supportsVariant = definition?.styles.some((style) => style.key === item.variant) ?? false
|
||||||
|
return {
|
||||||
|
id: item.id || createCardInstanceId(item.type),
|
||||||
|
type: item.type,
|
||||||
|
variant: supportsVariant ? item.variant : (definition?.defaultStyle || 'default'),
|
||||||
|
settings: (item.settings && typeof item.settings === 'object') ? item.settings : {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (normalizedCards.length === 0) {
|
||||||
|
return defaultWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: WORKSPACE_VERSION,
|
||||||
|
columns: normalizeColumns(Number(parsed.columns ?? DEFAULT_COLUMNS)),
|
||||||
|
cards: normalizedCards,
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return defaultWorkspace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWorkspace(userId: number | string, workspace: DashboardWorkspaceState): void {
|
||||||
|
const storageKey = getDashboardStorageKey(userId)
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify({
|
||||||
|
version: WORKSPACE_VERSION,
|
||||||
|
columns: normalizeColumns(workspace.columns),
|
||||||
|
cards: workspace.cards,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneWorkspaceByPermissions(
|
||||||
|
workspace: DashboardWorkspaceState,
|
||||||
|
visibleCards: DashboardCardRuntime[],
|
||||||
|
): DashboardWorkspaceState {
|
||||||
|
const visibleTypeSet = new Set(visibleCards.map((runtime) => runtime.definition.type))
|
||||||
|
const cards = workspace.cards.filter((card) => visibleTypeSet.has(card.type))
|
||||||
|
if (cards.length > 0) {
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
cards,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDefaultWorkspace(visibleCards)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetWorkspace(userId: number | string): void {
|
||||||
|
localStorage.removeItem(getDashboardStorageKey(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisibleCards(hasPermission: (permission: string) => boolean): DashboardCardRuntime[] {
|
||||||
|
return listVisibleCardRuntimes(hasPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCardInstanceId(type: string): string {
|
||||||
|
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||||
|
return `${type}-${Date.now()}-${randomSuffix}`
|
||||||
|
}
|
||||||
49
src/dashboard/types.ts
Normal file
49
src/dashboard/types.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export type DashboardCardType =
|
||||||
|
| 'welcome-status'
|
||||||
|
| 'users-manage'
|
||||||
|
| 'roles-manage'
|
||||||
|
| 'permissions-manage'
|
||||||
|
| 'servers-resource'
|
||||||
|
| 'servers-quick-use'
|
||||||
|
| 'servers-matrix'
|
||||||
|
| 'ops-clients'
|
||||||
|
| 'accounts-bastion'
|
||||||
|
| 'logs-access'
|
||||||
|
| 'profile-security'
|
||||||
|
|
||||||
|
export interface DashboardCardStyleVariant {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCardDefinition {
|
||||||
|
type: DashboardCardType
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
menuPath: string
|
||||||
|
viewPermissions: string[]
|
||||||
|
managePermissions: string[]
|
||||||
|
styles: DashboardCardStyleVariant[]
|
||||||
|
defaultStyle: string
|
||||||
|
defaultHeight: 'sm' | 'md' | 'lg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCardInstance {
|
||||||
|
id: string
|
||||||
|
type: DashboardCardType
|
||||||
|
variant: string
|
||||||
|
settings: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardWorkspaceState {
|
||||||
|
version: number
|
||||||
|
columns: number
|
||||||
|
cards: DashboardCardInstance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCardRuntime {
|
||||||
|
definition: DashboardCardDefinition
|
||||||
|
canView: boolean
|
||||||
|
canManage: boolean
|
||||||
|
}
|
||||||
@ -1,70 +1,624 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class='dashboard-hero'>
|
<div class='dashboard-page'>
|
||||||
<div class='hero-pattern'></div>
|
<div class='toolbar'>
|
||||||
<div class='hero-content'>
|
<div class='toolbar-left'>
|
||||||
<div class='hero-badge'>Bastion SSO Console</div>
|
<h2>首页工作台</h2>
|
||||||
<h2>欢迎回来,{{ authStore.user?.nickname || '同学' }}</h2>
|
<el-tag type='success' effect='plain'>布局:{{ layoutMode }}</el-tag>
|
||||||
<p>从左侧菜单进入用户、角色、资源和审计日志模块,继续完成你的访问控制配置。</p>
|
<el-tag type='info' effect='plain'>拖拽引擎:{{ dragEngine }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
<div class='toolbar-right btn-gap-8'>
|
||||||
|
<el-switch v-model='editMode' inline-prompt active-text='编辑模式' inactive-text='浏览模式' />
|
||||||
|
<template v-if='editMode'>
|
||||||
|
<el-select v-model='workspace.columns' style='width: 130px' @change='handleColumnsChange'>
|
||||||
|
<el-option :value='2' label='2 列' />
|
||||||
|
<el-option :value='3' label='3 列' />
|
||||||
|
<el-option :value='4' label='4 列' />
|
||||||
|
</el-select>
|
||||||
|
<el-button type='primary' @click='addDialogVisible = true'>添加卡片</el-button>
|
||||||
|
<el-button @click='restoreDefaultWorkspace'>恢复默认</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if='visibleCards.length === 0' class='empty-board'>
|
||||||
|
<el-empty description='当前没有添加卡片,点击右上角开关切换到编辑模式添加吧!' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else ref='cardsContainerRef' class='cards-masonry' :style='masonryStyle'>
|
||||||
|
<div
|
||||||
|
v-for='column in cardsByColumn'
|
||||||
|
:key='`column-${column.index}`'
|
||||||
|
class='masonry-column'
|
||||||
|
:data-column-index='column.index'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for='item in column.cards'
|
||||||
|
:key='item.instance.id'
|
||||||
|
class='dashboard-card-item'
|
||||||
|
:data-card-id='item.instance.id'
|
||||||
|
:data-column-index='column.index'
|
||||||
|
>
|
||||||
|
<SystemCard
|
||||||
|
:instance='item.instance'
|
||||||
|
:runtime='item.runtime'
|
||||||
|
:editable='editMode'
|
||||||
|
@remove='removeCard'
|
||||||
|
@variant-change='updateCardVariant'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if='dragState.dragPhase === "dragging" && dragState.dropIndicatorRect'
|
||||||
|
class='drop-indicator'
|
||||||
|
:style='{
|
||||||
|
left: `${dragState.dropIndicatorRect.left}px`,
|
||||||
|
top: `${dragState.dropIndicatorRect.top}px`,
|
||||||
|
width: `${dragState.dropIndicatorRect.width}px`,
|
||||||
|
}'
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddCardDialog
|
||||||
|
v-model='addDialogVisible'
|
||||||
|
:definitions='visibleDefinitions'
|
||||||
|
@add='addCard'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang='ts'>
|
<script setup lang='ts'>
|
||||||
|
import { useWindowSize } from '@vueuse/core'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||||
|
import AddCardDialog from '@/components/dashboard/AddCardDialog.vue'
|
||||||
|
import SystemCard from '@/components/dashboard/SystemCard.vue'
|
||||||
|
import { setupDashboardDrag, type DashboardDragController } from '@/dashboard/drag-engine'
|
||||||
|
import { findDashboardDefinition } from '@/dashboard/registry'
|
||||||
|
import {
|
||||||
|
createCardInstance,
|
||||||
|
createDefaultWorkspace,
|
||||||
|
loadWorkspace,
|
||||||
|
getDashboardStorageKey,
|
||||||
|
normalizeColumns,
|
||||||
|
pruneWorkspaceByPermissions,
|
||||||
|
resetWorkspace,
|
||||||
|
resolveVisibleCards,
|
||||||
|
saveWorkspace,
|
||||||
|
} from '@/dashboard/storage'
|
||||||
|
import type {
|
||||||
|
DashboardCardInstance,
|
||||||
|
DashboardCardRuntime,
|
||||||
|
DashboardCardType,
|
||||||
|
DashboardWorkspaceState,
|
||||||
|
} from '@/dashboard/types'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
|
||||||
|
const editMode = ref(false)
|
||||||
|
const addDialogVisible = ref(false)
|
||||||
|
const dragEngine = ref<'carddragger' | 'masonry-pointer'>('masonry-pointer')
|
||||||
|
const layoutMode = ref<'masonry'>('masonry')
|
||||||
|
const cardsContainerRef = ref<HTMLElement | null>(null)
|
||||||
|
const dragController = ref<DashboardDragController | null>(null)
|
||||||
|
|
||||||
|
const dragState = reactive({
|
||||||
|
dragPhase: 'idle' as 'idle' | 'dragging',
|
||||||
|
fromIndex: -1,
|
||||||
|
toCompactIndex: -1,
|
||||||
|
targetColumnIndex: -1,
|
||||||
|
measuredRects: [] as DOMRect[],
|
||||||
|
previousRects: new Map<string, DOMRect>(),
|
||||||
|
pendingDrop: null as { columnIndex: number; toCompactIndex: number } | null,
|
||||||
|
dropIndicatorRect: null as { left: number; top: number; width: number } | null,
|
||||||
|
insertDirection: null as 'up' | 'down' | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const workspace = reactive<DashboardWorkspaceState>({
|
||||||
|
version: 1,
|
||||||
|
columns: 3,
|
||||||
|
cards: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const userId = computed(() => authStore.user?.id || 0)
|
||||||
|
const visibleDefinitions = computed(() => resolveVisibleCards(authStore.hasPermission))
|
||||||
|
const runtimeByType = computed(() => {
|
||||||
|
const map = new Map<DashboardCardType, DashboardCardRuntime>()
|
||||||
|
for (const runtime of visibleDefinitions.value) {
|
||||||
|
map.set(runtime.definition.type, runtime)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
const visibleCards = computed<Array<{ instance: DashboardCardInstance; runtime: DashboardCardRuntime }>>(() => {
|
||||||
|
const nextCards: Array<{ instance: DashboardCardInstance; runtime: DashboardCardRuntime }> = []
|
||||||
|
for (const instance of workspace.cards) {
|
||||||
|
const runtime = runtimeByType.value.get(instance.type)
|
||||||
|
if (!runtime) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const supportsVariant = runtime.definition.styles.some((style) => style.key === instance.variant)
|
||||||
|
if (!supportsVariant) {
|
||||||
|
const defaultDefinition = findDashboardDefinition(instance.type)
|
||||||
|
instance.variant = defaultDefinition?.defaultStyle || runtime.definition.defaultStyle
|
||||||
|
}
|
||||||
|
nextCards.push({ instance, runtime })
|
||||||
|
}
|
||||||
|
return nextCards
|
||||||
|
})
|
||||||
|
|
||||||
|
const masonryStyle = computed(() => ({
|
||||||
|
'--masonry-columns': String(effectiveColumns.value),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const cardsByColumn = computed<Array<{ index: number; cards: Array<{ instance: DashboardCardInstance; runtime: DashboardCardRuntime }> }>>(() => {
|
||||||
|
const columns = Array.from({ length: effectiveColumns.value }, (_unused, index) => ({
|
||||||
|
index,
|
||||||
|
cards: [] as Array<{ instance: DashboardCardInstance; runtime: DashboardCardRuntime }>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
for (const item of visibleCards.value) {
|
||||||
|
const column = resolveCardColumn(item.instance)
|
||||||
|
columns[column].cards.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns
|
||||||
|
})
|
||||||
|
const effectiveColumns = computed(() => {
|
||||||
|
if (width.value <= 768) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return normalizeColumns(workspace.columns)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [userId.value, authStore.permissions.join('|')],
|
||||||
|
async () => {
|
||||||
|
await initializeWorkspace()
|
||||||
|
await nextTick()
|
||||||
|
await setupDrag()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => workspace.cards.length,
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
await setupDrag()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => workspace.cards.map((card) => `${card.id}:${String((card.settings as any)?.__dashboardColumn ?? 0)}`).join('|'),
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
await setupDrag()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(editMode, async () => {
|
||||||
|
await nextTick()
|
||||||
|
await setupDrag()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(effectiveColumns, async () => {
|
||||||
|
await nextTick()
|
||||||
|
await setupDrag()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
destroyDrag()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function initializeWorkspace(): Promise<void> {
|
||||||
|
if (!userId.value) {
|
||||||
|
workspace.cards = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = getDashboardStorageKey(userId.value)
|
||||||
|
const hasWorkspace = Boolean(localStorage.getItem(storageKey))
|
||||||
|
if (!hasWorkspace) {
|
||||||
|
const useDefault = await promptFirstWorkspaceChoice()
|
||||||
|
const firstWorkspace = useDefault
|
||||||
|
? createDefaultWorkspace(visibleDefinitions.value)
|
||||||
|
: {
|
||||||
|
version: 1,
|
||||||
|
columns: 3,
|
||||||
|
cards: [] as DashboardCardInstance[],
|
||||||
|
}
|
||||||
|
workspace.version = firstWorkspace.version
|
||||||
|
workspace.columns = normalizeColumns(firstWorkspace.columns)
|
||||||
|
workspace.cards = firstWorkspace.cards
|
||||||
|
spreadCardColumnsForCardsWithoutColumn()
|
||||||
|
compactCardColumnsToCurrent()
|
||||||
|
persistWorkspace()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = loadWorkspace(userId.value, visibleDefinitions.value)
|
||||||
|
const pruned = pruneWorkspaceByPermissions(loaded, visibleDefinitions.value)
|
||||||
|
workspace.version = pruned.version
|
||||||
|
workspace.columns = normalizeColumns(pruned.columns)
|
||||||
|
workspace.cards = pruned.cards
|
||||||
|
spreadCardColumnsForCardsWithoutColumn()
|
||||||
|
compactCardColumnsToCurrent()
|
||||||
|
persistWorkspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptFirstWorkspaceChoice(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'首次进入工作台,选择初始化方式:使用默认卡片,或保持为空手动添加。',
|
||||||
|
'初始化工作台',
|
||||||
|
{
|
||||||
|
confirmButtonText: '使用默认',
|
||||||
|
cancelButtonText: '保持为空',
|
||||||
|
distinguishCancelAndClose: true,
|
||||||
|
closeOnClickModal: false,
|
||||||
|
type: 'info',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistWorkspace(): void {
|
||||||
|
if (!userId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveWorkspace(userId.value, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnsChange(): void {
|
||||||
|
workspace.columns = normalizeColumns(workspace.columns)
|
||||||
|
compactCardColumnsToCurrent()
|
||||||
|
persistWorkspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCard(type: DashboardCardType, variant: string): void {
|
||||||
|
const runtime = runtimeByType.value.get(type)
|
||||||
|
if (!runtime || !runtime.canView) {
|
||||||
|
ElMessage.warning('无权限添加该卡片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const instance = createCardInstance(type, variant)
|
||||||
|
setCardColumn(instance, 0)
|
||||||
|
workspace.cards.push(instance)
|
||||||
|
persistWorkspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCard(id: string): void {
|
||||||
|
workspace.cards = workspace.cards.filter((card) => card.id !== id)
|
||||||
|
persistWorkspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardVariant(id: string, variant: string): void {
|
||||||
|
const target = workspace.cards.find((card) => card.id === id)
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.variant = variant
|
||||||
|
persistWorkspace()
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDefaultWorkspace(): void {
|
||||||
|
if (!userId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetWorkspace(userId.value)
|
||||||
|
const defaultWorkspace = createDefaultWorkspace(visibleDefinitions.value)
|
||||||
|
workspace.version = defaultWorkspace.version
|
||||||
|
workspace.columns = defaultWorkspace.columns
|
||||||
|
workspace.cards = defaultWorkspace.cards.map((card, index) => {
|
||||||
|
setCardColumn(card, index % Math.max(1, normalizeColumns(workspace.columns)))
|
||||||
|
return card
|
||||||
|
})
|
||||||
|
persistWorkspace()
|
||||||
|
ElMessage.success('已恢复默认卡片布局')
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactCardColumnsToCurrent(): void {
|
||||||
|
const maxColumn = Math.max(0, normalizeColumns(workspace.columns) - 1)
|
||||||
|
workspace.cards = workspace.cards.map((card) => {
|
||||||
|
const current = Number((card.settings as any)?.__dashboardColumn ?? 0)
|
||||||
|
const normalized = Number.isFinite(current) ? Math.max(0, Math.min(maxColumn, Math.round(current))) : 0
|
||||||
|
setCardColumn(card, normalized)
|
||||||
|
return card
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function spreadCardColumnsForCardsWithoutColumn(): void {
|
||||||
|
const columns = Math.max(1, normalizeColumns(workspace.columns))
|
||||||
|
let missingIndex = 0
|
||||||
|
workspace.cards = workspace.cards.map((card) => {
|
||||||
|
const hasColumn = Number.isFinite(Number((card.settings as any)?.__dashboardColumn))
|
||||||
|
if (hasColumn) {
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
setCardColumn(card, missingIndex % columns)
|
||||||
|
missingIndex += 1
|
||||||
|
return card
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupDrag(): Promise<void> {
|
||||||
|
destroyDrag()
|
||||||
|
if (!editMode.value || !cardsContainerRef.value || visibleCards.value.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dragController.value = await setupDashboardDrag({
|
||||||
|
container: cardsContainerRef.value,
|
||||||
|
handleSelector: '.drag-handle',
|
||||||
|
draggableSelector: '.dashboard-card-item',
|
||||||
|
onEngineResolved: (engine) => {
|
||||||
|
dragEngine.value = engine
|
||||||
|
},
|
||||||
|
onPreview: (payload) => {
|
||||||
|
dragState.pendingDrop = {
|
||||||
|
columnIndex: payload.columnIndex,
|
||||||
|
toCompactIndex: payload.toCompactIndex,
|
||||||
|
}
|
||||||
|
dragState.toCompactIndex = payload.toCompactIndex
|
||||||
|
dragState.targetColumnIndex = payload.columnIndex
|
||||||
|
dragState.insertDirection = payload.insertDirection
|
||||||
|
dragState.dropIndicatorRect = payload.indicatorRect
|
||||||
|
},
|
||||||
|
onSorted: (payload) => {
|
||||||
|
applyPendingDrop(payload.sourceCardId, payload.columnIndex, payload.beforeCardId, payload.afterCardId)
|
||||||
|
},
|
||||||
|
onDragStateChange: (state) => {
|
||||||
|
dragState.dragPhase = state.dragging ? 'dragging' : 'idle'
|
||||||
|
dragState.fromIndex = state.fromIndex
|
||||||
|
dragState.toCompactIndex = state.toCompactIndex
|
||||||
|
dragState.measuredRects = state.measuredRects
|
||||||
|
dragState.targetColumnIndex = state.columnIndex
|
||||||
|
dragState.insertDirection = state.insertDirection
|
||||||
|
if (!state.dragging) {
|
||||||
|
dragState.pendingDrop = null
|
||||||
|
dragState.dropIndicatorRect = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyDrag(): void {
|
||||||
|
dragController.value?.destroy()
|
||||||
|
dragController.value = null
|
||||||
|
dragState.dragPhase = 'idle'
|
||||||
|
dragState.fromIndex = -1
|
||||||
|
dragState.toCompactIndex = -1
|
||||||
|
dragState.targetColumnIndex = -1
|
||||||
|
dragState.measuredRects = []
|
||||||
|
dragState.previousRects = new Map()
|
||||||
|
dragState.pendingDrop = null
|
||||||
|
dragState.dropIndicatorRect = null
|
||||||
|
dragState.insertDirection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCardRects(): Map<string, DOMRect> {
|
||||||
|
const map = new Map<string, DOMRect>()
|
||||||
|
if (!cardsContainerRef.value) {
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
const items = cardsContainerRef.value.querySelectorAll<HTMLElement>('.dashboard-card-item[data-card-id]')
|
||||||
|
items.forEach((item) => {
|
||||||
|
const id = item.dataset.cardId
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
map.set(id, item.getBoundingClientRect())
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function playFlipAnimation(previousRects: Map<string, DOMRect>): void {
|
||||||
|
if (!cardsContainerRef.value || previousRects.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = cardsContainerRef.value.querySelectorAll<HTMLElement>('.dashboard-card-item[data-card-id]')
|
||||||
|
items.forEach((item) => {
|
||||||
|
const id = item.dataset.cardId
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previous = previousRects.get(id)
|
||||||
|
if (!previous) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = item.getBoundingClientRect()
|
||||||
|
const dx = previous.left - current.left
|
||||||
|
const dy = previous.top - current.top
|
||||||
|
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.classList.add('dashboard-shift')
|
||||||
|
item.style.transform = `translate(${dx}px, ${dy}px)`
|
||||||
|
item.style.transition = 'transform 0s'
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
item.style.transition = 'transform 220ms cubic-bezier(.2,.78,.25,1)'
|
||||||
|
item.style.transform = ''
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
item.classList.remove('dashboard-shift')
|
||||||
|
item.style.transition = ''
|
||||||
|
}, 260)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPendingDrop(
|
||||||
|
sourceCardId: string,
|
||||||
|
columnIndex: number,
|
||||||
|
beforeCardId: string | null,
|
||||||
|
afterCardId: string | null,
|
||||||
|
): void {
|
||||||
|
if (!sourceCardId || workspace.cards.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previousRects = collectCardRects()
|
||||||
|
const cards = [...workspace.cards]
|
||||||
|
const sourceIndex = cards.findIndex((card) => card.id === sourceCardId)
|
||||||
|
if (sourceIndex < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const moved = cards.splice(sourceIndex, 1)[0]
|
||||||
|
if (!moved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalizedColumn = Math.max(0, Math.min(effectiveColumns.value - 1, columnIndex))
|
||||||
|
setCardColumn(moved, normalizedColumn)
|
||||||
|
|
||||||
|
let targetIndex = cards.length
|
||||||
|
if (beforeCardId) {
|
||||||
|
const beforeIndex = cards.findIndex((card) => card.id === beforeCardId)
|
||||||
|
if (beforeIndex >= 0) {
|
||||||
|
targetIndex = beforeIndex
|
||||||
|
}
|
||||||
|
} else if (afterCardId) {
|
||||||
|
const afterIndex = cards.findIndex((card) => card.id === afterCardId)
|
||||||
|
if (afterIndex >= 0) {
|
||||||
|
targetIndex = afterIndex + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sameColumnIndexes = cards
|
||||||
|
.map((card, index) => ({ index, column: resolveCardColumn(card) }))
|
||||||
|
.filter((entry) => entry.column === normalizedColumn)
|
||||||
|
.map((entry) => entry.index)
|
||||||
|
if (sameColumnIndexes.length > 0) {
|
||||||
|
targetIndex = Math.max(...sameColumnIndexes) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.splice(Math.max(0, Math.min(targetIndex, cards.length)), 0, moved)
|
||||||
|
workspace.cards = cards
|
||||||
|
persistWorkspace()
|
||||||
|
void nextTick(() => {
|
||||||
|
playFlipAnimation(previousRects)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCardColumn(instance: DashboardCardInstance): number {
|
||||||
|
const rawColumn = (instance.settings?.__dashboardColumn ?? 0) as number
|
||||||
|
const maxColumn = Math.max(0, effectiveColumns.value - 1)
|
||||||
|
if (!Number.isFinite(rawColumn)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(maxColumn, Number(rawColumn)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCardColumn(instance: DashboardCardInstance, column: number): void {
|
||||||
|
instance.settings = {
|
||||||
|
...instance.settings,
|
||||||
|
__dashboardColumn: column,
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-hero {
|
.dashboard-page {
|
||||||
min-height: calc(100vh - 180px);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, #f8fbff 0%, #f6f8ff 100%);
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-board {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
min-height: 300px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
|
||||||
|
.cards-masonry {
|
||||||
|
--masonry-columns: 3;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--masonry-columns), minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
position: relative;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card-item {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
transition: transform 0.16s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card-item.dashboard-shift {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
height: 3px;
|
||||||
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;
|
border-radius: 999px;
|
||||||
background: rgba(15, 118, 110, 0.1);
|
background: #3b82f6;
|
||||||
color: #0f766e;
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2), 0 0 16px rgba(59, 130, 246, 0.35);
|
||||||
font-size: 12px;
|
z-index: 20;
|
||||||
font-weight: 700;
|
pointer-events: none;
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
.hero-content h2 {
|
|
||||||
margin: 0 0 12px;
|
@media (max-width: 768px) {
|
||||||
font-size: 40px;
|
.toolbar {
|
||||||
font-weight: 800;
|
flex-direction: column;
|
||||||
color: #0f172a;
|
align-items: stretch;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
.hero-content p {
|
|
||||||
margin: 0 auto;
|
.toolbar-right {
|
||||||
max-width: 640px;
|
justify-content: flex-start;
|
||||||
color: #475569;
|
}
|
||||||
font-size: 16px;
|
}
|
||||||
line-height: 1.8;
|
|
||||||
|
:global(.dashboard-is-dragging) {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dashboard-drag-ghost) {
|
||||||
|
opacity: 0.88;
|
||||||
|
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.24);
|
||||||
|
border-radius: 16px;
|
||||||
|
transform: rotate(1deg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -127,9 +127,7 @@
|
|||||||
<div>{{ useTargetRow?.display_name || useTargetRow?.name || '-' }}</div>
|
<div>{{ useTargetRow?.display_name || useTargetRow?.name || '-' }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label='协议'>
|
<el-form-item label='协议'>
|
||||||
<el-select v-model='useForm.protocol' class='w-full'>
|
<div>{{ useTargetRow?.protocols?.[0] || 'SSH' }}</div>
|
||||||
<el-option v-for='item in useProtocolOptions' :key='item' :label='item' :value='item' />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label='访问用户名'>
|
<el-form-item label='访问用户名'>
|
||||||
<el-input v-model='useForm.account_name' placeholder='请输入访问用户名' clearable />
|
<el-input v-model='useForm.account_name' placeholder='请输入访问用户名' clearable />
|
||||||
@ -198,7 +196,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div class='mt-3 btn-gap-8 btn-gap-8--end'>
|
<div class='mt-3 btn-gap-8 btn-gap-8--end'>
|
||||||
<el-button type='primary' :loading='opsSaving' @click='saveOpsPreferences'>保存我的软件选择</el-button>
|
<el-button type='primary' :loading='opsSaving' @click='saveOpsPreferences'>{{ opsSaveButtonLabel }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model='protocolDialogVisible' :title='editingProtocolId ? "编辑协议" : "新增协议"' width='520px'>
|
<el-dialog v-model='protocolDialogVisible' :title='editingProtocolId ? "编辑协议" : "新增协议"' width='520px'>
|
||||||
@ -257,11 +255,13 @@
|
|||||||
|
|
||||||
<script setup lang='ts'>
|
<script setup lang='ts'>
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { clearServerCredential, getServerCredential, setServerCredential } from '@/composables/server-credential'
|
||||||
import { serversApi } from '@/api/servers'
|
import { serversApi } from '@/api/servers'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const currentUserId = computed(() => Number(authStore.user?.id || 0))
|
||||||
const { hasPermission } = authStore
|
const { hasPermission } = authStore
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@ -275,6 +275,8 @@ const usingResource = ref(false)
|
|||||||
const copyingTempPassword = ref(false)
|
const copyingTempPassword = ref(false)
|
||||||
const opsLoading = ref(false)
|
const opsLoading = ref(false)
|
||||||
const opsSaving = ref(false)
|
const opsSaving = ref(false)
|
||||||
|
const forceSaveOpsReady = ref(false)
|
||||||
|
const forceSaveOpsTimer = ref<number | null>(null)
|
||||||
const permissionMode = ref<'server_assign' | 'resource_edit'>('server_assign')
|
const permissionMode = ref<'server_assign' | 'resource_edit'>('server_assign')
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const editingProtocolId = ref<number | null>(null)
|
const editingProtocolId = ref<number | null>(null)
|
||||||
@ -297,14 +299,7 @@ const protocolForm = reactive<any>({ name: '', bastion_protocol_id: 2, descripti
|
|||||||
const softwareForm = reactive<any>({ name: '', client_path: '', 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 opsSelections = reactive<Record<string, number | null>>({})
|
||||||
const permissionDialogTitle = computed(() => (permissionMode.value === 'server_assign' ? '服务器资源用户权限分配' : '资源已有用户权限修改'))
|
const permissionDialogTitle = computed(() => (permissionMode.value === 'server_assign' ? '服务器资源用户权限分配' : '资源已有用户权限修改'))
|
||||||
const useProtocolOptions = computed<string[]>(() => {
|
const opsSaveButtonLabel = computed(() => (forceSaveOpsReady.value ? '强制保存' : '保存我的软件选择'))
|
||||||
const values = useTargetRow.value?.protocols
|
|
||||||
if (Array.isArray(values) && values.length > 0) {
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['SSH']
|
|
||||||
})
|
|
||||||
const resourceProtocolOptions = computed<string[]>(() => {
|
const resourceProtocolOptions = computed<string[]>(() => {
|
||||||
return opsProtocols.value
|
return opsProtocols.value
|
||||||
.filter((item: any) => item.is_active)
|
.filter((item: any) => item.is_active)
|
||||||
@ -543,8 +538,9 @@ function sortByProtocol(a: any, b: any): number {
|
|||||||
|
|
||||||
function openUseResourceDialog(row: any): void {
|
function openUseResourceDialog(row: any): void {
|
||||||
useTargetRow.value = row
|
useTargetRow.value = row
|
||||||
const saved = readSavedCredentials(row.id)
|
const serverId = Number(row.parent_id || row.id || 0)
|
||||||
useForm.protocol = saved?.protocol || row.protocols?.[0] || 'SSH'
|
const saved = getServerCredential(currentUserId.value, serverId)
|
||||||
|
useForm.protocol = row.protocols?.[0] || 'SSH'
|
||||||
useForm.account_name = saved?.account_name || ''
|
useForm.account_name = saved?.account_name || ''
|
||||||
useForm.password = saved?.password || ''
|
useForm.password = saved?.password || ''
|
||||||
useForm.remember = Boolean(saved)
|
useForm.remember = Boolean(saved)
|
||||||
@ -561,7 +557,7 @@ async function submitUseResource(): Promise<void> {
|
|||||||
useForm.last_temp_password = result.tempPassword
|
useForm.last_temp_password = result.tempPassword
|
||||||
ElMessage.success(useForm.last_temp_password ? '已连接,可复制临时密码' : '已连接成功')
|
ElMessage.success(useForm.last_temp_password ? '已连接,可复制临时密码' : '已连接成功')
|
||||||
persistCredentials(useTargetRow.value.id, {
|
persistCredentials(useTargetRow.value.id, {
|
||||||
protocol: useForm.protocol,
|
protocol: useTargetRow.value?.protocols?.[0] || 'SSH',
|
||||||
account_name: useForm.account_name || '',
|
account_name: useForm.account_name || '',
|
||||||
password: useForm.password || '',
|
password: useForm.password || '',
|
||||||
remember: Boolean(useForm.remember),
|
remember: Boolean(useForm.remember),
|
||||||
@ -590,7 +586,7 @@ async function requestUseResource(action: 'connect' | 'copy'): Promise<{ url: st
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response: any = await serversApi.useResource(useTargetRow.value.id, {
|
const response: any = await serversApi.useResource(useTargetRow.value.id, {
|
||||||
protocol: useForm.protocol,
|
protocol: useTargetRow.value?.protocols?.[0] || 'SSH',
|
||||||
account_name: useForm.account_name || '',
|
account_name: useForm.account_name || '',
|
||||||
password: useForm.password || '',
|
password: useForm.password || '',
|
||||||
})
|
})
|
||||||
@ -633,45 +629,18 @@ async function copyTempPassword(): Promise<void> {
|
|||||||
ElMessage.success('临时密码已复制')
|
ElMessage.success('临时密码已复制')
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
function persistCredentials(resourceId: number, payload: { protocol: string; account_name: string; password: string; remember: boolean }): void {
|
||||||
const key = credentialStorageKey(resourceId)
|
const target = rows.value.find((item) => Number(item.id) === Number(resourceId))
|
||||||
const existing = localStorage.getItem(key)
|
const serverId = Number(target?.parent_id || resourceId || 0)
|
||||||
if (!payload.remember) {
|
if (!payload.remember) {
|
||||||
if (existing) {
|
clearServerCredential(currentUserId.value, serverId)
|
||||||
localStorage.removeItem(key)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(key, JSON.stringify({
|
setServerCredential(currentUserId.value, serverId, {
|
||||||
protocol: payload.protocol,
|
|
||||||
account_name: payload.account_name,
|
account_name: payload.account_name,
|
||||||
password: payload.password,
|
password: payload.password,
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOpsMeta(): Promise<void> {
|
async function fetchOpsMeta(): Promise<void> {
|
||||||
@ -821,26 +790,49 @@ async function saveOpsPreferences(): Promise<void> {
|
|||||||
.map((protocol) => String(protocol.id))
|
.map((protocol) => String(protocol.id))
|
||||||
.filter((key) => (opsSavedSelections[key] ?? null) !== (opsSelections[key] ?? null))
|
.filter((key) => (opsSavedSelections[key] ?? null) !== (opsSelections[key] ?? null))
|
||||||
|
|
||||||
if (!changedProtocolIds.length) {
|
if (!changedProtocolIds.length && !forceSaveOpsReady.value) {
|
||||||
ElMessage.info('没有修改,无需同步客户端')
|
openForceSaveOpsWindow()
|
||||||
|
ElMessage.info('没有修改,3秒内再次点击可强制保存并同步')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetProtocolIds = changedProtocolIds.length
|
||||||
|
? changedProtocolIds
|
||||||
|
: opsProtocols.value.map((protocol) => String(protocol.id))
|
||||||
|
|
||||||
const items = opsProtocols.value.map((protocol) => ({
|
const items = opsProtocols.value.map((protocol) => ({
|
||||||
protocol_id: protocol.id,
|
protocol_id: protocol.id,
|
||||||
software_id: opsSelections[String(protocol.id)] || null,
|
software_id: opsSelections[String(protocol.id)] || null,
|
||||||
}))
|
}))
|
||||||
await serversApi.saveOpsPreferences(items)
|
await serversApi.saveOpsPreferences(items)
|
||||||
await syncOpsLinksAfterSave(changedProtocolIds)
|
await syncOpsLinksAfterSave(targetProtocolIds)
|
||||||
for (const key of changedProtocolIds) {
|
for (const key of targetProtocolIds) {
|
||||||
opsSavedSelections[key] = opsSelections[key] ?? null
|
opsSavedSelections[key] = opsSelections[key] ?? null
|
||||||
}
|
}
|
||||||
|
clearForceSaveOpsWindow()
|
||||||
ElMessage.success('已保存并同步到客户端')
|
ElMessage.success('已保存并同步到客户端')
|
||||||
} finally {
|
} finally {
|
||||||
opsSaving.value = false
|
opsSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openForceSaveOpsWindow(): void {
|
||||||
|
clearForceSaveOpsWindow()
|
||||||
|
forceSaveOpsReady.value = true
|
||||||
|
forceSaveOpsTimer.value = window.setTimeout(() => {
|
||||||
|
forceSaveOpsReady.value = false
|
||||||
|
forceSaveOpsTimer.value = null
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForceSaveOpsWindow(): void {
|
||||||
|
if (forceSaveOpsTimer.value) {
|
||||||
|
window.clearTimeout(forceSaveOpsTimer.value)
|
||||||
|
forceSaveOpsTimer.value = null
|
||||||
|
}
|
||||||
|
forceSaveOpsReady.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function syncOpsLinksAfterSave(changedProtocolIds: string[]): Promise<void> {
|
async function syncOpsLinksAfterSave(changedProtocolIds: string[]): Promise<void> {
|
||||||
const selectedItems = opsProtocols.value
|
const selectedItems = opsProtocols.value
|
||||||
.map((protocol) => ({
|
.map((protocol) => ({
|
||||||
@ -879,6 +871,10 @@ async function removeRow(row: any): Promise<void> {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchList(), fetchOpsMeta()])
|
await Promise.all([fetchList(), fetchOpsMeta()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearForceSaveOpsWindow()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
2
src/types/dashboard-modules.d.ts
vendored
Normal file
2
src/types/dashboard-modules.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
declare module 'sortablejs'
|
||||||
|
declare module 'carddragger'
|
||||||
@ -33,7 +33,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8001', // 后端地址
|
target: 'https://sso.scirc.top/api', // 后端地址
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: path => path.replace(/^\/api/, '') // 去掉 /api 前缀
|
rewrite: path => path.replace(/^\/api/, '') // 去掉 /api 前缀
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user