test: web

This commit is contained in:
tbphp
2025-07-01 22:47:25 +08:00
parent c447e3ad0b
commit dcb862b11a
50 changed files with 5059 additions and 689 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -35,7 +35,7 @@
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
native-type="submit"
class="login-button"
>
{{ loading ? '登录中...' : '登录' }}

View File

@@ -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>

View File

View 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>

View File

@@ -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>