feat: 完成控制面板功能

This commit is contained in:
Boen_Shi 2026-05-01 15:40:08 +08:00
parent cf3301c984
commit 85d28a9bfc
15 changed files with 8155 additions and 106 deletions

5
components.d.ts vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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",

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

@ -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 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>
<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; }
.toolbar-right {
justify-content: flex-start;
}
} }
.hero-content p {
margin: 0 auto; :global(.dashboard-is-dragging) {
max-width: 640px; opacity: 0.35;
color: #475569; }
font-size: 16px;
line-height: 1.8; :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>

View File

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

@ -0,0 +1,2 @@
declare module 'sortablejs'
declare module 'carddragger'

View File

@ -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 前缀
} }