feat(frontend): unify ui with slate control center

add shared page and auth shells to standardize the main
application layout and authentication entry experience

refresh dashboard, settings, login, setup, notifications,
proxies, and audit logs with a consistent Element Plus based
control-center presentation

modernize workspace side panels and status monitor while
keeping the three-column layout, and fix the terminal hover
cursor to show a text caret
This commit is contained in:
yinjianm
2026-03-25 04:50:08 +08:00
parent 10df92ffa3
commit 91aa6e83ca
20 changed files with 2727 additions and 1632 deletions
+161 -186
View File
@@ -1,228 +1,203 @@
<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'; // Removed watch
import { useAuditLogStore } from '../stores/audit.store';
import { AuditLogEntry, AuditLogActionType } from '../types/server.types';
import { ref, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
// Removed lodash-es import
import { useAuditLogStore } from '../stores/audit.store';
import type { AuditLogEntry, AuditLogActionType } from '../types/server.types';
import PageShell from '../components/PageShell.vue';
const store = useAuditLogStore();
const { t } = useI18n();
// --- Filtering State ---
const searchTerm = ref('');
const selectedActionType = ref<AuditLogActionType | ''>(''); // Allow empty string for 'All'
const selectedActionType = ref<AuditLogActionType | ''>('');
// 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 Actions
'SSH_CONNECT_SUCCESS', 'SSH_CONNECT_FAILURE', 'SSH_SHELL_FAILURE',
// System/Error
'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_CONNECT_SUCCESS',
'SSH_CONNECT_FAILURE',
'SSH_SHELL_FAILURE',
'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 totalPages = computed(() => 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}`,
},
]);
// Function to apply filters and fetch logs
const applyFilters = () => {
// 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
});
store.fetchLogs({
page: 1,
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 => {
// Convert seconds to milliseconds for Date constructor
return new Date(timestamp * 1000).toLocaleString();
};
const formatTimestamp = (timestamp: number): string => new Date(timestamp * 1000).toLocaleString();
const translateActionType = (actionType: AuditLogActionType): string => {
// 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 key = `auditLog.actions.${actionType}`;
const translated = t(key);
return translated === key ? actionType : translated;
};
const formatDetails = (details: AuditLogEntry['details']): string => {
if (!details) return '';
if (typeof details === 'object' && details !== null) {
if (!details) return '-';
if (typeof details === 'object') {
if ('raw' in details && details.parseError) {
return `[Parse Error] Raw: ${details.raw}`;
return `[Parse Error] Raw: ${details.raw}`;
}
return JSON.stringify(details, null, 2); // Pretty print JSON
return JSON.stringify(details, null, 2);
}
return String(details); // Should ideally not happen if backend sends JSON string
return String(details);
};
const changePage = (page: number) => {
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
// Retain current filters when changing page
store.fetchLogs({
page: page,
searchTerm: searchTerm.value || undefined,
actionType: selectedActionType.value || undefined
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>
<style scoped>
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
</style>
<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>
@@ -746,4 +746,4 @@ const handleConnectAllFilteredConnections = async () => {
@saved="handleBatchEditSaved"
/>
</div>
</template>
</template>
+291 -236
View File
@@ -1,57 +1,51 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from '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 type { SortField, SortOrder } from '../stores/settings.store';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import type { ConnectionInfo } from '../stores/connections.store';
import { storeToRefs } from 'pinia';
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 { 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 type { ConnectionInfo } from '../stores/connections.store';
import type { SortField, SortOrder } from '../stores/settings.store';
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 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');
// 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' },
@@ -60,98 +54,114 @@ 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(); // +++ 获取搜索查询 +++
// 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
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;
return searchedConnections.sort((a, b) => {
let valA: any;
let valB: any;
let valA: string | number;
let valB: string | number;
switch (sortBy) {
case 'name':
valA = a.name || '';
valB = b.name || '';
return valA.localeCompare(valB) * factor;
return String(valA).localeCompare(String(valB)) * factor;
case 'type':
valA = a.type || '';
valB = b.type || '';
return valA.localeCompare(valB) * factor;
return String(valA).localeCompare(String(valB)) * factor;
case 'created_at':
valA = a.created_at ?? 0;
valB = b.created_at ?? 0;
return (valA - valB) * factor;
return (Number(valA) - Number(valB)) * factor;
case 'updated_at':
valA = a.updated_at ?? 0;
valB = b.updated_at ?? 0;
return (valA - valB) * factor;
return (Number(valA) - Number(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;
if (valA < valB) return -1 * factor;
return 1 * factor;
return Number(valA) < Number(valB) ? -1 * factor : 1 * factor;
default:
return 0;
}
});
});
const recentAuditLogs = computed(() => {
return auditLogs.value.slice(0, maxRecentLogs);
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', '工作台已接入'),
},
];
});
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("加载连接列表失败:", error);
console.error('Failed to load connections:', 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("加载审计日志失败:", error);
console.error('Failed to load audit logs:', error);
}
// +++ Fetch tags for filtering +++
try {
await tagsStore.fetchTags();
} catch (error) {
console.error("加载标签列表失败:", error);
console.error('Failed to load tags:', error);
}
});
@@ -160,13 +170,11 @@ 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'); // Use local state
const isAscending = computed(() => localSortOrder.value === 'asc');
// Watch for changes in local sort state and save to localStorage
watch(localSortBy, (newValue) => {
localStorage.setItem(LS_SORT_BY_KEY, newValue);
});
@@ -175,9 +183,7 @@ 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));
});
@@ -185,239 +191,288 @@ const dateFnsLocales: Record<string, Locale> = {
'en-US': enUS,
'zh-CN': zhCN,
'ja-JP': ja,
// 主语言回退
'en': enUS,
'zh': zhCN,
'ja': ja,
en: enUS,
zh: zhCN,
ja,
};
// 修正函数签名,接受 number | null | undefined
const formatRelativeTime = (timestampInSeconds: number | null | undefined): string => {
if (!timestampInSeconds) return t('connections.status.never');
try {
// 将秒级时间戳转换为毫秒级
const timestampInMs = timestampInSeconds * 1000;
// 检查转换后的值是否有效
if (isNaN(timestampInMs)) {
console.warn(`[Dashboard] Invalid timestamp received: ${timestampInSeconds}`);
return String(timestampInSeconds); // 返回原始值或错误提示
if (Number.isNaN(timestampInMs)) {
return String(timestampInSeconds);
}
const date = new Date(timestampInMs);
const currentI18nLocale = locale.value;
const langPart = currentI18nLocale.split('-')[0];
const targetLocale = dateFnsLocales[currentI18nLocale] || dateFnsLocales[langPart] || enUS;
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];
}
// 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: targetDateFnsLocale });
} catch (e) {
console.error("格式化日期失败:", e);
return String(timestampInSeconds); // 出错时返回原始字符串
return formatDistanceToNow(date, { addSuffix: true, locale: targetLocale });
} catch (error) {
console.error('Failed to format date:', error);
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 => !!name); // 过滤掉未找到的标签并确保类型为 string
.map((id) => allTags.find((tag) => tag.id === id)?.name)
.filter((name): name is string => Boolean(name));
};
// +++ 打开添加表单 +++
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(); // 重新加载连接列表
await connectionsStore.fetchConnections();
};
const openConnectionsView = () => {
router.push('/connections');
};
const openAuditLogsView = () => {
router.push('/audit-logs');
};
// --- 移除 selectTagFilter 函数 ---
</script>
<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>
<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="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:items-start">
<!-- 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"
>
<option :value="null">{{ t('dashboard.filterTags.all', '所有标签') }}</option>
<option v-if="isLoadingTags" disabled>{{ t('common.loading') }}</option>
<!-- 修正 v-for 循环中的类型 -->
<option v-for="tag in (tags as TagInfo[])" :key="tag.id" :value="tag.id">
{{ tag.name }}
</option>
</select>
<!-- Sort By Dropdown -->
<select
v-model="localSortBy"
class="h-8 px-2 py-1 text-sm border border-border rounded bg-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>
<!-- 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 #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 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}`">
</div>
</template>
<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
>
<template #prefix>
<i class="fas fa-search text-text-secondary"></i>
</template>
</el-input>
<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>
<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>
</div>
</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">
{{ conn.username }}@{{ conn.host }}:{{ conn.port }}
</span>
<span class="text-xs text-text-alt block mb-1"> <!-- Added margin-bottom -->
</div>
<div class="mt-2 text-xs text-text-secondary">
{{ t('dashboard.lastConnected', '上次连接:') }} {{ formatRelativeTime(conn.last_connected_at) }}
</span>
<div v-if="getTagNames(conn.tag_ids).length > 0" class="flex flex-wrap gap-1 mt-1">
<span
</div>
<div v-if="getTagNames(conn.tag_ids).length > 0" class="mt-3 flex flex-wrap gap-2">
<el-tag
v-for="tagName in getTagNames(conn.tag_ids)"
:key="tagName"
class="px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground border border-border"
effect="plain"
round
size="small"
>
{{ tagName }}
</span>
</el-tag>
</div>
</div>
<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 -->
<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>
{{ t('connections.actions.connect') }}
</button>
</el-button>
</div>
</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>
</el-card>
</div>
</div>
<!-- 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 v-else class="control-empty">
<el-empty
:description="
searchQuery
? t('dashboard.noConnectionsMatchSearch', '没有连接匹配搜索条件')
: selectedTagId !== null
? t('dashboard.noConnectionsWithTag', '该标签下没有连接记录')
: t('dashboard.noConnections', '没有连接记录')
"
/>
</div>
<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>
</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>
<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 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 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 />
</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>
<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"
@@ -425,5 +480,5 @@ const handleConnectionModified = async () => {
@connection-added="handleConnectionModified"
@connection-updated="handleConnectionModified"
/>
</div>
</PageShell>
</template>
+138 -169
View File
@@ -1,115 +1,86 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
import { reactive, ref, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { startAuthentication } from '@simplewebauthn/browser';
import { useAuthStore } from '../stores/auth.store';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
import VueRecaptcha from 'vue3-recaptcha2';
import AuthPanelLayout from '../components/AuthPanelLayout.vue';
import { useAuthStore } from '../stores/auth.store';
const { t } = useI18n();
const authStore = useAuthStore();
// 获取 loginRequires2FA 状态
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore); // Get publicCaptchaConfig and hasPasskeysAvailable
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore);
// 表单数据
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 类型以匹配新导入
// --- reCAPTCHA v3 Initialization ---
// const recaptchaInstance = useReCaptcha(); // 移除 v3 实例,因为我们将使用 v2 组件
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);
// --- CAPTCHA Event Handlers ---
const handleCaptchaVerified = (token: string) => {
// console.log('CAPTCHA verified, token:', token);
captchaToken.value = token;
captchaError.value = null; // Clear error on successful verification
captchaError.value = null;
};
const handleCaptchaExpired = () => {
// console.log('CAPTCHA expired');
captchaToken.value = null;
};
const handleCaptchaError = (errorDetails: any) => {
const handleCaptchaError = (errorDetails: unknown) => {
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();
};
// 处理登录或 2FA 验证提交
const handleSubmit = async () => {
captchaError.value = null; // Clear previous CAPTCHA error
captchaError.value = null;
// --- 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
}
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value && !captchaToken.value) {
captchaError.value = t('login.error.captchaRequired');
return;
}
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 // Pass token or undefined if null
...credentials,
rememberMe: rememberMe.value,
captchaToken: captchaToken.value ?? undefined,
});
}
// 成功后的重定向由 store action 处理
// 失败会更新 error 状态并在模板中显示
} finally {
// 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
if (publicCaptchaConfig.value?.enabled) {
resetCaptchaWidget();
}
}
};
// 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; // Clear previous errors
error.value = null;
// 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' },
@@ -120,138 +91,136 @@ 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>
<!-- 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>
<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>
<!-- 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>
<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>
<form @submit.prevent="handleSubmit" class="space-y-5"> <!-- Reduced space slightly -->
<!-- Regular Login Fields -->
<div v-if="!loginRequires2FA" class="space-y-6">
<div class="flex items-center justify-between gap-3 rounded-2xl border border-border bg-white/60 px-4 py-3">
<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 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>
<!-- 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>
<div v-if="publicCaptchaConfig?.provider === 'hcaptcha' && publicCaptchaConfig.hcaptchaSiteKey">
<VueHcaptcha
ref="hcaptchaWidget"
:sitekey="publicCaptchaConfig.hcaptchaSiteKey"
@verify="handleCaptchaVerified"
@expired="handleCaptchaExpired"
@error="handleCaptchaError"
theme="light"
/>
</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>
<div v-else-if="publicCaptchaConfig?.provider === 'recaptcha' && publicCaptchaConfig.recaptchaSiteKey">
<VueRecaptcha
ref="recaptchaWidget"
:sitekey="publicCaptchaConfig.recaptchaSiteKey"
@verify="handleCaptchaVerified"
@expire="handleCaptchaExpired"
@fail="handleCaptchaError"
theme="light"
/>
</div>
<!-- General Login Error -->
<div v-if="error" class="text-error text-center text-sm -mt-2 mb-2"> <!-- Adjusted margin -->
{{ error }}
</div>
<el-alert
v-if="captchaError"
class="mt-4"
:title="captchaError"
type="error"
:closable="false"
show-icon
/>
</el-card>
<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>
<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>
</div>
</div>
</div>
</el-form>
</AuthPanelLayout>
</template>
@@ -1,13 +1,15 @@
<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>
+14 -23
View File
@@ -2,8 +2,9 @@
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();
@@ -11,7 +12,6 @@ const proxiesStore = useProxiesStore();
const showForm = ref(false);
const editingProxy = ref<ProxyInfo | null>(null);
// 组件挂载时获取代理列表
onMounted(() => {
proxiesStore.fetchProxies();
});
@@ -42,21 +42,18 @@ const closeForm = () => {
</script>
<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>
<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 -->
<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') }}
</button>
</el-button>
</template>
<!-- 添加/编辑代理表单 -->
<el-card shadow="never" class="control-panel">
<AddProxyForm
v-if="showForm"
:proxy-to-edit="editingProxy"
@@ -65,13 +62,7 @@ const closeForm = () => {
@proxy-updated="handleProxyUpdated"
/>
<!-- 代理列表 -->
<ProxyList @edit-proxy="handleEditRequest" />
</div>
</div>
</el-card>
</PageShell>
</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>
+143 -118
View File
@@ -1,98 +1,10 @@
<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 { 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 { computed, onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useSettingsStore } from '../stores/settings.store';
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';
@@ -105,44 +17,157 @@ 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();
// 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 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' },
]);
const activeTab = ref(tabs.value[0].key);
// --- Reactive state from store ---
// 使用 storeToRefs 获取响应式 getter,包括 language
const activeTab = ref('workspace');
const {
settings,
isLoading: settingsLoading,
error: settingsError,
language: storeLanguage,
settings,
isLoading: settingsLoading,
error: settingsError,
} = 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 fetchIpBlacklist(); // REMOVED - Handled by useIpBlacklist.ts onMounted
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
await checkLatestVersion(); // 检查版本更新
await settingsStore.loadCaptchaSettings();
await checkLatestVersion();
});
</script>
<style scoped>
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
</style>
<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;
}
</style>
+82 -105
View File
@@ -1,98 +1,14 @@
<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 { useAuthStore } from '../stores/auth.store'; // *** 导入 Auth Store ***
import AuthPanelLayout from '../components/AuthPanelLayout.vue';
import apiClient from '../utils/apiClient';
import { useAuthStore } from '../stores/auth.store';
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore(); // *** 获取 Auth Store 实例 ***
const authStore = useAuthStore();
const username = ref('');
const password = ref('');
@@ -111,44 +27,105 @@ 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 {
// 确保调用正确的后端 API 端点
await apiClient.post('/auth/setup', { // 使用 apiClient 并移除 base URL
await apiClient.post('/auth/setup', {
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; // Re-enable button on error
isLoading.value = false;
}
// Removed finally block setting isLoading to false on success to keep button disabled
};
</script>
<!-- Copied styles from LoginView.vue -->
<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>