test: web
This commit is contained in:
@@ -1,53 +1,163 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<h1>仪表盘</h1>
|
||||
<div v-if="loading">加载中...</div>
|
||||
<div v-if="stats" class="stats-grid">
|
||||
<el-card>
|
||||
<el-statistic title="总请求数" :value="stats.total_requests" />
|
||||
</el-card>
|
||||
<el-card>
|
||||
<el-statistic title="成功请求数" :value="stats.success_requests" />
|
||||
</el-card>
|
||||
<el-card>
|
||||
<el-statistic title="成功率" :value="stats.success_rate" :formatter="rateFormatter" />
|
||||
</el-card>
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-header">
|
||||
<h1>仪表盘</h1>
|
||||
<p>查看您账户的总体使用情况和统计数据。</p>
|
||||
</div>
|
||||
|
||||
<LoadingSpinner v-if="loading && !stats.total_keys" />
|
||||
|
||||
<div v-else class="dashboard-content">
|
||||
<!-- 统计卡片 -->
|
||||
<StatsCards />
|
||||
|
||||
<!-- 快捷操作和筛选 -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="quick-actions-section">
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div class="filters-section">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<h3>筛选图表</h3>
|
||||
</template>
|
||||
<div class="filter-controls">
|
||||
<!-- 时间范围筛选 -->
|
||||
<el-select
|
||||
v-model="filters.timeRange"
|
||||
@change="onFilterChange"
|
||||
placeholder="选择时间范围"
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option label="过去 24 小时" value="24h" />
|
||||
<el-option label="过去 7 天" value="7d" />
|
||||
<el-option label="过去 30 天" value="30d" />
|
||||
</el-select>
|
||||
<!-- 分组筛选 -->
|
||||
<el-select
|
||||
v-model="filters.groupId"
|
||||
@change="onFilterChange"
|
||||
placeholder="选择分组"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="group in groupStore.groups"
|
||||
:key="group.id"
|
||||
:label="group.name"
|
||||
:value="group.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求统计图表 -->
|
||||
<RequestChart />
|
||||
</div>
|
||||
<el-card v-if="stats && stats.group_stats.length > 0" class="chart-card">
|
||||
<StatsChart :data="stats.group_stats" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useDashboardStore } from '@/stores/dashboardStore';
|
||||
import StatsChart from '@/components/StatsChart.vue';
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useDashboardStore } from "@/stores/dashboardStore";
|
||||
import { useGroupStore } from "@/stores/groupStore";
|
||||
import StatsCards from "@/components/business/dashboard/StatsCards.vue";
|
||||
import RequestChart from "@/components/business/dashboard/RequestChart.vue";
|
||||
import QuickActions from "@/components/business/dashboard/QuickActions.vue";
|
||||
import LoadingSpinner from "@/components/common/LoadingSpinner.vue";
|
||||
|
||||
const dashboardStore = useDashboardStore();
|
||||
const { stats, loading } = storeToRefs(dashboardStore);
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
const { stats, loading, filters } = storeToRefs(dashboardStore);
|
||||
|
||||
const onFilterChange = () => {
|
||||
dashboardStore.fetchDashboardData();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
dashboardStore.fetchStats();
|
||||
dashboardStore.startPolling();
|
||||
groupStore.fetchGroups(); // 获取分组列表用于筛选
|
||||
});
|
||||
|
||||
const rateFormatter = (rate: number) => {
|
||||
return `${(rate * 100).toFixed(2)}%`;
|
||||
};
|
||||
onUnmounted(() => {
|
||||
dashboardStore.stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
padding: 20px;
|
||||
.dashboard-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.stats-grid {
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.chart-card {
|
||||
margin-top: 20px;
|
||||
|
||||
.quick-actions-section {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.filters-section h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-controls .el-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,26 +1,313 @@
|
||||
<template>
|
||||
<div class="groups-view">
|
||||
<el-row :gutter="20" class="main-layout">
|
||||
<el-col :span="6" class="left-panel">
|
||||
<group-list />
|
||||
</el-col>
|
||||
<el-col :span="18" class="right-panel">
|
||||
<div class="config-section">
|
||||
<group-config-form />
|
||||
<!-- 左侧分组列表 -->
|
||||
<el-col :xs="24" :sm="8" :md="6" class="left-panel">
|
||||
<div class="left-content">
|
||||
<GroupList
|
||||
:selected-group-id="selectedGroupId"
|
||||
@select-group="handleSelectGroup"
|
||||
@add-group="handleAddGroup"
|
||||
@edit-group="handleEditGroup"
|
||||
@delete-group="handleDeleteGroup"
|
||||
/>
|
||||
</div>
|
||||
<div class="keys-section">
|
||||
<key-table />
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
<el-col :xs="24" :sm="16" :md="18" class="right-panel">
|
||||
<div v-if="selectedGroup" class="right-content">
|
||||
<!-- 分组信息卡片 -->
|
||||
<div class="group-info-card">
|
||||
<div class="card-header">
|
||||
<div class="group-title">
|
||||
<h3>{{ selectedGroup.name }}</h3>
|
||||
<el-tag
|
||||
:type="getChannelTypeColor(selectedGroup.channel_type)"
|
||||
size="large"
|
||||
>
|
||||
{{ getChannelTypeName(selectedGroup.channel_type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button @click="handleEditGroup(selectedGroup)">
|
||||
编辑分组
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleDeleteGroup(selectedGroup.id)"
|
||||
>
|
||||
删除分组
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="group-description">
|
||||
{{ selectedGroup.description || "暂无描述" }}
|
||||
</p>
|
||||
<div class="group-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">密钥总数</span>
|
||||
<span class="stat-value">{{ groupKeys.length }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">有效密钥</span>
|
||||
<span class="stat-value">{{ activeKeysCount }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总请求数</span>
|
||||
<span class="stat-value">{{ totalRequests }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-config" v-if="selectedGroup.config">
|
||||
<h4>配置信息</h4>
|
||||
<div
|
||||
class="config-item"
|
||||
v-if="selectedGroup.config.upstream_url"
|
||||
>
|
||||
<span class="config-label">上游地址:</span>
|
||||
<span class="config-value">{{
|
||||
selectedGroup.config.upstream_url
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="config-item" v-if="selectedGroup.config.timeout">
|
||||
<span class="config-label">超时时间:</span>
|
||||
<span class="config-value"
|
||||
>{{ selectedGroup.config.timeout }}ms</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密钥管理区域 -->
|
||||
<div class="keys-section">
|
||||
<div class="section-header">
|
||||
<h4>密钥管理</h4>
|
||||
</div>
|
||||
<KeyTable
|
||||
:keys="groupKeys"
|
||||
:loading="loading"
|
||||
:group-id="selectedGroupId"
|
||||
@add="handleAddKey"
|
||||
@edit="handleEditKey"
|
||||
@delete="handleDeleteKey"
|
||||
@toggle-status="handleToggleKeyStatus"
|
||||
@batch-operation="handleBatchOperation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未选择分组的提示 -->
|
||||
<div v-else class="empty-state">
|
||||
<EmptyState
|
||||
message="请选择一个分组来查看详情"
|
||||
description="在左侧选择一个分组,或者创建新的分组"
|
||||
>
|
||||
<el-button type="primary" @click="handleAddGroup">
|
||||
创建新分组
|
||||
</el-button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 分组表单对话框 -->
|
||||
<GroupForm
|
||||
v-model:visible="groupFormVisible"
|
||||
:group-data="currentGroup"
|
||||
@save="handleSaveGroup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GroupList from '@/components/GroupList.vue';
|
||||
import GroupConfigForm from '@/components/GroupConfigForm.vue';
|
||||
import KeyTable from '@/components/KeyTable.vue';
|
||||
import { ElRow, ElCol } from 'element-plus';
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ElRow, ElCol, ElButton, ElTag, ElMessage } from "element-plus";
|
||||
import GroupList from "@/components/business/groups/GroupList.vue";
|
||||
import KeyTable from "@/components/business/keys/KeyTable.vue";
|
||||
import GroupForm from "@/components/business/groups/GroupForm.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import { useGroupStore } from "@/stores/groupStore";
|
||||
import { useKeyStore } from "@/stores/keyStore";
|
||||
import type { Group, APIKey } from "@/types/models";
|
||||
|
||||
const groupStore = useGroupStore();
|
||||
const keyStore = useKeyStore();
|
||||
|
||||
const selectedGroupId = ref<number | undefined>();
|
||||
const groupFormVisible = ref(false);
|
||||
const currentGroup = ref<Group | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
// 计算属性
|
||||
const selectedGroup = computed(() => {
|
||||
if (!selectedGroupId.value) return null;
|
||||
return groupStore.groups.find((g) => g.id === selectedGroupId.value);
|
||||
});
|
||||
|
||||
const groupKeys = computed(() => {
|
||||
if (!selectedGroup.value) return [];
|
||||
return selectedGroup.value.api_keys || [];
|
||||
});
|
||||
|
||||
const activeKeysCount = computed(() => {
|
||||
return groupKeys.value.filter((key) => key.status === "active").length;
|
||||
});
|
||||
|
||||
const totalRequests = computed(() => {
|
||||
return groupKeys.value.reduce((total, key) => total + key.request_count, 0);
|
||||
});
|
||||
|
||||
// 工具函数
|
||||
const getChannelTypeColor = (channelType: string) => {
|
||||
switch (channelType) {
|
||||
case "openai":
|
||||
return "success";
|
||||
case "gemini":
|
||||
return "primary";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const getChannelTypeName = (channelType: string) => {
|
||||
switch (channelType) {
|
||||
case "openai":
|
||||
return "OpenAI";
|
||||
case "gemini":
|
||||
return "Gemini";
|
||||
default:
|
||||
return channelType;
|
||||
}
|
||||
};
|
||||
|
||||
// 事件处理函数
|
||||
const handleSelectGroup = (groupId: number) => {
|
||||
selectedGroupId.value = groupId;
|
||||
// 加载分组的密钥数据
|
||||
loadGroupKeys(groupId);
|
||||
};
|
||||
|
||||
const handleAddGroup = () => {
|
||||
currentGroup.value = null;
|
||||
groupFormVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEditGroup = (group: Group) => {
|
||||
currentGroup.value = group;
|
||||
groupFormVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (groupId: number) => {
|
||||
try {
|
||||
await groupStore.deleteGroup(groupId);
|
||||
ElMessage.success("分组删除成功");
|
||||
if (selectedGroupId.value === groupId) {
|
||||
selectedGroupId.value = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除分组失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveGroup = async (groupData: any) => {
|
||||
try {
|
||||
if (currentGroup.value) {
|
||||
await groupStore.updateGroup(currentGroup.value.id, groupData);
|
||||
ElMessage.success("分组更新成功");
|
||||
} else {
|
||||
await groupStore.createGroup(groupData);
|
||||
ElMessage.success("分组创建成功");
|
||||
}
|
||||
groupFormVisible.value = false;
|
||||
} catch (error) {
|
||||
ElMessage.error("保存分组失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddKey = () => {
|
||||
// KeyTable组件会处理添加密钥的逻辑
|
||||
};
|
||||
|
||||
const handleEditKey = (key: APIKey) => {
|
||||
// KeyTable组件会处理编辑密钥的逻辑
|
||||
console.log("Edit key:", key.id);
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (keyId: number) => {
|
||||
try {
|
||||
await keyStore.deleteKey(keyId.toString());
|
||||
ElMessage.success("密钥删除成功");
|
||||
// 重新加载当前分组的密钥
|
||||
if (selectedGroupId.value) {
|
||||
loadGroupKeys(selectedGroupId.value);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除密钥失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleKeyStatus = async (key: APIKey) => {
|
||||
try {
|
||||
const newStatus = key.status === "active" ? "inactive" : "active";
|
||||
await keyStore.updateKeyStatus(key.id, newStatus);
|
||||
ElMessage.success(`密钥已${newStatus === "active" ? "启用" : "禁用"}`);
|
||||
// 重新加载当前分组的密钥
|
||||
if (selectedGroupId.value) {
|
||||
loadGroupKeys(selectedGroupId.value);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchOperation = async (operation: string, keys: APIKey[]) => {
|
||||
try {
|
||||
switch (operation) {
|
||||
case "enable":
|
||||
await keyStore.batchUpdateStatus(
|
||||
keys.map((k) => k.id),
|
||||
"active"
|
||||
);
|
||||
ElMessage.success(`批量启用 ${keys.length} 个密钥成功`);
|
||||
break;
|
||||
case "disable":
|
||||
await keyStore.batchUpdateStatus(
|
||||
keys.map((k) => k.id),
|
||||
"inactive"
|
||||
);
|
||||
ElMessage.success(`批量禁用 ${keys.length} 个密钥成功`);
|
||||
break;
|
||||
case "delete":
|
||||
await keyStore.batchDelete(keys.map((k) => k.id));
|
||||
ElMessage.success(`批量删除 ${keys.length} 个密钥成功`);
|
||||
break;
|
||||
}
|
||||
// 重新加载当前分组的密钥
|
||||
if (selectedGroupId.value) {
|
||||
loadGroupKeys(selectedGroupId.value);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("批量操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadGroupKeys = async (groupId: number) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await groupStore.fetchGroupKeys(groupId);
|
||||
} catch (error) {
|
||||
console.error("加载分组密钥失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 加载分组列表
|
||||
groupStore.fetchGroups();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -30,24 +317,209 @@ import { ElRow, ElCol } from 'element-plus';
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-layout, .left-panel, .right-panel {
|
||||
.main-layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.right-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.config-section, .keys-section {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
.group-info-card {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-title h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.group-description {
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background-color: var(--el-bg-color-page);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.group-config {
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.group-config h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.config-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.config-value {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.keys-section {
|
||||
flex: 1;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.groups-view {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
height: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.left-content {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.group-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -35,7 +35,7 @@
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
native-type="submit"
|
||||
class="login-button"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
|
@@ -1,63 +1,481 @@
|
||||
<template>
|
||||
<div class="logs-page">
|
||||
<h1>日志查询</h1>
|
||||
<LogFilter />
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="timestamp" label="时间" width="180" :formatter="formatDate" />
|
||||
<el-table-column prop="group_id" label="分组ID" width="100" />
|
||||
<el-table-column prop="key_id" label="密钥ID" width="100" />
|
||||
<el-table-column prop="source_ip" label="源IP" width="150" />
|
||||
<el-table-column prop="status_code" label="状态码" width="100" />
|
||||
<el-table-column prop="request_path" label="请求路径" />
|
||||
<el-table-column prop="request_body_snippet" label="请求体片段" />
|
||||
</el-table>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.size"
|
||||
:current-page="pagination.page"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
class="pagination-container"
|
||||
/>
|
||||
<div class="logs-view">
|
||||
<div class="page-header">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">请求日志</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
查看和管// 筛选器 const filters = reactive({ dateRange: [] as [Date,
|
||||
Date] | [], groupId: undefined as number | undefined, statusCode:
|
||||
undefined as number | undefined, keyword: '', });志记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<div class="filters-card">
|
||||
<el-card shadow="never">
|
||||
<div class="filters-grid">
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">时间范围</label>
|
||||
<DateRangePicker v-model="filters.dateRange" />
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">分组</label>
|
||||
<el-select
|
||||
v-model="filters.groupId"
|
||||
placeholder="选择分组"
|
||||
clearable
|
||||
@clear="filters.groupId = ''"
|
||||
>
|
||||
<el-option
|
||||
v-for="group in groupStore.groups"
|
||||
:key="group.id"
|
||||
:label="group.name"
|
||||
:value="group.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">状态码</label>
|
||||
<el-select
|
||||
v-model="filters.statusCode"
|
||||
placeholder="选择状态码"
|
||||
clearable
|
||||
@clear="filters.statusCode = ''"
|
||||
>
|
||||
<el-option label="200 - 成功" :value="200" />
|
||||
<el-option label="400 - 请求错误" :value="400" />
|
||||
<el-option label="401 - 未授权" :value="401" />
|
||||
<el-option label="429 - 限流" :value="429" />
|
||||
<el-option label="500 - 服务器错误" :value="500" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">搜索</label>
|
||||
<SearchInput
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索IP地址或请求路径..."
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" @click="applyFilters">应用筛选</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<div class="logs-table">
|
||||
<DataTable
|
||||
:data="filteredLogs"
|
||||
:columns="tableColumns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
>
|
||||
<!-- 时间戳列 -->
|
||||
<template #timestamp="{ row }">
|
||||
<div class="timestamp-cell">
|
||||
{{ formatTimestamp(row.timestamp) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态码列 -->
|
||||
<template #status_code="{ row }">
|
||||
<el-tag :type="getStatusType(row.status_code)">
|
||||
{{ row.status_code }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<!-- 分组列 -->
|
||||
<template #group="{ row }">
|
||||
<span class="group-name">
|
||||
{{ getGroupName(row.group_id) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 请求路径列 -->
|
||||
<template #request_path="{ row }">
|
||||
<el-tooltip placement="top" :content="row.request_path">
|
||||
<span class="request-path">
|
||||
{{ truncateText(row.request_path, 50) }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
|
||||
<!-- IP地址列 -->
|
||||
<template #source_ip="{ row }">
|
||||
<span class="ip-address">{{ row.source_ip }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 请求体预览列 -->
|
||||
<template #request_body="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
@click="showRequestBody(row)"
|
||||
v-if="row.request_body_snippet"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<span v-else class="text-gray-400">无内容</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 请求详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="请求详情" width="800px">
|
||||
<div v-if="selectedLog" class="request-detail">
|
||||
<div class="detail-section">
|
||||
<h4>基本信息</h4>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="时间">
|
||||
{{ formatTimestamp(selectedLog.timestamp) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态码">
|
||||
<el-tag :type="getStatusType(selectedLog.status_code)">
|
||||
{{ selectedLog.status_code }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">
|
||||
{{ selectedLog.source_ip }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分组">
|
||||
{{ getGroupName(selectedLog.group_id) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求路径" :span="2">
|
||||
<code>{{ selectedLog.request_path }}</code>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="selectedLog.request_body_snippet">
|
||||
<h4>请求内容</h4>
|
||||
<div class="request-body-container">
|
||||
<pre class="request-body">{{
|
||||
selectedLog.request_body_snippet
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useLogStore } from '@/stores/logStore';
|
||||
import LogFilter from '@/components/LogFilter.vue';
|
||||
import type { RequestLog } from '@/types/models';
|
||||
import { ref, reactive, computed, onMounted } from "vue";
|
||||
import {
|
||||
ElCard,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElButton,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
ElDialog,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElMessage,
|
||||
} from "element-plus";
|
||||
import DataTable from "@/components/common/DataTable.vue";
|
||||
import SearchInput from "@/components/common/SearchInput.vue";
|
||||
import DateRangePicker from "@/components/common/DateRangePicker.vue";
|
||||
import { useGroupStore } from "@/stores/groupStore";
|
||||
import type { RequestLog } from "@/types/models";
|
||||
|
||||
const logStore = useLogStore();
|
||||
const { logs, loading, pagination } = storeToRefs(logStore);
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
onMounted(() => {
|
||||
logStore.fetchLogs();
|
||||
const loading = ref(false);
|
||||
const detailDialogVisible = ref(false);
|
||||
const selectedLog = ref<RequestLog | null>(null);
|
||||
|
||||
// 筛选器
|
||||
const filters = reactive({
|
||||
dateRange: null as [Date, Date] | null,
|
||||
groupId: "" as string | number | "",
|
||||
statusCode: "" as string | number | "",
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 表格列配置
|
||||
const tableColumns = [
|
||||
{ prop: "timestamp", label: "时间", width: 180 },
|
||||
{ prop: "status_code", label: "状态码", width: 100 },
|
||||
{ prop: "group", label: "分组", width: 120 },
|
||||
{ prop: "source_ip", label: "IP地址", width: 140 },
|
||||
{ prop: "request_path", label: "请求路径", minWidth: 200 },
|
||||
{ prop: "request_body", label: "请求内容", width: 120 },
|
||||
];
|
||||
|
||||
// 模拟日志数据(实际应该从 logStore 获取)
|
||||
const mockLogs: RequestLog[] = [
|
||||
{
|
||||
id: "1",
|
||||
timestamp: new Date().toISOString(),
|
||||
group_id: 1,
|
||||
key_id: 1,
|
||||
source_ip: "192.168.1.100",
|
||||
status_code: 200,
|
||||
request_path: "/v1/chat/completions",
|
||||
request_body_snippet:
|
||||
'{"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello"}]}',
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
timestamp: new Date(Date.now() - 60000).toISOString(),
|
||||
group_id: 1,
|
||||
key_id: 2,
|
||||
source_ip: "192.168.1.101",
|
||||
status_code: 429,
|
||||
request_path: "/v1/chat/completions",
|
||||
request_body_snippet:
|
||||
'{"model": "gpt-4", "messages": [{"role": "user", "content": "Hi there"}]}',
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
timestamp: new Date(Date.now() - 120000).toISOString(),
|
||||
group_id: 2,
|
||||
key_id: 3,
|
||||
source_ip: "192.168.1.102",
|
||||
status_code: 401,
|
||||
request_path: "/v1/models",
|
||||
request_body_snippet: "",
|
||||
},
|
||||
];
|
||||
|
||||
// 计算属性
|
||||
const filteredLogs = computed(() => {
|
||||
let logs = mockLogs;
|
||||
|
||||
// 应用筛选器
|
||||
if (filters.groupId) {
|
||||
logs = logs.filter((log) => log.group_id === filters.groupId);
|
||||
}
|
||||
|
||||
if (filters.statusCode) {
|
||||
logs = logs.filter((log) => log.status_code === filters.statusCode);
|
||||
}
|
||||
|
||||
if (filters.keyword) {
|
||||
const keyword = filters.keyword.toLowerCase();
|
||||
logs = logs.filter(
|
||||
(log) =>
|
||||
log.source_ip.toLowerCase().includes(keyword) ||
|
||||
log.request_path.toLowerCase().includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.dateRange) {
|
||||
const [start, end] = filters.dateRange;
|
||||
logs = logs.filter((log) => {
|
||||
const logTime = new Date(log.timestamp);
|
||||
return logTime >= start && logTime <= end;
|
||||
});
|
||||
}
|
||||
|
||||
// 更新分页总数
|
||||
pagination.total = logs.length;
|
||||
|
||||
// 应用分页
|
||||
const startIndex = (pagination.currentPage - 1) * pagination.pageSize;
|
||||
const endIndex = startIndex + pagination.pageSize;
|
||||
|
||||
return logs.slice(startIndex, endIndex);
|
||||
});
|
||||
|
||||
// 方法
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
const getStatusType = (statusCode: number) => {
|
||||
if (statusCode >= 200 && statusCode < 300) return "success";
|
||||
if (statusCode >= 400 && statusCode < 500) return "warning";
|
||||
if (statusCode >= 500) return "danger";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const getGroupName = (groupId: number) => {
|
||||
const group = groupStore.groups.find((g) => g.id === groupId);
|
||||
return group?.name || `分组 ${groupId}`;
|
||||
};
|
||||
|
||||
const truncateText = (text: string, maxLength: number) => {
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + "..." : text;
|
||||
};
|
||||
|
||||
const showRequestBody = (log: RequestLog) => {
|
||||
selectedLog.value = log;
|
||||
detailDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.currentPage = 1;
|
||||
// 搜索逻辑已在 computed 中处理
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
pagination.currentPage = 1;
|
||||
// 筛选逻辑已在 computed 中处理
|
||||
ElMessage.success("筛选条件已应用");
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.dateRange = null;
|
||||
filters.groupId = "";
|
||||
filters.statusCode = "";
|
||||
filters.keyword = "";
|
||||
pagination.currentPage = 1;
|
||||
ElMessage.success("筛选条件已重置");
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
logStore.setPage(page);
|
||||
pagination.currentPage = page;
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
logStore.setSize(size);
|
||||
pagination.pageSize = size;
|
||||
pagination.currentPage = 1;
|
||||
};
|
||||
|
||||
const formatDate = (_row: RequestLog, _column: any, cellValue: string) => {
|
||||
return new Date(cellValue).toLocaleString();
|
||||
};
|
||||
onMounted(() => {
|
||||
// 加载分组数据用于筛选
|
||||
groupStore.fetchGroups();
|
||||
|
||||
// 加载日志数据
|
||||
// TODO: 实现真实的日志加载逻辑
|
||||
// logStore.fetchLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
padding: 20px;
|
||||
.logs-view {
|
||||
padding: 24px;
|
||||
background-color: var(--el-bg-color-page);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.timestamp-cell {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.request-path {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.ip-address {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.request-detail {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.request-body-container {
|
||||
background-color: var(--el-bg-color-page);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.request-body {
|
||||
margin: 0;
|
||||
font-family: "Monaco", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.logs-view {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.filter-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
0
web/src/views/Logs_new.vue
Normal file
0
web/src/views/Logs_new.vue
Normal file
63
web/src/views/Logs_old.vue
Normal file
63
web/src/views/Logs_old.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="logs-page">
|
||||
<h1>日志查询</h1>
|
||||
<LogFilter />
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="timestamp" label="时间" width="180" :formatter="formatDate" />
|
||||
<el-table-column prop="group_id" label="分组ID" width="100" />
|
||||
<el-table-column prop="key_id" label="密钥ID" width="100" />
|
||||
<el-table-column prop="source_ip" label="源IP" width="150" />
|
||||
<el-table-column prop="status_code" label="状态码" width="100" />
|
||||
<el-table-column prop="request_path" label="请求路径" />
|
||||
<el-table-column prop="request_body_snippet" label="请求体片段" />
|
||||
</el-table>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, sizes"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.size"
|
||||
:current-page="pagination.page"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
class="pagination-container"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useLogStore } from '@/stores/logStore';
|
||||
import LogFilter from '@/components/LogFilter.vue';
|
||||
import type { RequestLog } from '@/types/models';
|
||||
|
||||
const logStore = useLogStore();
|
||||
const { logs, loading, pagination } = storeToRefs(logStore);
|
||||
|
||||
onMounted(() => {
|
||||
logStore.fetchLogs();
|
||||
});
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
logStore.setPage(page);
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
logStore.setSize(size);
|
||||
};
|
||||
|
||||
const formatDate = (_row: RequestLog, _column: any, cellValue: string) => {
|
||||
return new Date(cellValue).toLocaleString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
@@ -1,44 +1,113 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<h1>Settings</h1>
|
||||
<el-form :model="form" label-width="200px">
|
||||
<el-form-item v-for="setting in settings" :key="setting.key" :label="setting.key">
|
||||
<el-input v-model="form[setting.key]"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSettings">Save</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="flex h-full bg-gray-100">
|
||||
<!-- Left Navigation -->
|
||||
<aside class="w-64 bg-white p-4 shadow-md">
|
||||
<h2 class="text-xl font-bold mb-6">设置</h2>
|
||||
<nav class="space-y-2">
|
||||
<a
|
||||
v-for="item in navigation"
|
||||
:key="item.name"
|
||||
@click="activeTab = item.component"
|
||||
:class="[
|
||||
'block px-4 py-2 rounded-md cursor-pointer',
|
||||
activeTab === item.component
|
||||
? 'bg-indigo-500 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-200',
|
||||
]"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Right Content -->
|
||||
<main class="flex-1 p-8 overflow-y-auto">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="activeComponent" />
|
||||
</transition>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-8 flex justify-end space-x-4">
|
||||
<button
|
||||
@click="handleReset"
|
||||
class="px-6 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
@click="handleSave"
|
||||
:disabled="loading"
|
||||
class="px-6 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { computed, onMounted, shallowRef } from 'vue';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { Setting } from '@/types/models';
|
||||
import SystemSettings from '@/components/business/settings/SystemSettings.vue';
|
||||
import GroupSettings from '@/components/business/settings/GroupSettings.vue';
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
const { settings, loading } = storeToRefs(settingStore);
|
||||
const { loading } = storeToRefs(settingStore);
|
||||
|
||||
const form = ref<Record<string, string>>({});
|
||||
const navigation = [
|
||||
{ name: '系统设置', component: 'SystemSettings' },
|
||||
{ name: '认证设置', component: 'AuthSettings' },
|
||||
{ name: '性能设置', component: 'PerformanceSettings' },
|
||||
{ name: '日志设置', component: 'LogSettings' },
|
||||
{ name: '分组设置', component: 'GroupSettings' },
|
||||
];
|
||||
|
||||
const components: Record<string, any> = {
|
||||
SystemSettings,
|
||||
GroupSettings,
|
||||
// Placeholder for other setting components
|
||||
AuthSettings: { template: '<div class="p-6 bg-white shadow-md rounded-lg"><h3 class="text-lg font-semibold">认证设置</h3><p class="text-gray-500 mt-4">此部分功能待开发。</p></div>' },
|
||||
PerformanceSettings: { template: '<div class="p-6 bg-white shadow-md rounded-lg"><h3 class="text-lg font-semibold">性能设置</h3><p class="text-gray-500 mt-4">此部分功能待开发。</p></div>' },
|
||||
LogSettings: { template: '<div class="p-6 bg-white shadow-md rounded-lg"><h3 class="text-lg font-semibold">日志设置</h3><p class="text-gray-500 mt-4">此部分功能待开发。</p></div>' },
|
||||
};
|
||||
|
||||
const activeTab = shallowRef('SystemSettings');
|
||||
|
||||
const activeComponent = computed(() => components[activeTab.value]);
|
||||
|
||||
onMounted(() => {
|
||||
settingStore.fetchSettings();
|
||||
// Fetch initial data for the default tab
|
||||
settingStore.fetchSystemSettings();
|
||||
});
|
||||
|
||||
watch(settings, (newSettings) => {
|
||||
form.value = newSettings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
const saveSettings = () => {
|
||||
const settingsToUpdate: Setting[] = Object.entries(form.value).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
settingStore.updateSettings(settingsToUpdate);
|
||||
const handleSave = () => {
|
||||
// This logic would need to be more sophisticated if handling multiple setting types
|
||||
if (activeTab.value === 'SystemSettings') {
|
||||
settingStore.saveSystemSettings();
|
||||
}
|
||||
// Add logic for other setting types here
|
||||
};
|
||||
</script>
|
||||
|
||||
const handleReset = () => {
|
||||
if (activeTab.value === 'SystemSettings') {
|
||||
settingStore.resetSystemSettings();
|
||||
}
|
||||
// Add logic for other setting types here
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user