@@ -3,6 +3,8 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useConnectionsStore } from '../stores/connections.store' ;
import { useAuditLogStore } from '../stores/audit.store' ;
import { useSessionStore } from '../stores/session.store' ;
import { useTagsStore } from '../stores/tags.store' ; / / + + + 添 加 标 签 s t o r e + + +
import type { TagInfo } from '../stores/tags.store' ; / / + + + 修 正 标 签 类 型 导 入 + + +
/ / R e m o v e d s e t t i n g s s t o r e i m p o r t f o r s o r t i n g
import type { SortField , SortOrder } from '../stores/settings.store' ; / / K e e p t y p e i m p o r t
import { useI18n } from 'vue-i18n' ;
@@ -18,19 +20,29 @@ const router = useRouter();
const connectionsStore = useConnectionsStore ( ) ;
const auditLogStore = useAuditLogStore ( ) ;
const sessionStore = useSessionStore ( ) ;
const tagsStore = useTagsStore ( ) ; / / + + + 实 例 化 标 签 s t o r e + + +
/ / R e m o v e d s e t t i n g s s t o r e i n s t a n t i a t i o n
const { connections , isLoading : isLoadingConnections } = storeToRefs ( connectionsStore ) ;
const { logs : auditLogs , isLoading : isLoadingLogs , totalLogs } = storeToRefs ( auditLogStore ) ;
const { tags , isLoading : isLoadingTags } = storeToRefs ( tagsStore ) ; / / + + + 获 取 标 签 数 据 和 加 载 状 态 + + +
/ / R e m o v e d r e f s f r o m s e t t i n g s s t o r e
/ / L o c a l s t a t e f o r s o r t i n g w i t h l o c a l S t o r a g e p e r s i s t e n c e
const LS _SORT _BY _KEY = 'dashboard_connections_sort_by' ;
const LS _SORT _ORDER _KEY = 'dashboard_connections_sort_order' ;
const LS _FILTER _TAG _KEY = 'dashboard_connections_filter_tag' ; / / + + + 添 加 标 签 筛 选 的 l o c a l S t o r a g e k e y + + +
/ / I n i t i a l i z e w i t h l o c a l S t o r a g e v a l u e s o r d e f a u l t s
const localSortBy = ref < SortField > ( localStorage . getItem ( LS _SORT _BY _KEY ) as SortField || 'last_connected_at' ) ;
const localSortOrder = ref < SortOrder > ( localStorage . getItem ( LS _SORT _ORDER _KEY ) as SortOrder || 'desc' ) ;
/ / + + + 初 始 化 标 签 筛 选 状 态 , 从 l o c a l S t o r a g e 读 取 , 注 意 类 型 转 换 ( 修 正 r e f 初 始 化 ) + + +
const getInitialSelectedTagId = ( ) : number | null => {
const storedValue = localStorage . getItem ( LS _FILTER _TAG _KEY ) ;
/ / 如 果 存 储 的 值 是 ' n u l l ' 字 符 串 或 空 , 则 返 回 n u l l , 否 则 解 析 为 数 字
return storedValue && storedValue !== 'null' ? parseInt ( storedValue , 10 ) : null ;
} ;
const selectedTagId = ref < number | null > ( getInitialSelectedTagId ( ) ) ;
const maxRecentLogs = 5 ;
@@ -42,12 +54,20 @@ const sortOptions: { value: SortField; labelKey: string }[] = [
{ value : 'created_at' , labelKey : 'dashboard.sortOptions.created' } ,
] ;
const sortedConnections = computed ( ( ) => {
const sortBy = localSortBy . value ; / / U s e l o c a l s t a t e
const sortOrderVal = localSortOrder . value ; / / U s e l o c a l s t a t e
/ / + + + 修 改 计 算 属 性 , 先 筛 选 再 排 序 + + +
const filteredAndSortedConnections = computed ( ( ) => {
const sortBy = localSortBy . value ;
const sortOrderVal = localSortOrder . value ;
const factor = sortOrderVal === 'desc' ? - 1 : 1 ;
const filterTagId = selectedTagId . value ;
return [ ... connections . value ] . sort ( ( a , b ) => {
/ / 1 . F i l t e r b y s e l e c t e d t a g
const filtered = filterTagId === null
? [ ... connections . value ] / / N o t a g s e l e c t e d , s h o w a l l
: connections . value . filter ( conn => conn . tag _ids ? . includes ( filterTagId ) ) ;
/ / 2 . S o r t t h e f i l t e r e d c o n n e c t i o n s
return filtered . sort ( ( a , b ) => {
let valA : any ;
let valB : any ;
@@ -69,10 +89,8 @@ const sortedConnections = computed(() => {
valB = b . updated _at ? ? 0 ;
return ( valA - valB ) * factor ;
case 'last_connected_at' :
/ / H a n d l e n u l l / u n d e f i n e d l a s t _ c o n n e c t e d _ a t b a s e d o n s o r t o r d e r f o r c o n s i s t e n t s o r t i n g
valA = a . last _connected _at ? ? ( sortOrderVal === 'desc' ? - Infinity : Infinity ) ;
valB = b . last _connected _at ? ? ( sortOrderVal === 'desc' ? - Infinity : Infinity ) ;
/ / E n s u r e c o n s i s t e n t c o m p a r i s o n f o r p o t e n t i a l l y i n f i n i t e v a l u e s
if ( valA === valB ) return 0 ;
if ( valA < valB ) return - 1 * factor ;
return 1 * factor ;
@@ -87,8 +105,9 @@ const recentAuditLogs = computed(() => {
} ) ;
onMounted ( async ( ) => {
/ / L o a d s a v e d s o r t p r e f e r e n c e s f r o m l o c a l S t o r a g e ( a l r e a d y d o n e d u r i n g r e f i n i t i a l i z a t i o n )
/ / L o a d s a v e d p r e f e r e n c e s f r o m l o c a l S t o r a g e ( a l r e a d y d o n e d u r i n g r e f i n i t i a l i z a t i o n )
/ / F e t c h c o n n e c t i o n s i f n o t a l r e a d y l o a d e d
if ( connections . value . length === 0 ) {
try {
await connectionsStore . fetchConnections ( ) ;
@@ -96,6 +115,8 @@ onMounted(async () => {
console . error ( "加载连接列表失败:" , error ) ;
}
}
/ / F e t c h r e c e n t a u d i t l o g s
try {
await auditLogStore . fetchLogs ( {
page : 1 ,
@@ -106,6 +127,13 @@ onMounted(async () => {
} catch ( error ) {
console . error ( "加载审计日志失败:" , error ) ;
}
/ / + + + F e t c h t a g s f o r f i l t e r i n g + + +
try {
await tagsStore . fetchTags ( ) ;
} catch ( error ) {
console . error ( "加载标签列表失败:" , error ) ;
}
} ) ;
const connectTo = ( connection : ConnectionInfo ) => {
@@ -128,6 +156,12 @@ watch(localSortOrder, (newValue) => {
localStorage . setItem ( LS _SORT _ORDER _KEY , newValue ) ;
} ) ;
/ / + + + W a t c h f o r c h a n g e s i n s e l e c t e d t a g a n d s a v e t o l o c a l S t o r a g e + + +
watch ( selectedTagId , ( newValue ) => {
/ / S t o r e ' n u l l ' a s a s t r i n g o r t h e n u m b e r
localStorage . setItem ( LS _FILTER _TAG _KEY , newValue === null ? 'null' : String ( newValue ) ) ;
} ) ;
const dateFnsLocales : Record < string , Locale > = {
'en-US' : enUS ,
'zh-CN' : zhCN ,
@@ -189,6 +223,20 @@ const isFailedAction = (actionType: string): boolean => {
/ / 检 查 常 见 的 失 败 关 键 词
return lowerCaseAction . includes ( 'fail' ) || lowerCaseAction . includes ( 'error' ) || lowerCaseAction . includes ( 'denied' ) ;
} ;
/ / + + + 恢 复 : 根 据 t a g _ i d s 获 取 标 签 名 称 数 组 + + +
const getTagNames = ( tagIds : number [ ] | undefined ) : string [ ] => {
if ( ! tagIds || tagIds . length === 0 ) {
return [ ] ;
}
const allTags = tags . value as TagInfo [ ] ;
return tagIds
. map ( id => allTags . find ( tag => tag . id === id ) ? . name )
. filter ( ( name ) : name is string => ! ! name ) ; / / 过 滤 掉 未 找 到 的 标 签 并 确 保 类 型 为 s t r i n g
} ;
/ / - - - 移 除 s e l e c t T a g F i l t e r 函 数 - - -
< / script >
< template >
@@ -201,7 +249,24 @@ const isFailedAction = (actionType: string): boolean => {
< div class = "bg-card text-card-foreground shadow rounded-lg overflow-hidden border border-border min-h-[400px]" >
< div class = "px-4 py-3 border-b border-border flex justify-between items-center" >
< h2 class = "text-lg font-medium" > { { t ( 'dashboard.connectionList' , '连接列表' ) } } < / h2 >
< div class = "flex items-center space-x-2" >
< div class = "flex items-center space-x-2 flex-wrap gap-y-2" > <!-- Added flex - wrap and gap - y for responsiveness -- >
<!-- Tag Filter Dropdown -- >
< select
v - model = "selectedTagId"
class = "h-8 px-2 py-1 text-sm border border-border rounded bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary appearance-none bg-no-repeat bg-right pr-8"
style = "background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 16px 12px;"
aria - label = "Filter connections by tag"
: disabled = "isLoadingTags"
>
< option :value = "null" > { { t ( 'dashboard.filterTags.all' , '所有标签' ) } } < / option >
< option v-if = "isLoadingTags" disabled > { { t ( 'common.loading' ) } } < / option >
<!-- 修正 v - for 循环中的类型 -- >
< option v-for = "tag in (tags as TagInfo[])" :key="tag.id" :value="tag.id" >
{ { tag . name } }
< / option >
< / select >
<!-- Sort By Dropdown -- >
< select
v - model = "localSortBy"
class = "h-8 px-2 py-1 text-sm border border-border rounded bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary appearance-none bg-no-repeat bg-right pr-8"
@@ -212,6 +277,8 @@ const isFailedAction = (actionType: string): boolean => {
{ { t ( option . labelKey , option . value . replace ( '_' , ' ' ) ) } }
< / option >
< / select >
<!-- Sort Order Button -- >
< button
@ click = "toggleSortOrder"
class = "h-8 px-1.5 py-1 border border-border rounded hover:bg-muted focus:outline-none focus:ring-1 focus:ring-primary flex items-center justify-center"
@@ -223,9 +290,11 @@ const isFailedAction = (actionType: string): boolean => {
< / div >
< / div >
< div class = "p-4" >
< div v-if = "isLoadingConnections && sortedConnections.length === 0" class="text-center text-text-secondary" > {{ t ( ' common.loading ' ) }} < / div >
< ul v-else-if = "s ortedConnections.length > 0" class="space-y-3" >
< li v-for = "conn in s ortedConnections" :key="conn.id" class="flex items-center justify-between p-3 bg-header/50 border border-border/50 rounded transition duration-150 ease-in-out " >
<!-- Use filteredAndSortedConnections and check its length -- >
< div v-if = "isLoadingConnections && filteredAndS ortedConnections.length === 0" class="text-center text-text-secondary" > {{ t ( ' common.loading ' ) }} < / div >
< ul v-else-if = "filteredAndS ortedConnections.length > 0" class="space-y-3 " >
<!-- Iterate over filteredAndSortedConnections -- >
< li v-for = "conn in filteredAndSortedConnections" :key="conn.id" class="flex items-center justify-between p-3 bg-header/50 border border-border/50 rounded transition duration-150 ease-in-out" >
< div class = "flex-grow mr-4 overflow-hidden" >
< span class = "font-medium block truncate flex items-center" : title = "conn.name || ''" >
< i : class = "['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']" > < / i >
@@ -234,15 +303,26 @@ const isFailedAction = (actionType: string): boolean => {
< span class = "text-sm text-text-secondary block truncate" :title = "`${conn.username}@${conn.host}:${conn.port}`" >
{ { conn . username } } @ { { conn . host } } : { { conn . port } }
< / span >
< span class = "text-xs text-text-alt block" >
< span class = "text-xs text-text-alt block mb-1 " > <!-- Added margin - bottom -- >
{ { t ( 'dashboard.lastConnected' , '上次连接:' ) } } { { formatRelativeTime ( conn . last _connected _at ) } }
< / span >
< div v-if = "getTagNames(conn.tag_ids).length > 0" class="flex flex-wrap gap-1 mt-1" >
< span
v - for = "tagName in getTagNames(conn.tag_ids)"
: key = "tagName"
class = "px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground border border-border"
>
{ { tagName } }
< / span >
< / div >
< / div >
< button @click ="connectTo(conn)" class = "px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out text-sm font-medium flex-shrink-0" > <!-- Applied standard button style -- >
{ { t ( 'connections.actions.connect' ) } }
< / button >
< / li >
< / ul >
<!-- Adjust no connections message based on filtering -- >
< div v-else-if = "!isLoadingConnections && selectedTagId !== null" class="text-center text-text-secondary" > {{ t ( ' dashboard.noConnectionsWithTag ' , ' 该标签下没有连接记录 ' ) }} < / div >
< div v-else class = "text-center text-text-secondary" > { { t ( 'dashboard.noConnections' , '没有连接记录' ) } } < / div >
< / div >
< / div >