Revert "feat(frontend): unify ui with slate control center"

This reverts commit 91aa6e83ca.
This commit is contained in:
yinjianm
2026-03-25 05:21:34 +08:00
parent b9a4917467
commit d8a99e55b8
20 changed files with 1638 additions and 2733 deletions
+186 -161
View File
@@ -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 }]">
&laquo;
</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 }]">
&raquo;
</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>
+229 -284
View File
@@ -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>
+179 -148
View File
@@ -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>
+24 -15
View File
@@ -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>
+117 -142
View File
@@ -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>
+105 -82
View File
@@ -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 -->