Revert "feat(frontend): unify ui with slate control center"
This reverts commit 91aa6e83ca.
This commit is contained in:
@@ -1,203 +1,228 @@
|
||||
<template>
|
||||
<div class="p-4 bg-background text-foreground"> <!-- Outer container with padding -->
|
||||
<div class="max-w-7xl mx-auto"> <!-- Inner container for max-width (slightly wider for table) and centering -->
|
||||
<h1 class="text-xl font-semibold text-foreground mb-4 pb-2 border-b border-border"> <!-- Title styling -->
|
||||
{{ $t('auditLog.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Filtering Controls -->
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4 p-4 border border-border rounded-lg bg-header/50">
|
||||
<div class="flex-grow min-w-[200px]">
|
||||
<label for="search-term" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('common.search') }}</label>
|
||||
<input type="text" id="search-term" v-model="searchTerm" :placeholder="$t('auditLog.searchPlaceholder')"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm">
|
||||
</div>
|
||||
<div class="flex-grow min-w-[200px]">
|
||||
<label for="action-type" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('auditLog.table.actionType') }}</label>
|
||||
<select id="action-type" v-model="selectedActionType"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8 text-sm"
|
||||
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.75rem center; background-size: 16px 12px;">
|
||||
<option value="">{{ $t('common.all') }}</option>
|
||||
<option v-for="type in allActionTypes" :key="type" :value="type">{{ translateActionType(type) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button @click="applyFilters" class="px-4 py-2 bg-button text-button-text rounded hover:bg-button-hover text-sm font-medium">
|
||||
{{ $t('common.filter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Filtering Controls -->
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="store.error" class="p-4 mb-4 border-l-4 border-error bg-error/10 text-error rounded">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<!-- Loading state (Only show if loading AND logs empty) -->
|
||||
<div v-else-if="store.isLoading && logs.length === 0" class="p-4 text-center text-text-secondary italic">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- No logs state (Show only if not loading, no error, and logs empty) -->
|
||||
<div v-else-if="!store.isLoading && !store.error && logs.length === 0" class="p-4 mb-4 border-l-4 border-blue-400 bg-blue-100 text-blue-700 rounded">
|
||||
{{ $t('auditLog.noLogs') }}
|
||||
</div>
|
||||
|
||||
<!-- Table and Pagination (Show if not loading, no error, and logs exist) -->
|
||||
<div v-else-if="!store.isLoading && !store.error && logs.length > 0">
|
||||
<!-- Pagination Controls -->
|
||||
<nav aria-label="Audit Log Pagination" v-if="totalPages > 1" class="mb-4 flex justify-center"> <!-- Removed mt-6, added mb-4 -->
|
||||
<ul class="inline-flex items-center -space-x-px">
|
||||
<li>
|
||||
<a href="#" @click.prevent="changePage(currentPage - 1)"
|
||||
:class="['px-3 py-2 ml-0 leading-tight text-text-secondary bg-background border border-border rounded-l-lg hover:bg-header hover:text-foreground', { 'opacity-50 cursor-not-allowed pointer-events-none': currentPage === 1 }]">
|
||||
«
|
||||
</a>
|
||||
</li>
|
||||
<li v-for="page in paginationRange" :key="page">
|
||||
<a v-if="page !== '...'" href="#" @click.prevent="changePage(page as number)"
|
||||
:class="['px-3 py-2 leading-tight border border-border', page === currentPage ? 'text-button-text bg-button border-button hover:bg-button-hover' : 'text-text-secondary bg-background hover:bg-header hover:text-foreground']">
|
||||
{{ page }}
|
||||
</a>
|
||||
<span v-else class="px-3 py-2 leading-tight text-text-secondary bg-background border border-border">...</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="changePage(currentPage + 1)"
|
||||
:class="['px-3 py-2 leading-tight text-text-secondary bg-background border border-border rounded-r-lg hover:bg-header hover:text-foreground', { 'opacity-50 cursor-not-allowed pointer-events-none': currentPage === totalPages }]">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="text-right text-text-secondary text-sm mb-4"> <!-- Changed text-center to text-right, removed mt-3, added mb-4 -->
|
||||
{{ $t('auditLog.paginationInfo', { currentPage, totalPages, totalLogs }) }}
|
||||
</div>
|
||||
<div class="border border-border rounded-lg overflow-hidden shadow-sm bg-background"> <!-- Removed mt-4 -->
|
||||
<div class="overflow-x-auto"> <!-- Allow horizontal scroll -->
|
||||
<table class="min-w-full divide-y divide-border text-sm"> <!-- Table styling -->
|
||||
<thead class="bg-header">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left font-medium text-text-secondary tracking-wider whitespace-nowrap">{{ $t('auditLog.table.timestamp') }}</th>
|
||||
<th scope="col" class="px-6 py-3 text-left font-medium text-text-secondary tracking-wider whitespace-nowrap">{{ $t('auditLog.table.actionType') }}</th>
|
||||
<th scope="col" class="px-6 py-3 text-left font-medium text-text-secondary tracking-wider">{{ $t('auditLog.table.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-for="log in logs" :key="log.id" class="hover:bg-header/50"> <!-- Table rows with hover -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">{{ formatTimestamp(log.timestamp) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{{ translateActionType(log.action_type) }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<pre v-if="log.details" class="whitespace-pre-wrap break-all bg-header/50 p-2 border border-border/50 rounded text-xs font-mono max-h-40 overflow-y-auto">{{ formatDetails(log.details) }}</pre> <!-- Details pre styling -->
|
||||
<span v-else class="text-text-secondary">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref, onMounted, computed } from 'vue'; // Removed watch
|
||||
import { useAuditLogStore } from '../stores/audit.store';
|
||||
import type { AuditLogEntry, AuditLogActionType } from '../types/server.types';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import { AuditLogEntry, AuditLogActionType } from '../types/server.types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// Removed lodash-es import
|
||||
|
||||
const store = useAuditLogStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// --- Filtering State ---
|
||||
const searchTerm = ref('');
|
||||
const selectedActionType = ref<AuditLogActionType | ''>('');
|
||||
const selectedActionType = ref<AuditLogActionType | ''>(''); // Allow empty string for 'All'
|
||||
|
||||
// Define all possible action types for the dropdown
|
||||
const allActionTypes: AuditLogActionType[] = [
|
||||
'LOGIN_SUCCESS',
|
||||
'LOGIN_FAILURE',
|
||||
'LOGOUT',
|
||||
'PASSWORD_CHANGED',
|
||||
'2FA_ENABLED',
|
||||
'2FA_DISABLED',
|
||||
'CONNECTION_CREATED',
|
||||
'CONNECTION_UPDATED',
|
||||
'CONNECTION_DELETED',
|
||||
'PROXY_CREATED',
|
||||
'PROXY_UPDATED',
|
||||
'PROXY_DELETED',
|
||||
'TAG_CREATED',
|
||||
'TAG_UPDATED',
|
||||
'TAG_DELETED',
|
||||
'SETTINGS_UPDATED',
|
||||
'IP_WHITELIST_UPDATED',
|
||||
'NOTIFICATION_SETTING_CREATED',
|
||||
'NOTIFICATION_SETTING_UPDATED',
|
||||
'NOTIFICATION_SETTING_DELETED',
|
||||
'SSH_CONNECT_SUCCESS',
|
||||
'SSH_CONNECT_FAILURE',
|
||||
'SSH_SHELL_FAILURE',
|
||||
'DATABASE_MIGRATION',
|
||||
'ADMIN_SETUP_COMPLETE',
|
||||
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'PASSWORD_CHANGED',
|
||||
'2FA_ENABLED', '2FA_DISABLED',
|
||||
'CONNECTION_CREATED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED',
|
||||
'PROXY_CREATED', 'PROXY_UPDATED', 'PROXY_DELETED',
|
||||
'TAG_CREATED', 'TAG_UPDATED', 'TAG_DELETED',
|
||||
'SETTINGS_UPDATED', 'IP_WHITELIST_UPDATED',
|
||||
'NOTIFICATION_SETTING_CREATED', 'NOTIFICATION_SETTING_UPDATED', 'NOTIFICATION_SETTING_DELETED',
|
||||
// SSH Actions
|
||||
'SSH_CONNECT_SUCCESS', 'SSH_CONNECT_FAILURE', 'SSH_SHELL_FAILURE',
|
||||
// System/Error
|
||||
'DATABASE_MIGRATION', 'ADMIN_SETUP_COMPLETE'
|
||||
];
|
||||
|
||||
const logs = computed(() => store.logs);
|
||||
const totalLogs = computed(() => store.totalLogs);
|
||||
const currentPage = computed(() => store.currentPage);
|
||||
const logsPerPage = computed(() => store.logsPerPage);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalLogs.value / logsPerPage.value)));
|
||||
|
||||
const auditStats = computed(() => [
|
||||
{
|
||||
label: t('auditLog.title'),
|
||||
value: totalLogs.value,
|
||||
meta: `${t('common.search', '搜索')}: ${searchTerm.value || t('common.all', '全部')}`,
|
||||
},
|
||||
{
|
||||
label: t('auditLog.table.actionType'),
|
||||
value: selectedActionType.value || t('common.all', '全部'),
|
||||
meta: `${currentPage.value} / ${totalPages.value}`,
|
||||
},
|
||||
]);
|
||||
const totalPages = computed(() => Math.ceil(totalLogs.value / logsPerPage.value));
|
||||
|
||||
// Function to apply filters and fetch logs
|
||||
const applyFilters = () => {
|
||||
store.fetchLogs({
|
||||
page: 1,
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined,
|
||||
});
|
||||
// Pass undefined if filter is empty, otherwise pass the value
|
||||
store.fetchLogs({
|
||||
page: 1, // Reset to page 1 when applying filters
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined
|
||||
});
|
||||
};
|
||||
|
||||
// Removed watch for filters
|
||||
|
||||
onMounted(() => {
|
||||
// Fetch initial logs without filters
|
||||
store.fetchLogs();
|
||||
});
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => new Date(timestamp * 1000).toLocaleString();
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
// Convert seconds to milliseconds for Date constructor
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const translateActionType = (actionType: AuditLogActionType): string => {
|
||||
const key = `auditLog.actions.${actionType}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? actionType : translated;
|
||||
// Attempt to translate using a convention like auditLog.actions.ACTION_TYPE
|
||||
const key = `auditLog.actions.${actionType}`;
|
||||
const translated = t(key);
|
||||
// If translation is missing, return the original type
|
||||
return translated === key ? actionType : translated;
|
||||
};
|
||||
|
||||
const formatDetails = (details: AuditLogEntry['details']): string => {
|
||||
if (!details) return '-';
|
||||
if (typeof details === 'object') {
|
||||
if (!details) return '';
|
||||
if (typeof details === 'object' && details !== null) {
|
||||
if ('raw' in details && details.parseError) {
|
||||
return `[Parse Error] Raw: ${details.raw}`;
|
||||
return `[Parse Error] Raw: ${details.raw}`;
|
||||
}
|
||||
return JSON.stringify(details, null, 2);
|
||||
return JSON.stringify(details, null, 2); // Pretty print JSON
|
||||
}
|
||||
return String(details);
|
||||
return String(details); // Should ideally not happen if backend sends JSON string
|
||||
};
|
||||
|
||||
const changePage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
|
||||
// Retain current filters when changing page
|
||||
store.fetchLogs({
|
||||
page,
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined,
|
||||
page: page,
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Simple pagination range logic (can be improved for many pages)
|
||||
const paginationRange = computed(() => {
|
||||
const range: (number | string)[] = [];
|
||||
const delta = 2; // Number of pages around current page
|
||||
const left = currentPage.value - delta;
|
||||
const right = currentPage.value + delta + 1;
|
||||
let l: number | null = null; // Keep track of the last number added
|
||||
|
||||
for (let i = 1; i <= totalPages.value; i++) {
|
||||
if (i === 1 || i === totalPages.value || (i >= left && i < right)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const result: (number | string)[] = [];
|
||||
for (const pageNum of range) {
|
||||
// Ensure pageNum is treated as number for comparison/arithmetic
|
||||
const currentNum = pageNum as number;
|
||||
if (l !== null) {
|
||||
// Calculate difference explicitly as numbers
|
||||
if (currentNum - l === 2) {
|
||||
result.push(l + 1);
|
||||
} else if (currentNum - l > 1) { // Check if difference is greater than 1
|
||||
result.push('...');
|
||||
}
|
||||
}
|
||||
result.push(currentNum);
|
||||
l = currentNum; // Store the current number
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="$t('auditLog.title')"
|
||||
:subtitle="$t('auditLog.controlCenterSubtitle', '通过统一的筛选、时间线与明细面板追踪所有关键系统操作。')"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button plain @click="applyFilters">
|
||||
<i class="fas fa-rotate-right mr-2"></i>
|
||||
{{ $t('common.filter') }}
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #stats>
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in auditStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(240px,1fr)_220px_auto]">
|
||||
<el-input v-model="searchTerm" :placeholder="$t('auditLog.searchPlaceholder')" clearable>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-select v-model="selectedActionType" clearable :placeholder="$t('auditLog.table.actionType')">
|
||||
<el-option :label="$t('common.all')" value="" />
|
||||
<el-option
|
||||
v-for="type in allActionTypes"
|
||||
:key="type"
|
||||
:label="translateActionType(type)"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-button type="primary" @click="applyFilters">
|
||||
{{ $t('common.filter') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="store.error"
|
||||
class="mt-4"
|
||||
:title="store.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div v-else-if="store.isLoading && logs.length === 0" class="control-empty mt-4">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!store.isLoading && logs.length === 0" class="control-empty mt-4">
|
||||
<el-empty :description="$t('auditLog.noLogs')" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<el-table :data="logs" class="mt-5" stripe>
|
||||
<el-table-column prop="timestamp" :label="$t('auditLog.table.timestamp')" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.timestamp) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="action_type" :label="$t('auditLog.table.actionType')" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ translateActionType(row.action_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="details" :label="$t('auditLog.table.details')" min-width="420">
|
||||
<template #default="{ row }">
|
||||
<pre class="m-0 whitespace-pre-wrap break-all rounded-2xl bg-muted p-3 text-xs text-foreground">{{ formatDetails(row.details) }}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('auditLog.paginationInfo', { currentPage, totalPages, totalLogs }) }}
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="currentPage"
|
||||
:page-size="logsPerPage"
|
||||
:total="totalLogs"
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
|
||||
</style>
|
||||
|
||||
@@ -746,4 +746,4 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
@saved="handleBatchEditSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,51 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN, enUS, ja } from 'date-fns/locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import AddConnectionForm from '../components/AddConnectionForm.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';
|
||||
import type { TagInfo } from '../stores/tags.store';
|
||||
import { useTagsStore } from '../stores/tags.store';
|
||||
import type { TagInfo } from '../stores/tags.store';
|
||||
|
||||
import type { SortField, SortOrder } from '../stores/settings.store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { ConnectionInfo } from '../stores/connections.store';
|
||||
import type { SortField, SortOrder } from '../stores/settings.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN, enUS, ja } from 'date-fns/locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const auditLogStore = useAuditLogStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const tagsStore = useTagsStore();
|
||||
const tagsStore = useTagsStore();
|
||||
|
||||
|
||||
const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore);
|
||||
const { logs: auditLogs, isLoading: isLoadingLogs, totalLogs } = storeToRefs(auditLogStore);
|
||||
const { tags, isLoading: isLoadingTags } = storeToRefs(tagsStore);
|
||||
const { tags, isLoading: isLoadingTags } = storeToRefs(tagsStore);
|
||||
|
||||
|
||||
|
||||
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';
|
||||
|
||||
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');
|
||||
const LS_FILTER_TAG_KEY = 'dashboard_connections_filter_tag';
|
||||
|
||||
// Initialize with localStorage values or defaults
|
||||
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');
|
||||
// +++ 初始化标签筛选状态,从 localStorage 读取,注意类型转换 (修正 ref 初始化) +++
|
||||
const getInitialSelectedTagId = (): number | null => {
|
||||
const storedValue = localStorage.getItem(LS_FILTER_TAG_KEY);
|
||||
// 如果存储的值是 'null' 字符串或空,则返回 null,否则解析为数字
|
||||
return storedValue && storedValue !== 'null' ? parseInt(storedValue, 10) : null;
|
||||
};
|
||||
|
||||
const selectedTagId = ref<number | null>(getInitialSelectedTagId());
|
||||
const searchQuery = ref('');
|
||||
|
||||
// +++ 控制添加/编辑表单的显示状态 +++
|
||||
const showAddEditConnectionForm = ref(false);
|
||||
const connectionToEdit = ref<ConnectionInfo | null>(null);
|
||||
|
||||
const maxRecentLogs = 5;
|
||||
|
||||
|
||||
const sortOptions: { value: SortField; labelKey: string }[] = [
|
||||
{ value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' },
|
||||
{ value: 'name', labelKey: 'dashboard.sortOptions.name' },
|
||||
@@ -54,114 +60,98 @@ const sortOptions: { value: SortField; labelKey: string }[] = [
|
||||
{ value: 'created_at', labelKey: 'dashboard.sortOptions.created' },
|
||||
];
|
||||
|
||||
// +++ 修改计算属性,先筛选再排序 +++
|
||||
const filteredAndSortedConnections = computed(() => {
|
||||
const sortBy = localSortBy.value;
|
||||
const sortOrderVal = localSortOrder.value;
|
||||
const factor = sortOrderVal === 'desc' ? -1 : 1;
|
||||
const filterTagId = selectedTagId.value;
|
||||
const query = searchQuery.value.toLowerCase().trim();
|
||||
|
||||
const filteredByTag =
|
||||
filterTagId === null
|
||||
? [...connections.value]
|
||||
: connections.value.filter((conn) => conn.tag_ids?.includes(filterTagId));
|
||||
|
||||
const searchedConnections = query
|
||||
? filteredByTag.filter((conn) => {
|
||||
const nameMatch = conn.name?.toLowerCase().includes(query);
|
||||
const usernameMatch = conn.username?.toLowerCase().includes(query);
|
||||
const hostMatch = conn.host?.toLowerCase().includes(query);
|
||||
const portMatch = conn.port?.toString().includes(query);
|
||||
return nameMatch || usernameMatch || hostMatch || portMatch;
|
||||
})
|
||||
: filteredByTag;
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim(); // +++ 获取搜索查询 +++
|
||||
|
||||
// 1. Filter by selected tag
|
||||
let filteredByTag = filterTagId === null
|
||||
? [...connections.value] // No tag selected, show all
|
||||
: connections.value.filter(conn => conn.tag_ids?.includes(filterTagId));
|
||||
|
||||
// 2. Filter by search query
|
||||
let searchedConnections = filteredByTag;
|
||||
if (query) {
|
||||
searchedConnections = filteredByTag.filter(conn => {
|
||||
const nameMatch = conn.name?.toLowerCase().includes(query);
|
||||
const usernameMatch = conn.username?.toLowerCase().includes(query);
|
||||
const hostMatch = conn.host?.toLowerCase().includes(query);
|
||||
const portMatch = conn.port?.toString().includes(query);
|
||||
return nameMatch || usernameMatch || hostMatch || portMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Sort the searched connections
|
||||
return searchedConnections.sort((a, b) => {
|
||||
let valA: string | number;
|
||||
let valB: string | number;
|
||||
let valA: any;
|
||||
let valB: any;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
valA = a.name || '';
|
||||
valB = b.name || '';
|
||||
return String(valA).localeCompare(String(valB)) * factor;
|
||||
return valA.localeCompare(valB) * factor;
|
||||
case 'type':
|
||||
valA = a.type || '';
|
||||
valB = b.type || '';
|
||||
return String(valA).localeCompare(String(valB)) * factor;
|
||||
return valA.localeCompare(valB) * factor;
|
||||
case 'created_at':
|
||||
valA = a.created_at ?? 0;
|
||||
valB = b.created_at ?? 0;
|
||||
return (Number(valA) - Number(valB)) * factor;
|
||||
return (valA - valB) * factor;
|
||||
case 'updated_at':
|
||||
valA = a.updated_at ?? 0;
|
||||
valB = b.updated_at ?? 0;
|
||||
return (Number(valA) - Number(valB)) * factor;
|
||||
return (valA - valB) * factor;
|
||||
case 'last_connected_at':
|
||||
valA = a.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity);
|
||||
valB = b.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity);
|
||||
if (valA === valB) return 0;
|
||||
return Number(valA) < Number(valB) ? -1 * factor : 1 * factor;
|
||||
if (valA < valB) return -1 * factor;
|
||||
return 1 * factor;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const recentAuditLogs = computed(() => auditLogs.value.slice(0, maxRecentLogs));
|
||||
|
||||
const dashboardStats = computed(() => {
|
||||
const taggedConnections = connections.value.filter((conn) => (conn.tag_ids?.length ?? 0) > 0).length;
|
||||
const sshConnections = connections.value.filter((conn) => conn.type === 'SSH').length;
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('dashboard.connectionList', '连接列表'),
|
||||
value: connections.value.length,
|
||||
meta: `${filteredAndSortedConnections.value.length} ${t('common.filter', '筛选')} / ${sshConnections} SSH`,
|
||||
},
|
||||
{
|
||||
label: t('settings.workspace.showConnectionTagsTitle', '连接标签'),
|
||||
value: tags.value.length,
|
||||
meta: `${taggedConnections} ${t('dashboard.filterTags.all', '已关联标签')}`,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.recentActivity', '最近活动'),
|
||||
value: recentAuditLogs.value.length,
|
||||
meta: `${totalLogs.value} ${t('auditLog.title', '审计日志')}`,
|
||||
},
|
||||
{
|
||||
label: t('nav.terminal', '终端会话'),
|
||||
value: sessionStore.sessions.size,
|
||||
meta: t('workspace.workbench.label', '工作台已接入'),
|
||||
},
|
||||
];
|
||||
const recentAuditLogs = computed(() => {
|
||||
return auditLogs.value.slice(0, maxRecentLogs);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// Load saved preferences from localStorage (already done during ref initialization)
|
||||
|
||||
// Fetch connections if not already loaded
|
||||
if (connections.value.length === 0) {
|
||||
try {
|
||||
await connectionsStore.fetchConnections();
|
||||
} catch (error) {
|
||||
console.error('Failed to load connections:', error);
|
||||
console.error("加载连接列表失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recent audit logs
|
||||
try {
|
||||
await auditLogStore.fetchLogs({
|
||||
page: 1,
|
||||
limit: maxRecentLogs,
|
||||
sortOrder: 'desc',
|
||||
isDashboardRequest: true,
|
||||
page: 1,
|
||||
limit: maxRecentLogs,
|
||||
sortOrder: 'desc',
|
||||
isDashboardRequest: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit logs:', error);
|
||||
console.error("加载审计日志失败:", error);
|
||||
}
|
||||
|
||||
// +++ Fetch tags for filtering +++
|
||||
try {
|
||||
await tagsStore.fetchTags();
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
console.error("加载标签列表失败:", error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -170,11 +160,13 @@ const connectTo = (connection: ConnectionInfo) => {
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
// Only update the local sort order state
|
||||
localSortOrder.value = localSortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||
};
|
||||
|
||||
const isAscending = computed(() => localSortOrder.value === 'asc');
|
||||
const isAscending = computed(() => localSortOrder.value === 'asc'); // Use local state
|
||||
|
||||
// Watch for changes in local sort state and save to localStorage
|
||||
watch(localSortBy, (newValue) => {
|
||||
localStorage.setItem(LS_SORT_BY_KEY, newValue);
|
||||
});
|
||||
@@ -183,7 +175,9 @@ watch(localSortOrder, (newValue) => {
|
||||
localStorage.setItem(LS_SORT_ORDER_KEY, newValue);
|
||||
});
|
||||
|
||||
// +++ Watch for changes in selected tag and save to localStorage +++
|
||||
watch(selectedTagId, (newValue) => {
|
||||
// Store 'null' as a string or the number
|
||||
localStorage.setItem(LS_FILTER_TAG_KEY, newValue === null ? 'null' : String(newValue));
|
||||
});
|
||||
|
||||
@@ -191,288 +185,239 @@ const dateFnsLocales: Record<string, Locale> = {
|
||||
'en-US': enUS,
|
||||
'zh-CN': zhCN,
|
||||
'ja-JP': ja,
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
ja,
|
||||
// 主语言回退
|
||||
'en': enUS,
|
||||
'zh': zhCN,
|
||||
'ja': ja,
|
||||
};
|
||||
|
||||
// 修正函数签名,接受 number | null | undefined
|
||||
const formatRelativeTime = (timestampInSeconds: number | null | undefined): string => {
|
||||
if (!timestampInSeconds) return t('connections.status.never');
|
||||
|
||||
try {
|
||||
// 将秒级时间戳转换为毫秒级
|
||||
const timestampInMs = timestampInSeconds * 1000;
|
||||
if (Number.isNaN(timestampInMs)) {
|
||||
return String(timestampInSeconds);
|
||||
// 检查转换后的值是否有效
|
||||
if (isNaN(timestampInMs)) {
|
||||
console.warn(`[Dashboard] Invalid timestamp received: ${timestampInSeconds}`);
|
||||
return String(timestampInSeconds); // 返回原始值或错误提示
|
||||
}
|
||||
const date = new Date(timestampInMs);
|
||||
|
||||
const currentI18nLocale = locale.value; // 获取 vue-i18n 当前 locale (e.g., 'zh-CN')
|
||||
const langPart = currentI18nLocale.split('-')[0]; // 获取主语言部分 (e.g., 'zh')
|
||||
|
||||
// 1. 尝试精确匹配 (e.g., 'zh-CN' -> zhCN)
|
||||
let targetDateFnsLocale = dateFnsLocales[currentI18nLocale];
|
||||
|
||||
// 2. 如果无精确匹配,尝试匹配主语言 (e.g., 'zh' -> zhCN)
|
||||
if (!targetDateFnsLocale) {
|
||||
targetDateFnsLocale = dateFnsLocales[langPart];
|
||||
}
|
||||
|
||||
const date = new Date(timestampInMs);
|
||||
const currentI18nLocale = locale.value;
|
||||
const langPart = currentI18nLocale.split('-')[0];
|
||||
const targetLocale = dateFnsLocales[currentI18nLocale] || dateFnsLocales[langPart] || enUS;
|
||||
// 3. 如果仍然找不到,回退到默认 enUS
|
||||
if (!targetDateFnsLocale) {
|
||||
console.warn(`[Dashboard] date-fns locale not found for ${currentI18nLocale} or ${langPart}. Falling back to en-US.`);
|
||||
targetDateFnsLocale = enUS; // 默认回退到 enUS
|
||||
}
|
||||
|
||||
return formatDistanceToNow(date, { addSuffix: true, locale: targetLocale });
|
||||
} catch (error) {
|
||||
console.error('Failed to format date:', error);
|
||||
return String(timestampInSeconds);
|
||||
return formatDistanceToNow(date, { addSuffix: true, locale: targetDateFnsLocale });
|
||||
} catch (e) {
|
||||
console.error("格式化日期失败:", e);
|
||||
return String(timestampInSeconds); // 出错时返回原始字符串
|
||||
}
|
||||
};
|
||||
|
||||
const getActionTranslation = (actionType: string): string => {
|
||||
// 尝试从 i18n 获取翻译,如果找不到则返回原始 actionType
|
||||
const key = `auditLog.actions.${actionType}`;
|
||||
const translated = t(key);
|
||||
// 如果翻译结果等于 key 本身,说明没有找到翻译
|
||||
return translated === key ? actionType : translated;
|
||||
};
|
||||
|
||||
// 辅助函数:判断活动类型是否表示失败
|
||||
const isFailedAction = (actionType: string): boolean => {
|
||||
const lowerCaseAction = actionType.toLowerCase();
|
||||
// 检查常见的失败关键词
|
||||
return lowerCaseAction.includes('fail') || lowerCaseAction.includes('error') || lowerCaseAction.includes('denied');
|
||||
};
|
||||
|
||||
// +++ 恢复:根据 tag_ids 获取标签名称数组 +++
|
||||
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 => Boolean(name));
|
||||
.map(id => allTags.find(tag => tag.id === id)?.name)
|
||||
.filter((name): name is string => !!name); // 过滤掉未找到的标签并确保类型为 string
|
||||
};
|
||||
|
||||
// +++ 打开添加表单 +++
|
||||
const openAddConnectionForm = () => {
|
||||
connectionToEdit.value = null;
|
||||
showAddEditConnectionForm.value = true;
|
||||
};
|
||||
|
||||
// +++ 打开编辑表单 +++
|
||||
const openEditConnectionForm = (conn: ConnectionInfo) => {
|
||||
connectionToEdit.value = conn;
|
||||
showAddEditConnectionForm.value = true;
|
||||
};
|
||||
|
||||
// +++ 处理表单关闭事件 +++
|
||||
const handleFormClose = () => {
|
||||
showAddEditConnectionForm.value = false;
|
||||
connectionToEdit.value = null;
|
||||
connectionToEdit.value = null; // 清除编辑状态
|
||||
};
|
||||
|
||||
// +++ 处理连接添加/更新成功事件 +++
|
||||
const handleConnectionModified = async () => {
|
||||
showAddEditConnectionForm.value = false;
|
||||
connectionToEdit.value = null;
|
||||
await connectionsStore.fetchConnections();
|
||||
};
|
||||
|
||||
const openConnectionsView = () => {
|
||||
router.push('/connections');
|
||||
};
|
||||
|
||||
const openAuditLogsView = () => {
|
||||
router.push('/audit-logs');
|
||||
await connectionsStore.fetchConnections(); // 重新加载连接列表
|
||||
};
|
||||
|
||||
// --- 移除 selectTagFilter 函数 ---
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="t('nav.dashboard')"
|
||||
:subtitle="t('dashboard.controlCenterSubtitle', '在一个控制中心里查看连接、审计和常用入口,快速进入工作区。')"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button plain @click="openAuditLogsView">
|
||||
<i class="fas fa-shield-halved mr-2"></i>
|
||||
{{ t('dashboard.viewFullAuditLog', '查看完整审计日志') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openAddConnectionForm">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
{{ t('connections.addConnection', '添加新连接') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="p-4 md:p-6 lg:p-8 bg-background text-foreground">
|
||||
<h1 class="text-2xl font-semibold mb-6">{{ t('nav.dashboard') }}</h1>
|
||||
|
||||
<template #stats>
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in dashboardStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:items-start">
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[1.5fr_1fr]">
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">
|
||||
{{ t('dashboard.connectionList', '连接列表') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ filteredAndSortedConnections.length }} / {{ connections.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-[minmax(200px,1fr)_150px_160px_auto_auto]">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('dashboard.searchConnectionsPlaceholder', '搜索连接...')"
|
||||
clearable
|
||||
<!-- Connection List -->
|
||||
<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 flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
|
||||
<h2 class="text-lg font-medium flex-shrink-0">{{ t('dashboard.connectionList', '连接列表') }} ({{ filteredAndSortedConnections.length }})</h2>
|
||||
<div class="w-full sm:w-auto flex flex-wrap sm:flex-nowrap items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<!-- Search Input (Order adjusted for button placement) -->
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('dashboard.searchConnectionsPlaceholder', '搜索连接...')"
|
||||
class="h-8 px-3 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary w-full sm:w-48"
|
||||
/>
|
||||
<div class="flex items-center space-x-2"> <!-- Wrapper for existing controls -->
|
||||
<!-- Tag Filter Dropdown -->
|
||||
<select
|
||||
v-model="selectedTagId"
|
||||
class="h-8 px-2 py-1 text-sm border border-border rounded bg-background 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"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
<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>
|
||||
|
||||
<el-select v-model="selectedTagId" :disabled="isLoadingTags" clearable>
|
||||
<el-option :label="t('dashboard.filterTags.all', '所有标签')" :value="null" />
|
||||
<el-option
|
||||
v-for="tag in (tags as TagInfo[])"
|
||||
:key="tag.id"
|
||||
:label="tag.name"
|
||||
:value="tag.id"
|
||||
/>
|
||||
</el-select>
|
||||
<!-- Sort By Dropdown -->
|
||||
<select
|
||||
v-model="localSortBy"
|
||||
class="h-8 px-2 py-1 text-sm border border-border rounded bg-background 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="Sort connections by"
|
||||
>
|
||||
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||
{{ t(option.labelKey, option.value.replace('_', ' ')) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<el-select v-model="localSortBy">
|
||||
<el-option
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
:label="t(option.labelKey, option.value)"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-button plain @click="toggleSortOrder">
|
||||
<i :class="['fas', isAscending ? 'fa-arrow-up-a-z' : 'fa-arrow-down-z-a']"></i>
|
||||
</el-button>
|
||||
|
||||
<el-button plain @click="openConnectionsView">
|
||||
<i class="fas fa-layer-group mr-2"></i>
|
||||
{{ t('nav.connections') }}
|
||||
</el-button>
|
||||
<!-- 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"
|
||||
:aria-label="isAscending ? t('common.sortAscending') : t('common.sortDescending')"
|
||||
:title="isAscending ? t('common.sortAscending') : t('common.sortDescending')"
|
||||
>
|
||||
<i :class="['fas', isAscending ? 'fa-arrow-up-a-z' : 'fa-arrow-down-z-a', 'w-4 h-4']"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add Connection Button -->
|
||||
<button @click="openAddConnectionForm" title="Add Connection" class="h-8 w-8 bg-button 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 flex items-center justify-center flex-shrink-0 ml-2 sm:ml-0">
|
||||
<i class="fas fa-plus" style="color: white;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isLoadingConnections && filteredAndSortedConnections.length === 0" class="control-empty">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredAndSortedConnections.length > 0" class="grid gap-3">
|
||||
<el-card
|
||||
v-for="conn in filteredAndSortedConnections"
|
||||
:key="conn.id"
|
||||
shadow="hover"
|
||||
class="border border-border/50"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
conn.type === 'VNC' ? 'fa-plug' : conn.type === 'RDP' ? 'fa-desktop' : 'fa-server',
|
||||
'text-primary',
|
||||
]"
|
||||
></i>
|
||||
<span class="truncate">{{ conn.name || conn.host || t('connections.unnamedFallback', '未命名连接') }}</span>
|
||||
<el-tag size="small" effect="plain">{{ conn.type }}</el-tag>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-text-secondary">
|
||||
<div class="p-4">
|
||||
<!-- Use filteredAndSortedConnections and check its length -->
|
||||
<div v-if="isLoadingConnections && filteredAndSortedConnections.length === 0" class="text-center text-text-secondary">{{ t('common.loading') }}</div>
|
||||
<ul v-else-if="filteredAndSortedConnections.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 === 'VNC' ? 'fa-plug' : (conn.type === 'RDP' ? 'fa-desktop' : 'fa-server'), 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<span>{{ conn.name || conn.host || t('connections.unnamedFallback', '未命名连接') }}</span>
|
||||
</span>
|
||||
<span class="text-sm text-text-secondary block truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
|
||||
{{ conn.username }}@{{ conn.host }}:{{ conn.port }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-text-secondary">
|
||||
</span>
|
||||
<span class="text-xs text-text-alt block mb-1"> <!-- Added margin-bottom -->
|
||||
{{ t('dashboard.lastConnected', '上次连接:') }} {{ formatRelativeTime(conn.last_connected_at) }}
|
||||
</div>
|
||||
<div v-if="getTagNames(conn.tag_ids).length > 0" class="mt-3 flex flex-wrap gap-2">
|
||||
<el-tag
|
||||
</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"
|
||||
effect="plain"
|
||||
round
|
||||
size="small"
|
||||
class="px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground border border-border"
|
||||
>
|
||||
{{ tagName }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<el-button plain @click="openEditConnectionForm(conn)">
|
||||
<i class="fas fa-pen mr-2"></i>
|
||||
{{ t('connections.actions.edit') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="connectTo(conn)">
|
||||
<i class="fas fa-terminal mr-2"></i>
|
||||
<div class="flex space-x-2 flex-shrink-0">
|
||||
<button @click="openEditConnectionForm(conn)" class="px-3 py-1.5 bg-transparent text-foreground border border-border rounded-md shadow-sm hover:bg-border focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out text-sm font-medium">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<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"> <!-- Applied standard button style -->
|
||||
{{ t('connections.actions.connect') }}
|
||||
</el-button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Adjust no connections message based on filtering and search -->
|
||||
<div v-else-if="!isLoadingConnections && searchQuery && filteredAndSortedConnections.length === 0" class="text-center text-text-secondary">{{ t('dashboard.noConnectionsMatchSearch', '没有连接匹配搜索条件') }}</div>
|
||||
<div v-else-if="!isLoadingConnections && selectedTagId !== null && filteredAndSortedConnections.length === 0" 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>
|
||||
|
||||
<div v-else class="control-empty">
|
||||
<el-empty
|
||||
:description="
|
||||
searchQuery
|
||||
? t('dashboard.noConnectionsMatchSearch', '没有连接匹配搜索条件')
|
||||
: selectedTagId !== null
|
||||
? t('dashboard.noConnectionsWithTag', '该标签下没有连接记录')
|
||||
: t('dashboard.noConnections', '没有连接记录')
|
||||
"
|
||||
/>
|
||||
<!-- Recent Activity -->
|
||||
<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">
|
||||
<h2 class="text-lg font-medium">{{ t('dashboard.recentActivity', '最近活动') }}</h2>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">
|
||||
{{ t('dashboard.recentActivity', '最近活动') }}
|
||||
<div class="p-4">
|
||||
<!-- Loading State (Only show if loading AND no logs are displayed yet) -->
|
||||
<div v-if="isLoadingLogs && recentAuditLogs.length === 0" class="text-center text-text-secondary">{{ t('common.loading') }}</div>
|
||||
<ul v-else-if="recentAuditLogs.length > 0" class="space-y-3">
|
||||
<li v-for="log in recentAuditLogs" :key="log.id" class="p-3 bg-header/50 border border-border/50 rounded"> <!-- Applied audit log item style -->
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<span class="font-medium text-sm" :class="{ 'text-error': isFailedAction(log.action_type) }">{{ getActionTranslation(log.action_type) }}</span>
|
||||
<span class="text-xs text-text-alt flex-shrink-0 ml-2">{{ formatRelativeTime(log.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ t('auditLog.paginationInfo', { currentPage: 1, totalPages: 1, totalLogs }) }}
|
||||
</div>
|
||||
</div>
|
||||
<el-button plain @click="openAuditLogsView">
|
||||
{{ t('auditLog.title', '审计日志') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isLoadingLogs && recentAuditLogs.length === 0" class="control-empty">
|
||||
<el-skeleton :rows="5" animated />
|
||||
<p class="text-sm text-text-secondary break-words">{{ log.details }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="text-center text-text-secondary">{{ t('dashboard.noRecentActivity', '没有最近活动记录') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentAuditLogs.length > 0" class="grid gap-3">
|
||||
<el-card
|
||||
v-for="log in recentAuditLogs"
|
||||
:key="log.id"
|
||||
shadow="never"
|
||||
class="border border-border/50 bg-white/70"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-sm font-semibold"
|
||||
:class="isFailedAction(log.action_type) ? 'text-error' : 'text-foreground'"
|
||||
>
|
||||
{{ getActionTranslation(log.action_type) }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm leading-6 text-text-secondary break-words">
|
||||
{{ log.details }}
|
||||
</div>
|
||||
</div>
|
||||
<el-tag size="small" effect="plain">
|
||||
{{ formatRelativeTime(log.timestamp) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
<div class="px-4 py-3 border-t border-border text-right">
|
||||
<RouterLink :to="{ name: 'AuditLogs' }" class="text-sm text-link hover:text-link-hover hover:underline">
|
||||
{{ t('dashboard.viewFullAuditLog', '查看完整审计日志') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="control-empty">
|
||||
<el-empty :description="t('dashboard.noRecentActivity', '没有最近活动记录')" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Connection Form Modal -->
|
||||
<AddConnectionForm
|
||||
v-if="showAddEditConnectionForm"
|
||||
:connectionToEdit="connectionToEdit"
|
||||
@@ -480,5 +425,5 @@ const openAuditLogsView = () => {
|
||||
@connection-added="handleConnectionModified"
|
||||
@connection-updated="handleConnectionModified"
|
||||
/>
|
||||
</PageShell>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,86 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import VueRecaptcha from 'vue3-recaptcha2';
|
||||
import AuthPanelLayout from '../components/AuthPanelLayout.vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore);
|
||||
// 获取 loginRequires2FA 状态
|
||||
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore); // Get publicCaptchaConfig and hasPasskeysAvailable
|
||||
|
||||
// 表单数据
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
|
||||
const rememberMe = ref(false); // 记住我状态,默认为 false
|
||||
const captchaToken = ref<string | null>(null); // Store CAPTCHA token
|
||||
const captchaError = ref<string | null>(null); // Store CAPTCHA specific error
|
||||
const hcaptchaWidget = ref<InstanceType<typeof VueHcaptcha> | null>(null); // Ref for hCaptcha component instance
|
||||
const recaptchaWidget = ref<InstanceType<typeof VueRecaptcha> | null>(null); // 更新 Ref 类型以匹配新导入
|
||||
|
||||
const twoFactorToken = ref('');
|
||||
const rememberMe = ref(false);
|
||||
const captchaToken = ref<string | null>(null);
|
||||
const captchaError = ref<string | null>(null);
|
||||
const hcaptchaWidget = ref<InstanceType<typeof VueHcaptcha> | null>(null);
|
||||
const recaptchaWidget = ref<InstanceType<typeof VueRecaptcha> | null>(null);
|
||||
// --- reCAPTCHA v3 Initialization ---
|
||||
// const recaptchaInstance = useReCaptcha(); // 移除 v3 实例,因为我们将使用 v2 组件
|
||||
|
||||
|
||||
// --- CAPTCHA Event Handlers ---
|
||||
const handleCaptchaVerified = (token: string) => {
|
||||
// console.log('CAPTCHA verified, token:', token);
|
||||
captchaToken.value = token;
|
||||
captchaError.value = null;
|
||||
captchaError.value = null; // Clear error on successful verification
|
||||
};
|
||||
|
||||
const handleCaptchaExpired = () => {
|
||||
// console.log('CAPTCHA expired');
|
||||
captchaToken.value = null;
|
||||
};
|
||||
|
||||
const handleCaptchaError = (errorDetails: unknown) => {
|
||||
const handleCaptchaError = (errorDetails: any) => {
|
||||
console.error('CAPTCHA error:', errorDetails);
|
||||
captchaToken.value = null;
|
||||
captchaError.value = t('login.error.captchaLoadFailed');
|
||||
};
|
||||
|
||||
const resetCaptchaWidget = () => {
|
||||
// console.log('Resetting CAPTCHA widget...');
|
||||
captchaToken.value = null;
|
||||
// Reset hCaptcha if it exists
|
||||
hcaptchaWidget.value?.reset();
|
||||
// Reset reCAPTCHA v2 if it exists
|
||||
recaptchaWidget.value?.reset();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
captchaError.value = null;
|
||||
|
||||
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value && !captchaToken.value) {
|
||||
captchaError.value = t('login.error.captchaRequired');
|
||||
return;
|
||||
// 处理登录或 2FA 验证提交
|
||||
const handleSubmit = async () => {
|
||||
captchaError.value = null; // Clear previous CAPTCHA error
|
||||
|
||||
// --- CAPTCHA Execution & Check ---
|
||||
// --- CAPTCHA Check (v2/hCaptcha) ---
|
||||
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value) {
|
||||
// Check if token exists (obtained via component event for v2/hCaptcha)
|
||||
if (!captchaToken.value) {
|
||||
captchaError.value = t('login.error.captchaRequired');
|
||||
return; // Stop submission if CAPTCHA is required but not completed
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (loginRequires2FA.value) {
|
||||
// 如果需要 2FA,则调用 2FA 验证 action
|
||||
await authStore.verifyLogin2FA(twoFactorToken.value);
|
||||
} else {
|
||||
// 否则,调用常规登录 action,并传递 rememberMe 和 captchaToken 状态
|
||||
await authStore.login({
|
||||
...credentials,
|
||||
rememberMe: rememberMe.value,
|
||||
captchaToken: captchaToken.value ?? undefined,
|
||||
...credentials,
|
||||
rememberMe: rememberMe.value,
|
||||
captchaToken: captchaToken.value ?? undefined // Pass token or undefined if null
|
||||
});
|
||||
}
|
||||
// 成功后的重定向由 store action 处理
|
||||
// 失败会更新 error 状态并在模板中显示
|
||||
} finally {
|
||||
if (publicCaptchaConfig.value?.enabled) {
|
||||
resetCaptchaWidget();
|
||||
}
|
||||
}
|
||||
// Reset CAPTCHA after attempt (success or failure handled by store redirect/error display)
|
||||
if (publicCaptchaConfig.value?.enabled) {
|
||||
resetCaptchaWidget(); // Reset the widget for potential retry
|
||||
}
|
||||
} // <-- Correctly closing the try block here
|
||||
};
|
||||
|
||||
// Fetch CAPTCHA config and check passkey availability on component mount
|
||||
onMounted(async () => {
|
||||
// console.log('[LoginView] Component mounted, calling fetchCaptchaConfig and checkHasPasskeysConfigured...');
|
||||
authStore.fetchCaptchaConfig();
|
||||
// Check if passkeys are available for login (uses the new public endpoint)
|
||||
// Optionally pass username if needed: await authStore.checkHasPasskeysConfigured(credentials.username);
|
||||
await authStore.checkHasPasskeysConfigured();
|
||||
});
|
||||
|
||||
// --- Passkey Login Handler ---
|
||||
const handlePasskeyLogin = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
error.value = null; // Clear previous errors
|
||||
|
||||
// Prepare body for authentication options request
|
||||
// If username is provided, include it. Otherwise, send an empty object
|
||||
// to allow the backend to attempt discoverable credential authentication.
|
||||
const authOptionsBody = credentials.username ? { username: credentials.username } : {};
|
||||
|
||||
// Step 1: Get authentication options from the server
|
||||
const optionsResponse = await fetch('/api/v1/auth/passkey/authentication-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -91,136 +120,138 @@ const handlePasskeyLogin = async () => {
|
||||
const errData = await optionsResponse.json();
|
||||
throw new Error(errData.message || t('login.error.passkeyAuthOptionsFailed'));
|
||||
}
|
||||
|
||||
const authOptions = await optionsResponse.json();
|
||||
|
||||
// Step 2: Use WebAuthn API to authenticate
|
||||
const authenticationResult = await startAuthentication(authOptions);
|
||||
|
||||
// Step 3: Send authentication result to the server
|
||||
// Pass username if it was used to get options, otherwise pass null or rely on backend to extract from assertion
|
||||
// For simplicity, we'll pass the username if available, or an empty string if not.
|
||||
// The store action `loginWithPasskey` expects a string.
|
||||
// The backend should ideally identify the user from the assertion if an empty username is provided.
|
||||
await authStore.loginWithPasskey(credentials.username || '', authenticationResult);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Passkey login error:', err);
|
||||
error.value = err.message || t('login.error.passkeyAuthFailed');
|
||||
// Potentially reset CAPTCHA if it was involved, though typically not for passkey flows directly
|
||||
// if (publicCaptchaConfig.value?.enabled) {
|
||||
// resetCaptchaWidget();
|
||||
// }
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPanelLayout
|
||||
:title="t('login.title')"
|
||||
:subtitle="t('login.controlCenterSubtitle', '使用密码、双重验证或 Passkey 安全接入你的控制中心。')"
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent="handleSubmit">
|
||||
<div class="grid gap-5">
|
||||
<template v-if="!loginRequires2FA">
|
||||
<el-form-item :label="t('login.username')">
|
||||
<el-input v-model="credentials.username" :disabled="isLoading" size="large" clearable>
|
||||
<template #prefix>
|
||||
<i class="fas fa-user text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('login.password')">
|
||||
<el-input
|
||||
v-model="credentials.password"
|
||||
:disabled="isLoading"
|
||||
type="password"
|
||||
show-password
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-lock text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl border border-border bg-white/60 px-4 py-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-foreground">{{ t('login.rememberMe', '记住我') }}</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
{{ t('login.sessionHint', '在受信任设备上保留登录状态。') }}
|
||||
</div>
|
||||
</div>
|
||||
<el-checkbox v-model="rememberMe" :disabled="isLoading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form-item v-else :label="t('login.twoFactorPrompt')">
|
||||
<el-input
|
||||
v-model="twoFactorToken"
|
||||
:disabled="isLoading"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-shield-halved text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-card
|
||||
v-if="publicCaptchaConfig && publicCaptchaConfig.enabled && !loginRequires2FA"
|
||||
shadow="never"
|
||||
class="border border-border/70 bg-white/65"
|
||||
>
|
||||
<div class="mb-3 text-sm font-medium text-foreground">
|
||||
{{ t('login.captchaPrompt') }}
|
||||
</div>
|
||||
|
||||
<div v-if="publicCaptchaConfig?.provider === 'hcaptcha' && publicCaptchaConfig.hcaptchaSiteKey">
|
||||
<VueHcaptcha
|
||||
ref="hcaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.hcaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expired="handleCaptchaExpired"
|
||||
@error="handleCaptchaError"
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="publicCaptchaConfig?.provider === 'recaptcha' && publicCaptchaConfig.recaptchaSiteKey">
|
||||
<VueRecaptcha
|
||||
ref="recaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.recaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expire="handleCaptchaExpired"
|
||||
@fail="handleCaptchaError"
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="captchaError"
|
||||
class="mt-4"
|
||||
:title="captchaError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<el-button native-type="submit" type="primary" size="large" :loading="isLoading" class="w-full">
|
||||
{{ loginRequires2FA ? t('login.verifyButton') : t('login.loginButton') }}
|
||||
</el-button>
|
||||
|
||||
<template v-if="hasPasskeysAvailable && !loginRequires2FA">
|
||||
<el-divider>{{ t('login.passkeyDivider', '或使用安全密钥') }}</el-divider>
|
||||
|
||||
<el-button plain size="large" class="w-full" :loading="isLoading" @click="handlePasskeyLogin">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
{{ t('login.loginWithPasskey') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- Page Container -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-background p-4">
|
||||
<!-- Login Card -->
|
||||
<div class="flex w-full max-w-4xl rounded-xl shadow-2xl overflow-hidden bg-background border border-border/20">
|
||||
<!-- Left Panel (Brand) - Hidden on small screens -->
|
||||
<div class="hidden md:flex w-2/5 bg-gradient-to-br from-primary to-primary-dark flex-col items-center justify-center p-10 text-white relative">
|
||||
<!-- Subtle pattern or overlay could go here -->
|
||||
<div class="z-10 text-center">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-20 w-auto mb-5 mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-2">{{ t('projectName') }}</h1>
|
||||
<p class="text-base opacity-80">{{ t('slogan') }}</p> <!-- Example Slogan -->
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</AuthPanelLayout>
|
||||
|
||||
<!-- Right Panel (Login Form) -->
|
||||
<div class="w-full md:w-3/5 flex flex-col justify-center p-8 sm:p-12">
|
||||
<!-- Mobile Logo (optional) -->
|
||||
<div class="flex justify-center mb-6 md:hidden">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-16 w-auto">
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold mb-6 text-center text-foreground">{{ t('login.title') }}</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5"> <!-- Reduced space slightly -->
|
||||
<!-- Regular Login Fields -->
|
||||
<div v-if="!loginRequires2FA" class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.username') }}</label>
|
||||
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.password') }}</label>
|
||||
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
<!-- Remember Me Checkbox -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="rememberMe" v-model="rememberMe" :disabled="isLoading"
|
||||
class="w-4 h-4 mr-2 accent-primary rounded border-gray-300 focus:ring-primary disabled:cursor-not-allowed" />
|
||||
<label for="rememberMe" class="text-sm text-text-secondary cursor-pointer">{{ t('login.rememberMe', '记住我') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Token Input -->
|
||||
<div v-if="loginRequires2FA">
|
||||
<label for="twoFactorToken" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.twoFactorPrompt') }}</label>
|
||||
<input type="text" id="twoFactorToken" v-model="twoFactorToken" required :disabled="isLoading" pattern="\d{6}" title="请输入 6 位数字验证码"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
|
||||
<!-- CAPTCHA Area -->
|
||||
<!-- 恢复原始的 v-if 条件 -->
|
||||
<div v-if="publicCaptchaConfig && publicCaptchaConfig.enabled && !loginRequires2FA" class="space-y-2">
|
||||
<!-- 提示标签 -->
|
||||
<label class="block text-sm font-medium text-text-secondary">{{ t('login.captchaPrompt') }}</label>
|
||||
<!-- hCaptcha Component -->
|
||||
<div v-if="publicCaptchaConfig?.provider === 'hcaptcha' && publicCaptchaConfig.hcaptchaSiteKey">
|
||||
<VueHcaptcha
|
||||
ref="hcaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.hcaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expired="handleCaptchaExpired"
|
||||
@error="handleCaptchaError"
|
||||
theme="auto"
|
||||
></VueHcaptcha>
|
||||
</div>
|
||||
<!-- reCAPTCHA v2 Component -->
|
||||
<div v-else-if="publicCaptchaConfig?.provider === 'recaptcha' && publicCaptchaConfig.recaptchaSiteKey">
|
||||
<VueRecaptcha
|
||||
ref="recaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.recaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expire="handleCaptchaExpired"
|
||||
@fail="handleCaptchaError"
|
||||
theme="light"
|
||||
/>
|
||||
<!-- 注意: 根据 vue3-recaptcha2 文档调整事件名 @expire, @fail -->
|
||||
<!-- 注意: publicCaptchaConfig 需要包含 recaptchaSiteKey -->
|
||||
<!-- theme 可以是 'light' 或 'dark' -->
|
||||
</div>
|
||||
<!-- CAPTCHA Error Message -->
|
||||
<div v-if="captchaError" class="text-error text-sm">
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Login Error -->
|
||||
<div v-if="error" class="text-error text-center text-sm -mt-2 mb-2"> <!-- Adjusted margin -->
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-primary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
|
||||
</button>
|
||||
|
||||
<!-- Passkey Login Button -->
|
||||
<div v-if="hasPasskeysAvailable" class="mt-4 text-center">
|
||||
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-secondary text-black border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 flex items-center justify-center">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
<span>{{ isLoading ? t('login.loggingIn') : t('login.loginWithPasskey') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<template>
|
||||
<div class="p-4 bg-background text-foreground">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import NotificationSettings from '../components/NotificationSettings.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="$t('nav.notifications')"
|
||||
:subtitle="$t('notifications.controlCenterSubtitle', '集中配置 webhook、邮件与 Telegram 通知渠道,统一管理触发事件。')"
|
||||
>
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<NotificationSettings />
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useProxiesStore, ProxyInfo } from '../stores/proxies.store';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import ProxyList from '../components/ProxyList.vue';
|
||||
import AddProxyForm from '../components/AddProxyForm.vue';
|
||||
import AddProxyForm from '../components/AddProxyForm.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const proxiesStore = useProxiesStore();
|
||||
@@ -12,6 +11,7 @@ const proxiesStore = useProxiesStore();
|
||||
const showForm = ref(false);
|
||||
const editingProxy = ref<ProxyInfo | null>(null);
|
||||
|
||||
// 组件挂载时获取代理列表
|
||||
onMounted(() => {
|
||||
proxiesStore.fetchProxies();
|
||||
});
|
||||
@@ -42,18 +42,21 @@ const closeForm = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="t('proxies.title')"
|
||||
:subtitle="t('proxies.controlCenterSubtitle', '在统一的控制中心里管理代理入口、账号和转发策略。')"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="openAddForm">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
{{ t('proxies.addProxy') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="p-4 bg-background text-foreground"> <!-- Outer container with padding -->
|
||||
<div class="max-w-6xl mx-auto"> <!-- Inner container for max-width and centering -->
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4 pb-2 border-b border-border"> <!-- Title styling consistent with Notifications -->
|
||||
{{ t('proxies.title') }}
|
||||
</h2>
|
||||
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<button
|
||||
@click="openAddForm"
|
||||
v-if="!showForm"
|
||||
class="px-4 py-2 bg-button text-button-text rounded hover:bg-button-hover mb-4 inline-flex items-center text-sm font-medium"
|
||||
> <!-- Button styling consistent with Notifications -->
|
||||
{{ t('proxies.addProxy') }}
|
||||
</button>
|
||||
|
||||
<!-- 添加/编辑代理表单 -->
|
||||
<AddProxyForm
|
||||
v-if="showForm"
|
||||
:proxy-to-edit="editingProxy"
|
||||
@@ -62,7 +65,13 @@ const closeForm = () => {
|
||||
@proxy-updated="handleProxyUpdated"
|
||||
/>
|
||||
|
||||
<!-- 代理列表 -->
|
||||
<ProxyList @edit-proxy="handleEditRequest" />
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Remove scoped styles previously handled by Tailwind */
|
||||
/* .proxies-view, button, button:hover, button:disabled, .placeholder-form, .placeholder-list rules are removed */
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,98 @@
|
||||
<template>
|
||||
<div class="p-4 bg-background text-foreground min-h-screen"> <!-- Outer container -->
|
||||
<div class="max-w-7xl mx-auto"> <!-- Inner container for max-width -->
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="mb-6 flex space-x-1 bg-background z-10 py-2">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
:class="['px-4 py-2 text-sm font-medium rounded-md focus:outline-none transition-colors duration-150 ease-in-out',
|
||||
activeTab === tab.key ? 'bg-primary text-white' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground']"
|
||||
>
|
||||
<span class="relative flex items-center" :class="{'text-warning': tab.key === 'about' && isUpdateAvailable}">
|
||||
{{ tab.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error state (Show first if error exists) -->
|
||||
<div v-if="settingsError" class="p-4 mb-4 border-l-4 border-error bg-error/10 text-error rounded">
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
|
||||
<!-- Settings Content based on activeTab -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Security Tab Content -->
|
||||
<div v-if="activeTab === 'security'">
|
||||
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden">
|
||||
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">{{ $t('settings.category.security') }}</h2>
|
||||
<div class="p-6 space-y-6">
|
||||
<ChangePasswordForm />
|
||||
<hr class="border-border/50">
|
||||
<PasskeyManagement />
|
||||
<hr class="border-border/50">
|
||||
<TwoFactorAuthSettings />
|
||||
<hr class="border-border/50">
|
||||
<CaptchaSettingsForm />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Control Tab Content -->
|
||||
<div v-if="activeTab === 'ipControl'">
|
||||
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden mb-6">
|
||||
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">{{ $t('settings.ipWhitelist.title') }}</h2>
|
||||
<div class="p-6 space-y-6">
|
||||
<IpWhitelistSettings />
|
||||
</div>
|
||||
</div>
|
||||
<IpBlacklistSettings v-if="settings" />
|
||||
<div v-else-if="!settings && activeTab === 'ipControl'" class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace Tab Content -->
|
||||
<div v-if="activeTab === 'workspace'">
|
||||
<WorkspaceSettingsSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- System Tab Content -->
|
||||
<div v-if="activeTab === 'system'">
|
||||
<SystemSettingsSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Management Tab Content -->
|
||||
<div v-if="activeTab === 'dataManagement'">
|
||||
<DataManagementSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Tab Content -->
|
||||
<div v-if="activeTab === 'appearance'">
|
||||
<AppearanceSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- About Tab Content -->
|
||||
<div v-if="activeTab === 'about'">
|
||||
<AboutSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useAppearanceStore } from '../stores/appearance.store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useVersionCheck } from '../composables/settings/useVersionCheck';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import ChangePasswordForm from '../components/settings/ChangePasswordForm.vue';
|
||||
import PasskeyManagement from '../components/settings/PasskeyManagement.vue';
|
||||
import TwoFactorAuthSettings from '../components/settings/TwoFactorAuthSettings.vue';
|
||||
@@ -17,157 +105,44 @@ import SystemSettingsSection from '../components/settings/SystemSettingsSection.
|
||||
import DataManagementSection from '../components/settings/DataManagementSection.vue';
|
||||
import AppearanceSection from '../components/settings/AppearanceSection.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const appearanceStore = useAppearanceStore(); // 实例化外观 store
|
||||
const { t } = useI18n();
|
||||
const { isUpdateAvailable, checkLatestVersion } = useVersionCheck();
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'workspace', label: t('settings.tabs.workspace', '工作区'), icon: 'fas fa-sliders' },
|
||||
{ key: 'system', label: t('settings.tabs.system', '系统'), icon: 'fas fa-server' },
|
||||
{ key: 'security', label: t('settings.tabs.security', '安全'), icon: 'fas fa-shield-halved' },
|
||||
{ key: 'ipControl', label: t('settings.tabs.ipControl', 'IP 管控'), icon: 'fas fa-network-wired' },
|
||||
{ key: 'dataManagement', label: t('settings.tabs.dataManagement', '数据管理'), icon: 'fas fa-database' },
|
||||
{ key: 'appearance', label: t('settings.tabs.appearance', '外观'), icon: 'fas fa-palette' },
|
||||
{ key: 'about', label: t('settings.tabs.about', '关于'), icon: 'fas fa-circle-info' },
|
||||
// Define tabs for settings sections
|
||||
const tabs = ref([
|
||||
{ key: 'workspace', label: t('settings.tabs.workspace', '工作区') },
|
||||
{ key: 'system', label: t('settings.tabs.system', '系统') },
|
||||
{ key: 'security', label: t('settings.tabs.security', '安全') },
|
||||
{ key: 'ipControl', label: t('settings.tabs.ipControl', 'IP 管控') },
|
||||
{ key: 'dataManagement', label: t('settings.tabs.dataManagement', '数据管理') },
|
||||
{ key: 'appearance', label: t('settings.tabs.appearance', '外观') },
|
||||
{ key: 'about', label: t('settings.tabs.about', '关于') },
|
||||
]);
|
||||
const activeTab = ref(tabs.value[0].key);
|
||||
|
||||
const activeTab = ref('workspace');
|
||||
|
||||
// --- Reactive state from store ---
|
||||
// 使用 storeToRefs 获取响应式 getter,包括 language
|
||||
const {
|
||||
settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
language: storeLanguage,
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
const settingsStats = computed(() => [
|
||||
{
|
||||
label: t('settings.tabs.workspace', '工作区'),
|
||||
value: activeTab.value === 'workspace' ? 'Active' : 'Ready',
|
||||
meta: t('settings.workspace.title', '工作区与终端'),
|
||||
},
|
||||
{
|
||||
label: t('settings.tabs.security', '安全'),
|
||||
value: settings.value ? 'Live' : 'Pending',
|
||||
meta: t('settings.category.security', '安全设置'),
|
||||
},
|
||||
{
|
||||
label: t('settings.tabs.appearance', '外观'),
|
||||
value: isUpdateAvailable.value ? 'Update' : 'Stable',
|
||||
meta: isUpdateAvailable.value
|
||||
? t('settings.about.updateAvailable', '发现新版本')
|
||||
: t('settings.about.latestVersion', '已是最新版本'),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.loadCaptchaSettings();
|
||||
await checkLatestVersion();
|
||||
// await fetchIpBlacklist(); // REMOVED - Handled by useIpBlacklist.ts onMounted
|
||||
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
|
||||
await checkLatestVersion(); // 检查版本更新
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="t('nav.settings')"
|
||||
:subtitle="t('settings.controlCenterSubtitle', '将系统、安全、外观与工作区配置统一收束到一个控制中心。')"
|
||||
>
|
||||
<template #badge>
|
||||
<el-tag v-if="isUpdateAvailable" type="warning" effect="light" round>
|
||||
{{ t('settings.about.updateAvailable', '发现新版本') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template #stats>
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in settingsStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="settingsError"
|
||||
:title="settingsError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<el-card v-else shadow="never" class="control-panel">
|
||||
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||
<el-tab-pane v-for="tab in tabs" :key="tab.key" :name="tab.key">
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i :class="tab.icon"></i>
|
||||
<span>{{ tab.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div v-if="settingsLoading && !settings" class="control-empty">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 'security'" class="grid gap-4">
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('settings.category.security') }}</template>
|
||||
<div class="grid gap-6">
|
||||
<ChangePasswordForm />
|
||||
<el-divider />
|
||||
<PasskeyManagement />
|
||||
<el-divider />
|
||||
<TwoFactorAuthSettings />
|
||||
<el-divider />
|
||||
<CaptchaSettingsForm />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'ipControl'" class="grid gap-4">
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('settings.ipWhitelist.title') }}</template>
|
||||
<IpWhitelistSettings />
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('settings.ipBlacklist.title', 'IP 黑名单') }}</template>
|
||||
<IpBlacklistSettings />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card v-if="activeTab === 'workspace'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.workspace', '工作区') }}</template>
|
||||
<WorkspaceSettingsSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'system'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.system', '系统') }}</template>
|
||||
<SystemSettingsSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'dataManagement'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.dataManagement', '数据管理') }}</template>
|
||||
<DataManagementSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'appearance'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.appearance', '外观') }}</template>
|
||||
<AppearanceSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'about'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.about', '关于') }}</template>
|
||||
<AboutSection />
|
||||
</el-card>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,14 +1,98 @@
|
||||
<template>
|
||||
<!-- Page Container with Subtle Dot Background -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-background p-4 bg-[radial-gradient(theme(colors.border)_1px,transparent_1px)] bg-[size:16px_16px]">
|
||||
<!-- Setup Card -->
|
||||
<div class="flex w-full max-w-4xl rounded-xl shadow-2xl overflow-hidden bg-background border border-border/20">
|
||||
<!-- Left Panel (Brand) - Hidden on small screens -->
|
||||
<div class="hidden md:flex w-2/5 bg-gradient-to-br from-primary to-primary-dark flex-col items-center justify-center p-10 text-white relative">
|
||||
<!-- Subtle pattern or overlay could go here -->
|
||||
<div class="z-10 text-center">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-20 w-auto mb-5 mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-2">{{ $t('projectName') }}</h1>
|
||||
<p class="text-base opacity-80">{{ $t('setup.description') }}</p> <!-- Moved description here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel (Setup Form) -->
|
||||
<div class="w-full md:w-3/5 flex flex-col justify-center p-8 sm:p-12">
|
||||
<!-- Mobile Logo & Title (optional) -->
|
||||
<div class="flex flex-col items-center mb-6 md:hidden">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-16 w-auto mb-3">
|
||||
<h2 class="text-xl font-semibold text-foreground">{{ $t('setup.title') }}</h2>
|
||||
<p class="text-sm text-text-secondary mt-1">{{ $t('setup.description') }}</p>
|
||||
</div>
|
||||
<!-- Desktop Title (Subtle) -->
|
||||
<h2 class="text-2xl font-semibold mb-6 text-center text-foreground hidden md:block">{{ $t('setup.title') }}</h2>
|
||||
|
||||
<form @submit.prevent="handleSetup" class="space-y-5"> <!-- Reduced space slightly -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('setup.username') }}</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('setup.usernamePlaceholder')"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('setup.password') }}</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('setup.passwordPlaceholder')"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('setup.confirmPassword') }}</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('setup.confirmPasswordPlaceholder')"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-error bg-error/10 border border-error/20 px-4 py-2 rounded text-center text-sm"> <!-- Adjusted padding -->
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="successMessage" class="text-success bg-success/10 border border-success/20 px-4 py-2 rounded text-center text-sm"> <!-- Adjusted padding -->
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-primary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<span v-if="isLoading">{{ $t('setup.settingUp') }}</span>
|
||||
<span v-else>{{ $t('setup.submitButton') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AuthPanelLayout from '../components/AuthPanelLayout.vue';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useAuthStore } from '../stores/auth.store'; // *** 导入 Auth Store ***
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const authStore = useAuthStore(); // *** 获取 Auth Store 实例 ***
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -27,105 +111,44 @@ const handleSetup = async () => {
|
||||
}
|
||||
|
||||
if (!username.value || !password.value) {
|
||||
error.value = t('setup.error.fieldsRequired');
|
||||
return;
|
||||
error.value = t('setup.error.fieldsRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.post('/auth/setup', {
|
||||
// 确保调用正确的后端 API 端点
|
||||
await apiClient.post('/auth/setup', { // 使用 apiClient 并移除 base URL
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
confirmPassword: confirmPassword.value
|
||||
});
|
||||
|
||||
successMessage.value = t('setup.success');
|
||||
// *** 手动更新 needsSetup 状态 ***
|
||||
authStore.needsSetup = false;
|
||||
// *** 重置认证状态,因为设置完成后需要重新登录 ***
|
||||
authStore.isAuthenticated = false;
|
||||
authStore.user = null;
|
||||
// 禁用表单或按钮,防止重复提交
|
||||
isLoading.value = true; // Keep loading state to disable button
|
||||
// Redirect to login immediately after showing success message (removed setTimeout)
|
||||
// The success message will be briefly visible before navigation.
|
||||
router.push('/login');
|
||||
} catch (err: any) {
|
||||
console.error('Setup failed:', err);
|
||||
if (err.response?.data?.message) {
|
||||
// 尝试从后端响应中获取更具体的错误信息
|
||||
error.value = err.response.data.message;
|
||||
} else if (err.message) {
|
||||
error.value = err.message;
|
||||
error.value = err.message;
|
||||
} else {
|
||||
error.value = t('setup.error.generic');
|
||||
error.value = t('setup.error.generic');
|
||||
}
|
||||
isLoading.value = false;
|
||||
isLoading.value = false; // Re-enable button on error
|
||||
}
|
||||
// Removed finally block setting isLoading to false on success to keep button disabled
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPanelLayout
|
||||
:title="t('setup.title')"
|
||||
:subtitle="t('setup.description')"
|
||||
accent-label="Slate Bootstrap"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
:title="t('setup.bootstrapHint', '创建首个管理员账号后即可进入完整控制中心。')"
|
||||
/>
|
||||
|
||||
<el-form label-position="top" @submit.prevent="handleSetup">
|
||||
<div class="grid gap-5">
|
||||
<el-form-item :label="t('setup.username')">
|
||||
<el-input
|
||||
v-model="username"
|
||||
:disabled="isLoading"
|
||||
:placeholder="t('setup.usernamePlaceholder')"
|
||||
size="large"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-user text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('setup.password')">
|
||||
<el-input
|
||||
v-model="password"
|
||||
:disabled="isLoading"
|
||||
:placeholder="t('setup.passwordPlaceholder')"
|
||||
type="password"
|
||||
show-password
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-lock text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('setup.confirmPassword')">
|
||||
<el-input
|
||||
v-model="confirmPassword"
|
||||
:disabled="isLoading"
|
||||
:placeholder="t('setup.confirmPasswordPlaceholder')"
|
||||
type="password"
|
||||
show-password
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-check text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<el-alert v-if="successMessage" :title="successMessage" type="success" :closable="false" show-icon />
|
||||
|
||||
<el-button native-type="submit" type="primary" size="large" class="w-full" :loading="isLoading">
|
||||
{{ t('setup.submitButton') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</AuthPanelLayout>
|
||||
</template>
|
||||
<!-- Copied styles from LoginView.vue -->
|
||||
Reference in New Issue
Block a user