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 */
|
||||
declare module 'vue' {
|
||||
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']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
@ -36,6 +38,8 @@ declare module 'vue' {
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
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']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
@ -46,6 +50,7 @@ declare module 'vue' {
|
||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SystemCard: typeof import('./src/components/dashboard/SystemCard.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
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/integrations": "^13.5.0",
|
||||
"axios": "^1.10.0",
|
||||
"carddragger": "^0.3.6",
|
||||
"crypto-js": "^4.2.0",
|
||||
"element-plus": "^2.10.3",
|
||||
"pinia": "^3.0.3",
|
||||
"sortablejs": "^1.15.7",
|
||||
"universal-cookie": "^8.0.1",
|
||||
"vue": "^3.5.32",
|
||||
"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>
|
||||
<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 class='dashboard-page'>
|
||||
<div class='toolbar'>
|
||||
<div class='toolbar-left'>
|
||||
<h2>首页工作台</h2>
|
||||
<el-tag type='success' effect='plain'>布局:{{ layoutMode }}</el-tag>
|
||||
<el-tag type='info' effect='plain'>拖拽引擎:{{ dragEngine }}</el-tag>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-hero {
|
||||
min-height: calc(100vh - 180px);
|
||||
.dashboard-page {
|
||||
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;
|
||||
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 {
|
||||
|
||||
.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;
|
||||
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;
|
||||
height: 3px;
|
||||
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;
|
||||
background: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2), 0 0 16px rgba(59, 130, 246, 0.35);
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-content h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1.2;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
.hero-content p {
|
||||
margin: 0 auto;
|
||||
max-width: 640px;
|
||||
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>
|
||||
|
||||
@ -127,9 +127,7 @@
|
||||
<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>
|
||||
<div>{{ useTargetRow?.protocols?.[0] || 'SSH' }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label='访问用户名'>
|
||||
<el-input v-model='useForm.account_name' placeholder='请输入访问用户名' clearable />
|
||||
@ -198,7 +196,7 @@
|
||||
</el-table>
|
||||
|
||||
<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>
|
||||
|
||||
<el-dialog v-model='protocolDialogVisible' :title='editingProtocolId ? "编辑协议" : "新增协议"' width='520px'>
|
||||
@ -257,11 +255,13 @@
|
||||
|
||||
<script setup lang='ts'>
|
||||
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 { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const currentUserId = computed(() => Number(authStore.user?.id || 0))
|
||||
const { hasPermission } = authStore
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
@ -275,6 +275,8 @@ const usingResource = ref(false)
|
||||
const copyingTempPassword = ref(false)
|
||||
const opsLoading = 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 editingId = 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 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 opsSaveButtonLabel = computed(() => (forceSaveOpsReady.value ? '强制保存' : '保存我的软件选择'))
|
||||
const resourceProtocolOptions = computed<string[]>(() => {
|
||||
return opsProtocols.value
|
||||
.filter((item: any) => item.is_active)
|
||||
@ -543,8 +538,9 @@ function sortByProtocol(a: any, b: any): number {
|
||||
|
||||
function openUseResourceDialog(row: any): void {
|
||||
useTargetRow.value = row
|
||||
const saved = readSavedCredentials(row.id)
|
||||
useForm.protocol = saved?.protocol || row.protocols?.[0] || 'SSH'
|
||||
const serverId = Number(row.parent_id || row.id || 0)
|
||||
const saved = getServerCredential(currentUserId.value, serverId)
|
||||
useForm.protocol = row.protocols?.[0] || 'SSH'
|
||||
useForm.account_name = saved?.account_name || ''
|
||||
useForm.password = saved?.password || ''
|
||||
useForm.remember = Boolean(saved)
|
||||
@ -561,7 +557,7 @@ async function submitUseResource(): Promise<void> {
|
||||
useForm.last_temp_password = result.tempPassword
|
||||
ElMessage.success(useForm.last_temp_password ? '已连接,可复制临时密码' : '已连接成功')
|
||||
persistCredentials(useTargetRow.value.id, {
|
||||
protocol: useForm.protocol,
|
||||
protocol: useTargetRow.value?.protocols?.[0] || 'SSH',
|
||||
account_name: useForm.account_name || '',
|
||||
password: useForm.password || '',
|
||||
remember: Boolean(useForm.remember),
|
||||
@ -590,7 +586,7 @@ async function requestUseResource(action: 'connect' | 'copy'): Promise<{ url: st
|
||||
}
|
||||
try {
|
||||
const response: any = await serversApi.useResource(useTargetRow.value.id, {
|
||||
protocol: useForm.protocol,
|
||||
protocol: useTargetRow.value?.protocols?.[0] || 'SSH',
|
||||
account_name: useForm.account_name || '',
|
||||
password: useForm.password || '',
|
||||
})
|
||||
@ -633,45 +629,18 @@ async function copyTempPassword(): Promise<void> {
|
||||
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 {
|
||||
const key = credentialStorageKey(resourceId)
|
||||
const existing = localStorage.getItem(key)
|
||||
const target = rows.value.find((item) => Number(item.id) === Number(resourceId))
|
||||
const serverId = Number(target?.parent_id || resourceId || 0)
|
||||
if (!payload.remember) {
|
||||
if (existing) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
clearServerCredential(currentUserId.value, serverId)
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
protocol: payload.protocol,
|
||||
setServerCredential(currentUserId.value, serverId, {
|
||||
account_name: payload.account_name,
|
||||
password: payload.password,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchOpsMeta(): Promise<void> {
|
||||
@ -821,26 +790,49 @@ async function saveOpsPreferences(): Promise<void> {
|
||||
.map((protocol) => String(protocol.id))
|
||||
.filter((key) => (opsSavedSelections[key] ?? null) !== (opsSelections[key] ?? null))
|
||||
|
||||
if (!changedProtocolIds.length) {
|
||||
ElMessage.info('没有修改,无需同步客户端')
|
||||
if (!changedProtocolIds.length && !forceSaveOpsReady.value) {
|
||||
openForceSaveOpsWindow()
|
||||
ElMessage.info('没有修改,3秒内再次点击可强制保存并同步')
|
||||
return
|
||||
}
|
||||
|
||||
const targetProtocolIds = changedProtocolIds.length
|
||||
? changedProtocolIds
|
||||
: opsProtocols.value.map((protocol) => String(protocol.id))
|
||||
|
||||
const items = opsProtocols.value.map((protocol) => ({
|
||||
protocol_id: protocol.id,
|
||||
software_id: opsSelections[String(protocol.id)] || null,
|
||||
}))
|
||||
await serversApi.saveOpsPreferences(items)
|
||||
await syncOpsLinksAfterSave(changedProtocolIds)
|
||||
for (const key of changedProtocolIds) {
|
||||
await syncOpsLinksAfterSave(targetProtocolIds)
|
||||
for (const key of targetProtocolIds) {
|
||||
opsSavedSelections[key] = opsSelections[key] ?? null
|
||||
}
|
||||
clearForceSaveOpsWindow()
|
||||
ElMessage.success('已保存并同步到客户端')
|
||||
} finally {
|
||||
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> {
|
||||
const selectedItems = opsProtocols.value
|
||||
.map((protocol) => ({
|
||||
@ -879,6 +871,10 @@ async function removeRow(row: any): Promise<void> {
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchList(), fetchOpsMeta()])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearForceSaveOpsWindow()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8001', // 后端地址
|
||||
target: 'https://sso.scirc.top/api', // 后端地址
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, '') // 去掉 /api 前缀
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user