feat: 重构前端

This commit is contained in:
tbphp
2025-07-02 17:15:10 +08:00
parent 6a96c4464b
commit f15d0dd8da
102 changed files with 5392 additions and 10344 deletions

View File

@@ -0,0 +1,5 @@
<template>
<n-card title="基础统计">
<div>基础统计</div>
</n-card>
</template>

View File

@@ -1,99 +0,0 @@
<template>
<div class="group-config-form">
<el-card v-if="groupStore.selectedGroupDetails" shadow="never">
<template #header>
<div class="card-header">
<span>分组配置</span>
<el-button type="primary" @click="handleSave" :loading="isSaving"
>保存</el-button
>
</div>
</template>
<el-form :model="formData" label-width="120px" ref="formRef">
<el-form-item
label="分组名称"
prop="name"
:rules="[{ required: true, message: '请输入分组名称' }]"
>
<el-input v-model="formData.name"></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="formData.description" type="textarea"></el-input>
</el-form-item>
<el-form-item label="设为默认" prop="is_default">
<el-switch v-model="formData.is_default"></el-switch>
</el-form-item>
</el-form>
</el-card>
<el-empty v-else description="请先从左侧选择一个分组"></el-empty>
</div>
</template>
<script setup lang="ts">
import { ref, watch, reactive } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import { updateGroup } from "@/api/groups";
import {
ElCard,
ElForm,
ElFormItem,
ElInput,
ElButton,
ElSwitch,
ElMessage,
ElEmpty,
} from "element-plus";
import type { FormInstance } from "element-plus";
const groupStore = useGroupStore();
const formRef = ref<FormInstance>();
const isSaving = ref(false);
const formData = reactive({
name: "",
description: "",
is_default: false,
});
watch(
() => groupStore.selectedGroupDetails,
(newGroup) => {
if (newGroup) {
formData.name = newGroup.name;
formData.description = newGroup.description;
formData.is_default = newGroup.is_default || false;
}
},
{ immediate: true, deep: true }
);
const handleSave = async () => {
if (!formRef.value || !groupStore.selectedGroupId) return;
try {
await formRef.value.validate();
isSaving.value = true;
await updateGroup(groupStore.selectedGroupId.toString(), {
name: formData.name,
description: formData.description,
is_default: formData.is_default,
});
ElMessage.success("保存成功");
// 刷新列表以获取最新数据
await groupStore.fetchGroups();
} catch (error) {
console.error("Failed to save group config:", error);
ElMessage.error("保存失败,请查看控制台");
} finally {
isSaving.value = false;
}
};
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -1,61 +0,0 @@
<template>
<div class="group-list" v-loading="groupStore.isLoading">
<el-menu
:default-active="groupStore.selectedGroupId?.toString() || undefined"
@select="handleSelect"
>
<el-menu-item
v-for="group in groupStore.groups"
:key="group.id"
:index="group.id.toString()"
>
<template #title>
<span>{{ group.name }}</span>
<el-tag v-if="group.is_default" size="small" style="margin-left: 8px"
>默认</el-tag
>
</template>
</el-menu-item>
</el-menu>
<div
v-if="!groupStore.isLoading && groupStore.groups.length === 0"
class="empty-state"
>
暂无分组
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import { ElMenu, ElMenuItem, ElTag, vLoading } from "element-plus";
const groupStore = useGroupStore();
onMounted(() => {
// 组件挂载时获取分组数据
if (groupStore.groups.length === 0) {
groupStore.fetchGroups();
}
});
const handleSelect = (index: string) => {
groupStore.selectGroup(Number(index));
};
</script>
<style scoped>
.group-list {
border-right: 1px solid var(--el-border-color);
height: 100%;
}
.el-menu {
border-right: none;
}
.empty-state {
text-align: center;
color: var(--el-text-color-secondary);
padding-top: 20px;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<n-layout>
<n-layout-header class="flex items-center">
<h1 class="layout-header-title">T.COM</h1>
<nav-bar />
<logout />
</n-layout-header>
<n-layout-content class="layout-content" content-style="padding: 24px;">
<router-view />
</n-layout-content>
</n-layout>
</template>
<script setup lang="ts">
import NavBar from '@/components/NavBar.vue'
import Logout from '@/components/Logout.vue'
</script>
<style scoped>
.layout-header-title {
margin-top: 0;
margin-bottom: 8px;
margin-right: 20px;
}
.layout-content {
width: 100%;
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<n-card title="拆线图">
<div>拆线图</div>
</n-card>
</template>

View File

@@ -1,66 +0,0 @@
<template>
<el-form :inline="true" :model="filterData" class="log-filter-form">
<el-form-item label="分组">
<el-select v-model="filterData.group_id" placeholder="所有分组" clearable>
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
</el-form-item>
<el-form-item label="状态码">
<el-input v-model.number="filterData.status_code" placeholder="例如 200" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="applyFilters">查询</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useLogStore } from '@/stores/logStore';
import { useGroupStore } from '@/stores/groupStore';
import { storeToRefs } from 'pinia';
import type { LogQuery } from '@/api/logs';
const logStore = useLogStore();
const groupStore = useGroupStore();
const { groups } = storeToRefs(groupStore);
const filterData = reactive<LogQuery>({});
const dateRange = ref<[Date, Date] | null>(null);
onMounted(() => {
groupStore.fetchGroups();
});
const handleDateChange = (dates: [Date, Date] | null) => {
if (dates) {
filterData.start_time = dates[0].toISOString();
filterData.end_time = dates[1].toISOString();
} else {
filterData.start_time = undefined;
filterData.end_time = undefined;
}
};
const applyFilters = () => {
logStore.setFilters(filterData);
};
</script>
<style scoped>
.log-filter-form {
padding: 20px;
background-color: #f5f7fa;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<n-button quaternary round>退出</n-button>
</template>

View File

@@ -0,0 +1,35 @@
<template>
<n-menu mode="horizontal" :options="menuOptions" :value="activeMenu" responsive />
</template>
<script setup lang="ts">
import type { MenuOption } from 'naive-ui'
import { h, computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const menuOptions: MenuOption[] = [
renderMenuItem('dashboard', '仪表盘'),
renderMenuItem('keys', '密钥管理'),
renderMenuItem('logs', '日志'),
renderMenuItem('settings', '系统设置'),
]
const route = useRoute()
const activeMenu = computed(() => route.name)
function renderMenuItem(key: string, label: string): MenuOption {
return {
label: () =>
h(
RouterLink,
{
to: {
name: key,
},
},
{ default: () => label }
),
key,
}
}
</script>

View File

@@ -1,52 +0,0 @@
<template>
<div ref="chart" style="width: 100%; height: 400px;"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import * as echarts from 'echarts';
import type { GroupRequestStat } from '@/types/models';
const props = defineProps<{
data: GroupRequestStat[];
}>();
const chart = ref<HTMLElement | null>(null);
let myChart: echarts.ECharts | null = null;
const initChart = () => {
if (chart.value) {
myChart = echarts.init(chart.value);
updateChart();
}
};
const updateChart = () => {
if (!myChart) return;
myChart.setOption({
title: {
text: '各分组请求量',
},
tooltip: {},
xAxis: {
data: props.data.map(item => item.group_name),
},
yAxis: {},
series: [
{
name: '请求量',
type: 'bar',
data: props.data.map(item => item.request_count),
},
],
});
};
onMounted(() => {
initChart();
});
watch(() => props.data, () => {
updateChart();
}, { deep: true });
</script>

View File

@@ -1,38 +0,0 @@
<template>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">快捷操作</h3>
<div class="flex space-x-4">
<button
@click="onAddKey"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<PlusIcon class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
添加密钥
</button>
<button
@click="onCreateGroup"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<PlusCircleIcon class="-ml-1 mr-2 h-5 w-5 text-gray-400" aria-hidden="true" />
创建分组
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { PlusIcon, PlusCircleIcon } from '@heroicons/vue/24/solid';
const router = useRouter();
const onAddKey = () => {
// Assuming you have a route for adding a key, possibly on the keys page with a modal
router.push({ name: 'keys', query: { action: 'add' } });
};
const onCreateGroup = () => {
// Assuming you have a route for groups where a creation modal can be triggered
router.push({ name: 'groups', query: { action: 'create' } });
};
</script>

View File

@@ -1,92 +0,0 @@
<template>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
分组请求统计
</h3>
<div ref="chartRef" style="width: 100%; height: 400px"></div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import * as echarts from "echarts";
import { useDashboardStore } from "@/stores/dashboardStore";
const chartRef = ref<HTMLElement | null>(null);
const dashboardStore = useDashboardStore();
const { chartData } = storeToRefs(dashboardStore);
let chartInstance: echarts.ECharts | null = null;
const initChart = () => {
if (chartRef.value) {
chartInstance = echarts.init(chartRef.value);
setChartOptions();
}
};
const setChartOptions = () => {
if (!chartInstance) return;
const options: echarts.EChartsOption = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
xAxis: {
type: "category",
data: chartData.value.labels,
axisLabel: {
rotate: 45,
interval: 0,
},
},
yAxis: {
type: "value",
name: "请求数",
},
series: [
{
name: "请求数",
data: chartData.value.data,
type: "bar",
itemStyle: {
color: "#409EFF",
},
},
],
grid: {
left: "3%",
right: "4%",
bottom: "15%",
containLabel: true,
},
};
chartInstance.setOption(options);
};
const resizeChart = () => {
chartInstance?.resize();
};
onMounted(() => {
initChart();
window.addEventListener("resize", resizeChart);
});
onUnmounted(() => {
chartInstance?.dispose();
window.removeEventListener("resize", resizeChart);
});
watch(
chartData,
() => {
setChartOptions();
},
{ deep: true }
);
</script>

View File

@@ -1,76 +0,0 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="stat in statsData"
:key="stat.name"
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md"
>
<div class="flex items-center">
<div class="flex-shrink-0">
<component
:is="stat.icon"
class="h-8 w-8 text-gray-500"
aria-hidden="true"
/>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt
class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate"
>
{{ stat.name }}
</dt>
<dd class="flex items-baseline">
<span
class="text-2xl font-semibold text-gray-900 dark:text-white"
>
{{ stat.value }}
</span>
</dd>
</dl>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "@/stores/dashboardStore";
import { formatNumber } from "@/types/models";
const dashboardStore = useDashboardStore();
const { stats } = storeToRefs(dashboardStore);
const statsData = computed(() => [
{
name: "总密钥数",
value: formatNumber(stats.value.total_keys || 0),
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/KeyIcon")
),
},
{
name: "有效密钥数",
value: formatNumber(stats.value.active_keys || 0),
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/CheckCircleIcon")
),
},
{
name: "总请求数",
value: formatNumber(stats.value.total_requests),
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/ArrowTrendingUpIcon")
),
},
{
name: "成功率",
value: `${(stats.value.success_rate * 100).toFixed(1)}%`,
icon: defineAsyncComponent(
() => import("@heroicons/vue/24/outline/ChartBarIcon")
),
},
]);
</script>

View File

@@ -1,486 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑分组' : '创建分组'"
width="600px"
:before-close="handleClose"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="分组名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入分组名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="分组描述">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入分组描述(可选)"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="渠道类型" prop="channel_type">
<el-radio-group v-model="formData.channel_type">
<el-radio value="openai">
<div class="channel-option">
<span class="channel-name">OpenAI</span>
<span class="channel-desc">支持 GPT-3.5GPT-4 等模型</span>
</div>
</el-radio>
<el-radio value="gemini">
<div class="channel-option">
<span class="channel-name">Gemini</span>
<span class="channel-desc">Google Gemini 模型</span>
</div>
</el-radio>
</el-radio-group>
</el-form-item>
<el-divider content-position="left">配置设置</el-divider>
<el-form-item label="上游地址" prop="config.upstream_url">
<el-input
v-model="formData.config.upstream_url"
placeholder="请输入API上游地址"
/>
<div class="form-tip">
例如https://api.openai.com 或
https://generativelanguage.googleapis.com
</div>
</el-form-item>
<el-form-item label="超时时间">
<el-input-number
v-model="formData.config.timeout"
:min="1000"
:max="300000"
:step="1000"
placeholder="请输入超时时间"
/>
<span class="input-suffix">毫秒</span>
<div class="form-tip">请求超时时间范围1 - 5分钟默认30秒</div>
</el-form-item>
<el-form-item label="最大令牌数">
<el-input-number
v-model="formData.config.max_tokens"
:min="1"
:max="32000"
placeholder="请输入最大令牌数"
/>
<div class="form-tip">单次请求最大令牌数留空使用模型默认值</div>
</el-form-item>
<!-- 高级配置 -->
<el-collapse v-model="activeCollapse">
<el-collapse-item title="高级配置" name="advanced">
<el-form-item label="请求头">
<div class="config-editor">
<el-input
v-model="headersText"
type="textarea"
:rows="4"
placeholder="请输入自定义请求头配置JSON格式"
@blur="validateHeaders"
/>
<div class="form-tip">
格式{"Authorization": "Bearer token", "Custom-Header":
"value"}
</div>
</div>
</el-form-item>
<el-form-item label="其他配置">
<div class="config-editor">
<el-input
v-model="otherConfigText"
type="textarea"
:rows="4"
placeholder="请输入其他配置项JSON格式"
@blur="validateOtherConfig"
/>
<div class="form-tip">其他自定义配置参数将合并到分组配置中</div>
</div>
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? "保存修改" : "创建分组" }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from "vue";
import {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElRadioGroup,
ElRadio,
ElButton,
ElDivider,
ElCollapse,
ElCollapseItem,
ElMessage,
type FormInstance,
type FormRules,
} from "element-plus";
import type { Group, GroupConfig } from "@/types/models";
interface Props {
visible: boolean;
groupData?: Group | null;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
groupData: null,
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "save", data: any): void;
}>();
const formRef = ref<FormInstance>();
const dialogVisible = ref(props.visible);
const submitting = ref(false);
const activeCollapse = ref<string[]>([]);
const headersText = ref("");
const otherConfigText = ref("");
// 计算属性
const isEdit = computed(() => !!props.groupData);
// 表单数据
const formData = reactive<{
name: string;
description: string;
channel_type: "openai" | "gemini";
config: GroupConfig;
}>({
name: "",
description: "",
channel_type: "openai",
config: {
upstream_url: "",
timeout: 30000,
max_tokens: undefined,
},
});
// 表单验证规则
const formRules: FormRules = {
name: [
{ required: true, message: "请输入分组名称", trigger: "blur" },
{ min: 2, max: 50, message: "分组名称长度为2-50个字符", trigger: "blur" },
],
channel_type: [
{ required: true, message: "请选择渠道类型", trigger: "change" },
],
"config.upstream_url": [
{ required: true, message: "请输入上游地址", trigger: "blur" },
{
pattern: /^https?:\/\/.+/,
message: "请输入有效的HTTP/HTTPS地址",
trigger: "blur",
},
],
};
// 监听器
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
if (val) {
resetForm();
if (props.groupData) {
loadGroupData();
} else {
setDefaultConfig();
}
}
}
);
watch(dialogVisible, (val) => {
emit("update:visible", val);
});
watch(
() => formData.channel_type,
(newType) => {
setDefaultConfig(newType);
}
);
// 方法
const resetForm = () => {
formData.name = "";
formData.description = "";
formData.channel_type = "openai";
formData.config = {
upstream_url: "",
timeout: 30000,
max_tokens: undefined,
};
headersText.value = "";
otherConfigText.value = "";
activeCollapse.value = [];
submitting.value = false;
nextTick(() => {
formRef.value?.clearValidate();
});
};
const setDefaultConfig = (channelType?: "openai" | "gemini") => {
const type = channelType || formData.channel_type;
if (!isEdit.value) {
switch (type) {
case "openai":
formData.config.upstream_url = "https://api.openai.com";
break;
case "gemini":
formData.config.upstream_url =
"https://generativelanguage.googleapis.com";
break;
}
}
};
const loadGroupData = () => {
if (props.groupData) {
formData.name = props.groupData.name;
formData.description = props.groupData.description;
formData.channel_type = props.groupData.channel_type;
formData.config = { ...props.groupData.config };
// 解析高级配置
if (props.groupData.config.headers) {
headersText.value = JSON.stringify(
props.groupData.config.headers,
null,
2
);
}
// 提取其他配置(排除已知字段)
const { upstream_url, timeout, max_tokens, headers, ...otherConfig } =
props.groupData.config;
if (Object.keys(otherConfig).length > 0) {
otherConfigText.value = JSON.stringify(otherConfig, null, 2);
}
}
};
const validateHeaders = () => {
if (!headersText.value.trim()) return;
try {
JSON.parse(headersText.value);
} catch {
ElMessage.error("请求头配置格式错误请检查JSON语法");
return false;
}
return true;
};
const validateOtherConfig = () => {
if (!otherConfigText.value.trim()) return;
try {
JSON.parse(otherConfigText.value);
} catch {
ElMessage.error("其他配置格式错误请检查JSON语法");
return false;
}
return true;
};
const validateForm = async (): Promise<boolean> => {
if (!formRef.value) return false;
try {
await formRef.value.validate();
// 验证高级配置
if (headersText.value.trim() && !validateHeaders()) {
return false;
}
if (otherConfigText.value.trim() && !validateOtherConfig()) {
return false;
}
return true;
} catch {
return false;
}
};
const buildConfigData = () => {
const config: GroupConfig = {
upstream_url: formData.config.upstream_url,
timeout: formData.config.timeout,
};
if (formData.config.max_tokens) {
config.max_tokens = formData.config.max_tokens;
}
// 添加自定义请求头
if (headersText.value.trim()) {
try {
config.headers = JSON.parse(headersText.value);
} catch {
// 已在验证中处理
}
}
// 添加其他配置
if (otherConfigText.value.trim()) {
try {
const otherConfig = JSON.parse(otherConfigText.value);
Object.assign(config, otherConfig);
} catch {
// 已在验证中处理
}
}
return config;
};
const handleSubmit = async () => {
if (!(await validateForm())) return;
submitting.value = true;
try {
const saveData = {
name: formData.name,
description: formData.description,
channel_type: formData.channel_type,
config: buildConfigData(),
};
if (isEdit.value) {
emit("save", {
...saveData,
id: props.groupData!.id,
});
} else {
emit("save", saveData);
}
ElMessage.success(isEdit.value ? "分组更新成功" : "分组创建成功");
handleClose();
} catch (error) {
console.error("Save group failed:", error);
ElMessage.error("操作失败,请重试");
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
handleClose();
};
const handleClose = () => {
if (submitting.value) {
ElMessage.warning("操作进行中,请稍后");
return;
}
dialogVisible.value = false;
};
const handleClosed = () => {
resetForm();
};
</script>
<style scoped>
.channel-option {
display: flex;
flex-direction: column;
margin-left: 8px;
}
.channel-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.channel-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.input-suffix {
margin-left: 8px;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.config-editor {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.el-radio) {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
margin-right: 30px;
}
:deep(.el-radio__input) {
margin-top: 2px;
}
:deep(.el-collapse-item__header) {
font-weight: 500;
}
:deep(.el-input-number) {
width: 200px;
}
</style>

View File

@@ -1,233 +0,0 @@
<template>
<div class="group-list-container">
<div class="header">
<search-input v-model="searchQuery" placeholder="搜索分组..." />
<el-button type="primary" :icon="Plus" @click="handleAddGroup"
>添加分组</el-button
>
</div>
<el-scrollbar class="group-list-scrollbar">
<loading-spinner v-if="groupStore.isLoading" />
<empty-state
v-else-if="filteredGroups.length === 0"
message="未找到分组"
/>
<ul v-else class="group-list">
<li
v-for="group in filteredGroups"
:key="group.id"
:class="{ active: group.id === selectedGroupId }"
@click="handleSelectGroup(group.id)"
>
<div class="group-item">
<span class="group-name">{{ group.name }}</span>
<div class="group-meta">
<el-tag
size="small"
:type="getChannelTypeColor(group.channel_type)"
>
{{ getChannelTypeName(group.channel_type) }}
</el-tag>
<span class="key-count"
>{{ (group.api_keys || []).length }} 密钥</span
>
</div>
</div>
<div class="group-actions">
<el-button size="small" text @click.stop="handleEditGroup(group)">
编辑
</el-button>
<el-button
size="small"
text
type="danger"
@click.stop="handleDeleteGroup(group)"
>
删除
</el-button>
</div>
</li>
</ul>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import SearchInput from "@/components/common/SearchInput.vue";
import LoadingSpinner from "@/components/common/LoadingSpinner.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import { ElButton, ElScrollbar, ElTag, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import type { Group } from "@/types/models";
interface Props {
selectedGroupId?: number;
}
const props = defineProps<Props>();
const selectedGroupId = computed(() => props.selectedGroupId);
const emit = defineEmits<{
(e: "select-group", groupId: number): void;
(e: "add-group"): void;
(e: "edit-group", group: Group): void;
(e: "delete-group", groupId: number): void;
}>();
const groupStore = useGroupStore();
const searchQuery = ref("");
const filteredGroups = computed(() => {
if (!searchQuery.value) {
return groupStore.groups;
}
return groupStore.groups.filter(
(group) =>
group.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
group.description.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
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) => {
emit("select-group", groupId);
};
const handleAddGroup = () => {
emit("add-group");
};
const handleEditGroup = (group: Group) => {
emit("edit-group", group);
};
const handleDeleteGroup = async (group: Group) => {
try {
await ElMessageBox.confirm(
`确定要删除分组 "${group.name}" 吗?这将同时删除该分组下的所有密钥。`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
emit("delete-group", group.id);
} catch {
// 用户取消删除
}
};
onMounted(() => {
if (groupStore.groups.length === 0) {
groupStore.fetchGroups();
}
});
</script>
<style scoped>
.group-list-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
box-sizing: border-box;
}
.header {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.group-list-scrollbar {
flex-grow: 1;
}
.group-list {
list-style: none;
padding: 0;
margin: 0;
}
.group-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
margin-bottom: 8px;
border: 1px solid transparent;
}
.group-list li:hover {
background-color: var(--el-fill-color-light);
border-color: var(--el-border-color);
}
.group-list li.active {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.group-item {
flex: 1;
min-width: 0;
}
.group-name {
font-weight: 500;
font-size: 14px;
display: block;
margin-bottom: 4px;
}
.group-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-regular);
}
.key-count {
color: var(--el-text-color-secondary);
}
.group-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.group-list li:hover .group-actions {
opacity: 1;
}
</style>

View File

@@ -1,123 +0,0 @@
<template>
<div class="group-stats-container">
<empty-state
v-if="!selectedGroup"
message="请从左侧选择一个分组以查看详情"
/>
<div v-else class="stats-content">
<div class="header">
<h2 class="group-name">{{ selectedGroup.name }}</h2>
<div class="actions">
<el-button :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</div>
<p class="group-description">{{ selectedGroup.description || '暂无描述' }}</p>
<el-row :gutter="20" class="stats-cards">
<el-col :span="8">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ keyStore.keys.length }}</div>
<div class="stat-label">密钥总数</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ activeKeysCount }}</div>
<div class="stat-label">已启用</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ disabledKeysCount }}</div>
<div class="stat-label">已禁用</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useGroupStore } from '@/stores/groupStore';
import { useKeyStore } from '@/stores/keyStore';
import EmptyState from '@/components/common/EmptyState.vue';
import { ElButton, ElRow, ElCol, ElCard, ElMessage } from 'element-plus';
import { Edit, Delete } from '@element-plus/icons-vue';
const groupStore = useGroupStore();
const keyStore = useKeyStore();
const selectedGroup = computed(() => groupStore.selectedGroupDetails);
const activeKeysCount = computed(() => {
return keyStore.keys.filter(key => key.status === 'active').length;
});
const disabledKeysCount = computed(() => {
return keyStore.keys.filter(key => key.status !== 'active').length;
});
const handleEdit = () => {
// TODO: Implement edit group logic (e.g., open a dialog)
console.log('Edit group:', selectedGroup.value?.id);
ElMessage.info('编辑功能待实现');
};
const handleDelete = () => {
// TODO: Implement delete group logic (with confirmation)
console.log('Delete group:', selectedGroup.value?.id);
ElMessage.warning('删除功能待实现');
};
</script>
<style scoped>
.group-stats-container {
width: 100%;
}
.stats-content {
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.group-name {
font-size: 24px;
font-weight: bold;
margin: 0;
}
.group-description {
color: #606266;
margin-bottom: 20px;
min-height: 22px;
}
.stats-cards .stat-item {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: var(--el-color-primary);
}
.stat-label {
font-size: 14px;
color: #909399;
}
</style>

View File

@@ -1,108 +0,0 @@
<template>
<div class="key-batch-ops-container">
<div class="batch-actions">
<el-button @click="handleBatchEnable" :disabled="!hasSelection">
批量启用
</el-button>
<el-button
type="warning"
@click="handleBatchDisable"
:disabled="!hasSelection"
>
批量禁用
</el-button>
<el-button
type="danger"
@click="handleBatchDelete"
:disabled="!hasSelection"
>
批量删除
</el-button>
</div>
<el-button type="primary" :icon="Plus" @click="handleAddNew">
添加密钥
</el-button>
<key-form v-model:visible="isFormVisible" :key-data="null" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useKeyStore } from "@/stores/keyStore";
import KeyForm from "./KeyForm.vue";
import { ElButton, ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
const keyStore = useKeyStore();
const isFormVisible = ref(false);
const hasSelection = computed(() => keyStore.selectedKeyIds.length > 0);
const handleAddNew = () => {
isFormVisible.value = true;
};
const createBatchHandler = (action: "启用" | "禁用" | "删除") => {
const actionMap: {
[key: string]: { status?: "active" | "inactive"; verb: string };
} = {
启用: { status: "active", verb: "启用" },
禁用: { status: "inactive", verb: "禁用" },
删除: { verb: "删除" },
};
return async () => {
const selectedIds = keyStore.selectedKeyIds;
if (selectedIds.length === 0) {
ElMessage.warning("请至少选择一个密钥");
return;
}
try {
await ElMessageBox.confirm(
`确定要${actionMap[action].verb}选中的 ${selectedIds.length} 个密钥吗?`,
"警告",
{
confirmButtonText: `确定${actionMap[action].verb}`,
cancelButtonText: "取消",
type: "warning",
}
);
if (action === "删除") {
await keyStore.batchDelete(selectedIds);
} else {
await keyStore.batchUpdateStatus(
selectedIds,
actionMap[action].status!
);
}
ElMessage.success(`选中的密钥已${actionMap[action].verb}`);
} catch (error) {
if (error !== "cancel") {
ElMessage.error(`批量${actionMap[action].verb}操作失败`);
} else {
ElMessage.info("操作已取消");
}
}
};
};
const handleBatchEnable = createBatchHandler("启用");
const handleBatchDisable = createBatchHandler("禁用");
const handleBatchDelete = createBatchHandler("删除");
</script>
<style scoped>
.key-batch-ops-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.batch-actions {
display: flex;
gap: 10px;
}
</style>

View File

@@ -1,368 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑密钥' : '添加密钥'"
width="600px"
:before-close="handleClose"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="API密钥" prop="key_value">
<el-input
v-model="formData.key_value"
type="textarea"
:rows="3"
placeholder="请输入完整的API密钥"
:disabled="isEdit"
/>
<div class="form-tip">
<span v-if="isEdit">编辑时无法修改密钥值</span>
<span v-else>请输入完整的API密钥支持粘贴多行文本</span>
</div>
</el-form-item>
<el-form-item label="密钥状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio value="active">启用</el-radio>
<el-radio value="inactive">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
placeholder="可选:为此密钥添加备注信息"
maxlength="200"
show-word-limit
/>
</el-form-item>
<!-- 批量导入模式 -->
<el-collapse v-if="!isEdit" v-model="activeCollapse">
<el-collapse-item title="批量导入密钥" name="batch">
<div class="batch-import-section">
<el-alert
title="批量导入说明"
type="info"
:closable="false"
show-icon
>
<template #default>
<p>每行一个密钥系统会自动分割并创建多个密钥记录</p>
<p>支持以下格式</p>
<ul class="format-list">
<li> sk-xxxxxxxxxxxxxxxxxxxx</li>
<li> sk-proj-xxxxxxxxxxxxxxxxxxxx</li>
<li> 其他格式的API密钥</li>
</ul>
</template>
</el-alert>
<el-form-item label="批量密钥" style="margin-top: 16px">
<el-input
v-model="batchKeys"
type="textarea"
:rows="8"
placeholder="请粘贴多个密钥,每行一个"
@input="handleBatchKeysChange"
/>
<div class="batch-info" v-if="parsedBatchKeys.length > 0">
检测到 {{ parsedBatchKeys.length }} 个密钥
</div>
</el-form-item>
</div>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ submitButtonText }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from "vue";
import {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElRadioGroup,
ElRadio,
ElButton,
ElCollapse,
ElCollapseItem,
ElAlert,
ElMessage,
type FormInstance,
type FormRules,
} from "element-plus";
import type { APIKey } from "@/types/models";
interface Props {
visible: boolean;
keyData?: APIKey | null;
groupId?: number;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
keyData: null,
groupId: undefined,
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "save", data: any): void;
}>();
const formRef = ref<FormInstance>();
const dialogVisible = ref(props.visible);
const submitting = ref(false);
const activeCollapse = ref<string[]>([]);
const batchKeys = ref("");
// 计算属性
const isEdit = computed(() => !!props.keyData);
const submitButtonText = computed(() => {
if (submitting.value) {
return isEdit.value ? "保存中..." : "创建中...";
}
if (parsedBatchKeys.value.length > 1) {
return `批量创建 ${parsedBatchKeys.value.length} 个密钥`;
}
return isEdit.value ? "保存" : "创建密钥";
});
// 表单数据
const formData = reactive<{
key_value: string;
status: "active" | "inactive";
remark: string;
}>({
key_value: "",
status: "active",
remark: "",
});
// 表单验证规则
const formRules: FormRules = {
key_value: [
{ required: true, message: "请输入API密钥", trigger: "blur" },
{ min: 10, message: "密钥长度至少10位", trigger: "blur" },
],
status: [{ required: true, message: "请选择密钥状态", trigger: "change" }],
};
// 批量密钥解析
const parsedBatchKeys = computed(() => {
if (!batchKeys.value.trim()) {
return formData.key_value ? [formData.key_value] : [];
}
return batchKeys.value
.split("\n")
.map((key) => key.trim())
.filter((key) => key.length > 0)
.filter((key, index, arr) => arr.indexOf(key) === index); // 去重
});
// 监听器
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
if (val) {
resetForm();
if (props.keyData) {
loadKeyData();
}
}
}
);
watch(dialogVisible, (val) => {
emit("update:visible", val);
});
// 方法
const resetForm = () => {
formData.key_value = "";
formData.status = "active";
formData.remark = "";
batchKeys.value = "";
activeCollapse.value = [];
submitting.value = false;
nextTick(() => {
formRef.value?.clearValidate();
});
};
const loadKeyData = () => {
if (props.keyData) {
formData.key_value = props.keyData.key_value;
formData.status =
props.keyData.status === "error" ? "inactive" : props.keyData.status;
formData.remark = (props.keyData as any).remark || "";
}
};
const handleBatchKeysChange = () => {
// 如果有批量密钥输入,清空单个密钥输入
if (batchKeys.value.trim()) {
formData.key_value = "";
}
};
const validateForm = async (): Promise<boolean> => {
if (!formRef.value) return false;
try {
await formRef.value.validate();
// 检查是否有密钥数据
if (parsedBatchKeys.value.length === 0) {
ElMessage.error("请输入至少一个密钥");
return false;
}
// 验证密钥格式
const invalidKeys = parsedBatchKeys.value.filter((key) => key.length < 10);
if (invalidKeys.length > 0) {
ElMessage.error(
`检测到 ${invalidKeys.length} 个无效密钥密钥长度至少10位`
);
return false;
}
return true;
} catch {
return false;
}
};
const handleSubmit = async () => {
if (!(await validateForm())) return;
submitting.value = true;
try {
const saveData = {
status: formData.status,
remark: formData.remark,
group_id: props.groupId,
};
if (isEdit.value) {
// 编辑模式
emit("save", {
...saveData,
id: props.keyData!.id,
key_value: formData.key_value,
});
} else if (parsedBatchKeys.value.length === 1) {
// 单个密钥创建
emit("save", {
...saveData,
key_value: parsedBatchKeys.value[0],
});
} else {
// 批量创建
emit("save", {
...saveData,
keys: parsedBatchKeys.value,
batch: true,
});
}
ElMessage.success(
isEdit.value
? "密钥更新成功"
: `成功创建 ${parsedBatchKeys.value.length} 个密钥`
);
handleClose();
} catch (error) {
console.error("Save key failed:", error);
ElMessage.error("操作失败,请重试");
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
handleClose();
};
const handleClose = () => {
if (submitting.value) {
ElMessage.warning("操作进行中,请稍后");
return;
}
dialogVisible.value = false;
};
const handleClosed = () => {
resetForm();
};
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.batch-import-section {
margin-top: 16px;
}
.format-list {
margin: 8px 0;
padding-left: 16px;
}
.format-list li {
margin: 4px 0;
color: var(--el-text-color-regular);
font-family: monospace;
}
.batch-info {
margin-top: 8px;
padding: 8px 12px;
background-color: var(--el-color-success-light-9);
border: 1px solid var(--el-color-success-light-7);
border-radius: 4px;
color: var(--el-color-success);
font-size: 12px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.el-collapse-item__header) {
font-weight: 500;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
</style>

View File

@@ -1,328 +0,0 @@
<template>
<div class="key-table-container">
<!-- 工具栏 -->
<div class="table-toolbar mb-4">
<div class="flex justify-between items-center">
<div class="flex space-x-2">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加密钥
</el-button>
<el-button @click="handleBatchImport"> 批量导入 </el-button>
<el-dropdown
@command="handleBatchOperation"
v-if="selectedKeys.length > 0"
>
<el-button>
批量操作<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="enable">批量启用</el-dropdown-item>
<el-dropdown-item command="disable">批量禁用</el-dropdown-item>
<el-dropdown-item command="delete" divided
>批量删除</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="flex space-x-2">
<SearchInput
v-model="searchKeyword"
placeholder="搜索密钥..."
@search="handleSearch"
/>
</div>
</div>
</div>
<!-- 数据表格 -->
<DataTable
:data="filteredKeys"
:columns="tableColumns"
:loading="loading"
selectable
@selection-change="handleSelectionChange"
>
<!-- 密钥值列 - 脱敏显示 -->
<template #key_value="{ row }">
<div class="key-value-cell flex items-center space-x-2">
<span class="font-mono text-sm">
{{ row.showKey ? row.key_value : maskKey(row.key_value) }}
</span>
<el-button size="small" text @click="toggleKeyVisibility(row)">
<el-icon>
<component :is="row.showKey ? 'Hide' : 'View'" />
</el-icon>
</el-button>
<el-button
size="small"
text
@click="copyKey(row.key_value)"
title="复制密钥"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</template>
<!-- 状态列 -->
<template #status="{ row }">
<StatusBadge :status="row.status" />
</template>
<!-- 使用统计列 -->
<template #usage="{ row }">
<el-tooltip placement="top">
<div class="text-center">
<div class="text-sm font-medium">
{{ formatNumber(row.request_count) }}
</div>
<div class="text-xs text-gray-500" v-if="row.failure_count > 0">
失败: {{ formatNumber(row.failure_count) }}
</div>
</div>
<template #content>
<div>总请求: {{ row.request_count }}</div>
<div>失败次数: {{ row.failure_count }}</div>
<div v-if="row.last_used_at">
最后使用: {{ formatTime(row.last_used_at) }}
</div>
</template>
</el-tooltip>
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<div class="flex space-x-1">
<el-button size="small" @click="handleEdit(row)"> 编辑 </el-button>
<el-button
size="small"
:type="row.status === 'active' ? 'warning' : 'success'"
@click="toggleKeyStatus(row)"
>
{{ row.status === "active" ? "禁用" : "启用" }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">
删除
</el-button>
</div>
</template>
</DataTable>
<!-- 密钥表单对话框 -->
<KeyForm
v-model:visible="formVisible"
:key-data="currentKey"
:group-id="currentGroupId"
@save="handleSave"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, withDefaults } from "vue";
import {
ElButton,
ElIcon,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElTooltip,
ElMessage,
ElMessageBox,
} from "element-plus";
import { Plus, ArrowDown, CopyDocument } from "@element-plus/icons-vue";
import DataTable from "@/components/common/DataTable.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import SearchInput from "@/components/common/SearchInput.vue";
import KeyForm from "./KeyForm.vue";
import type { APIKey } from "@/types/models";
import { maskKey, formatNumber } from "@/types/models";
interface Props {
keys: APIKey[];
loading?: boolean;
groupId?: number;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
groupId: undefined,
});
const emit = defineEmits<{
(e: "add"): void;
(e: "edit", key: APIKey): void;
(e: "delete", keyId: number): void;
(e: "toggle-status", key: APIKey): void;
(e: "batch-operation", operation: string, keys: APIKey[]): void;
}>();
const selectedKeys = ref<APIKey[]>([]);
const searchKeyword = ref("");
const formVisible = ref(false);
const currentKey = ref<APIKey | null>(null);
const currentGroupId = ref<number | undefined>(props.groupId);
// 表格列配置
const tableColumns = [
{ prop: "key_value", label: "API密钥", minWidth: 200 },
{ prop: "status", label: "状态", width: 100 },
{ prop: "usage", label: "使用统计", width: 120 },
{ prop: "created_at", label: "创建时间", width: 150 },
];
// 过滤后的密钥列表
const filteredKeys = computed(() => {
let keys = props.keys.map((key) => ({
...key,
showKey: false, // 添加显示状态
}));
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase();
keys = keys.filter(
(key) =>
key.key_value.toLowerCase().includes(keyword) ||
key.status.toLowerCase().includes(keyword)
);
}
return keys;
});
// 事件处理函数
const handleAdd = () => {
currentKey.value = null;
currentGroupId.value = props.groupId;
formVisible.value = true;
emit("add");
};
const handleEdit = (key: APIKey) => {
currentKey.value = key;
formVisible.value = true;
emit("edit", key);
};
const handleDelete = async (key: APIKey) => {
try {
await ElMessageBox.confirm(
`确定要删除这个密钥吗?此操作不可恢复。`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
emit("delete", key.id);
} catch {
// 用户取消删除
}
};
const toggleKeyStatus = async (key: APIKey) => {
const action = key.status === "active" ? "禁用" : "启用";
try {
await ElMessageBox.confirm(`确定要${action}这个密钥吗?`, `确认${action}`, {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
emit("toggle-status", key);
} catch {
// 用户取消操作
}
};
const handleSelectionChange = (selection: APIKey[]) => {
selectedKeys.value = selection;
};
const handleBatchImport = () => {
// TODO: 实现批量导入功能
ElMessage.info("批量导入功能开发中...");
};
const handleBatchOperation = async (command: string) => {
if (selectedKeys.value.length === 0) {
ElMessage.warning("请先选择要操作的密钥");
return;
}
const operationMap = {
enable: "启用",
disable: "禁用",
delete: "删除",
};
const operation = operationMap[command as keyof typeof operationMap];
try {
await ElMessageBox.confirm(
`确定要${operation}选中的 ${selectedKeys.value.length} 个密钥吗?`,
`确认批量${operation}`,
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
emit("batch-operation", command, selectedKeys.value);
} catch {
// 用户取消操作
}
};
const handleSearch = () => {
// 搜索逻辑已在computed中处理
};
const handleSave = () => {
formVisible.value = false;
// 父组件处理保存逻辑
};
// 工具函数
const toggleKeyVisibility = (key: any) => {
key.showKey = !key.showKey;
};
const copyKey = async (keyValue: string) => {
try {
await navigator.clipboard.writeText(keyValue);
ElMessage.success("密钥已复制到剪贴板");
} catch {
ElMessage.error("复制失败,请手动复制");
}
};
const formatTime = (timeStr: string) => {
return new Date(timeStr).toLocaleString("zh-CN");
};
</script>
<style scoped>
.key-table-container {
width: 100%;
}
.table-toolbar {
padding: 16px 0;
border-bottom: 1px solid var(--el-border-color-light);
}
.key-value-cell {
max-width: 300px;
}
@media (max-width: 768px) {
.table-toolbar .flex {
flex-direction: column;
gap: 12px;
}
}
</style>

View File

@@ -1,53 +0,0 @@
<template>
<div class="p-6 bg-white shadow-md rounded-lg">
<h3 class="text-lg font-semibold leading-6 text-gray-900 mb-6">分组设置</h3>
<div class="mb-4">
<label for="group-select" class="block text-sm font-medium text-gray-700"
>选择分组</label
>
<select
id="group-select"
v-model="selectedGroup"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
<div v-if="selectedGroup">
<p class="text-sm text-gray-600">
<strong>{{ selectedGroupName }}</strong>
分组设置覆盖配置这些配置将优先于系统默认配置
</p>
<!-- Add group-specific setting items here later -->
<div class="mt-4 p-4 border border-dashed rounded-md">
<p class="text-center text-gray-500">分组配置项待实现</p>
</div>
</div>
<div v-else>
<p class="text-center text-gray-500">请先选择一个分组以查看其配置</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useGroupStore } from "@/stores/groupStore";
import { storeToRefs } from "pinia";
const groupStore = useGroupStore();
const { groups } = storeToRefs(groupStore);
const selectedGroup = ref<number | null>(null);
const selectedGroupName = computed(() => {
return groups.value.find((g) => g.id === selectedGroup.value)?.name || "";
});
onMounted(() => {
groupStore.fetchGroups();
});
</script>

View File

@@ -1,33 +0,0 @@
<template>
<div class="setting-item mb-4">
<label class="block text-sm font-medium text-gray-700">{{ label }}</label>
<input
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
:placeholder="placeholder"
/>
<p v-if="description" class="mt-2 text-sm text-gray-500">{{ description }}</p>
<p v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string | number;
label: string;
type?: string;
placeholder?: string;
description?: string;
error?: string;
}>();
defineEmits(['update:modelValue']);
</script>
<style scoped>
.setting-item {
/* Add any specific styling for the setting item here */
}
</style>

View File

@@ -1,47 +0,0 @@
<template>
<div class="p-6 bg-white shadow-md rounded-lg">
<h3 class="text-lg font-semibold leading-6 text-gray-900 mb-6">系统设置</h3>
<div v-if="settings" class="space-y-6">
<SettingItem
v-model.number="settings.port"
label="服务端口"
type="number"
description="Web 服务和 API 监听的端口。"
:error="errors['port']"
/>
<SettingItem
v-model="settings.cors.allowed_origins"
label="允许的跨域来源 (CORS)"
description="允许访问 API 的来源列表,用逗号分隔。使用 '*' 表示允许所有来源。"
:error="errors['cors.allowed_origins']"
/>
<SettingItem
v-model.number="settings.timeout.read"
label="读取超时 (秒)"
type="number"
description="服务器读取请求的超时时间。"
:error="errors['timeout.read']"
/>
<SettingItem
v-model.number="settings.timeout.write"
label="写入超时 (秒)"
type="number"
description="服务器写入响应的超时时间。"
:error="errors['timeout.write']"
/>
</div>
<div v-else>
<p>正在加载设置...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useSettingStore } from '@/stores/settingStore';
import SettingItem from './SettingItem.vue';
const settingStore = useSettingStore();
// 我们将在 store 中定义 systemSettings 和 errors
const { systemSettings: settings, errors } = storeToRefs(settingStore);
</script>

View File

@@ -1,112 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="title"
width="30%"
:before-close="handleClose"
center
>
<div class="dialog-content">
<el-icon :class="['icon', type]">
<WarningFilled v-if="type === 'warning'" />
<CircleCloseFilled v-if="type === 'delete'" />
<InfoFilled v-if="type === 'info'" />
</el-icon>
<span>{{ content }}</span>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCancel">{{ cancelText }}</el-button>
<el-button :type="confirmButtonType" @click="handleConfirm">
{{ confirmText }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ElDialog, ElButton, ElIcon } from 'element-plus';
import { WarningFilled, CircleCloseFilled, InfoFilled } from '@element-plus/icons-vue';
type DialogType = 'warning' | 'delete' | 'info';
const props = withDefaults(defineProps<{
visible: boolean;
title: string;
content: string;
type?: DialogType;
confirmText?: string;
cancelText?: string;
}>(), {
visible: false,
type: 'warning',
confirmText: '确认',
cancelText: '取消',
});
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'confirm'): void;
(e: 'cancel'): void;
}>();
const dialogVisible = ref(props.visible);
watch(() => props.visible, (val) => {
dialogVisible.value = val;
});
const confirmButtonType = computed(() => {
switch (props.type) {
case 'delete':
return 'danger';
case 'warning':
return 'warning';
default:
return 'primary';
}
});
const handleClose = (done: () => void) => {
emit('update:visible', false);
emit('cancel');
done();
};
const handleConfirm = () => {
emit('confirm');
emit('update:visible', false);
};
const handleCancel = () => {
emit('cancel');
emit('update:visible', false);
};
</script>
<style scoped>
.dialog-content {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
}
.icon {
font-size: 24px;
}
.icon.warning {
color: #E6A23C;
}
.icon.delete {
color: #F56C6C;
}
.icon.info {
color: #909399;
}
</style>

View File

@@ -1,108 +0,0 @@
<template>
<div class="data-table-container">
<el-table
v-loading="loading"
:data="data"
style="width: 100%"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
>
<el-table-column v-if="selectable" type="selection" width="55" />
<el-table-column
v-for="column in columns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:sortable="column.sortable ? 'custom' : false"
>
<template #default="{ row }">
<slot :name="column.prop" :row="row">
{{ row[column.prop] }}
</slot>
</template>
</el-table-column>
<el-table-column v-if="$slots.actions" label="操作" fixed="right" width="180">
<template #default="{ row }">
<slot name="actions" :row="row"></slot>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="pagination"
class="pagination"
:current-page="pagination.currentPage"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</template>
<script setup lang="ts">
import { defineProps, withDefaults, defineEmits } from 'vue';
import { ElTable, ElTableColumn, ElPagination, ElLoadingDirective as vLoading } from 'element-plus';
export interface TableColumn {
prop: string;
label: string;
width?: string | number;
sortable?: boolean;
}
export interface PaginationConfig {
currentPage: number;
pageSize: number;
total: number;
}
withDefaults(defineProps<{
data: any[];
columns: TableColumn[];
loading?: boolean;
selectable?: boolean;
pagination?: PaginationConfig;
}>(), {
loading: false,
selectable: false,
pagination: undefined,
});
const emit = defineEmits<{
(e: 'selection-change', selection: any[]): void;
(e: 'sort-change', { column, prop, order }: { column: any; prop: string; order: string | null }): void;
(e: 'page-change', page: number): void;
(e: 'size-change', size: number): void;
}>();
const handleSelectionChange = (selection: any[]) => {
emit('selection-change', selection);
};
const handleSortChange = ({ column, prop, order }: { column: any; prop: string; order: string | null }) => {
emit('sort-change', { column, prop, order });
};
const handlePageChange = (page: number) => {
emit('page-change', page);
};
const handleSizeChange = (size: number) => {
emit('size-change', size);
};
</script>
<style scoped>
.data-table-container {
width: 100%;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
:shortcuts="shortcuts"
@change="handleChange"
/>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, withDefaults, defineEmits } from 'vue';
import { ElDatePicker } from 'element-plus';
type DateRangeValue = [Date, Date];
const props = withDefaults(defineProps<{
modelValue: DateRangeValue | null;
}>(), {
modelValue: null,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: DateRangeValue | null): void;
}>();
const dateRange = ref<DateRangeValue | []>(props.modelValue || []);
watch(() => props.modelValue, (val) => {
dateRange.value = val || [];
});
const handleChange = (value: DateRangeValue | null) => {
emit('update:modelValue', value);
};
const shortcuts = [
{
text: '最近一周',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
return [start, end];
},
},
{
text: '最近一个月',
value: () => {
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 1);
return [start, end];
},
},
{
text: '最近三个月',
value: () => {
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 3);
return [start, end];
},
},
];
</script>
<style scoped>
.el-date-picker {
width: 100%;
}
</style>

View File

@@ -1,52 +0,0 @@
<template>
<div class="empty-state-container">
<el-empty :description="description">
<template #image>
<slot name="image">
<img v-if="image" :src="image" alt="Empty state" />
</slot>
</template>
<template #default>
<slot name="actions">
<el-button v-if="actionText" type="primary" @click="$emit('action')">
{{ actionText }}
</el-button>
</slot>
</template>
</el-empty>
</div>
</template>
<script setup lang="ts">
import { defineProps, withDefaults, defineEmits } from 'vue';
import { ElEmpty, ElButton } from 'element-plus';
withDefaults(defineProps<{
image?: string;
description?: string;
actionText?: string;
}>(), {
image: '',
description: '暂无数据',
actionText: '',
});
defineEmits<{
(e: 'action'): void;
}>();
</script>
<style scoped>
.empty-state-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
padding: 40px 0;
}
.el-empty__image img {
max-width: 150px;
user-select: none;
}
</style>

View File

@@ -1,64 +0,0 @@
<template>
<div class="loading-spinner" :style="{ width: size, height: size }">
<svg class="spinner" viewBox="0 0 50 50">
<circle
class="path"
cx="25"
cy="25"
r="20"
fill="none"
:stroke="color"
stroke-width="5"
></circle>
</svg>
</div>
</template>
<script setup lang="ts">
import { defineProps, withDefaults } from 'vue';
withDefaults(defineProps<{
size?: string;
color?: string;
}>(), {
size: '48px',
color: '#409EFF', // Element Plus 主色
});
</script>
<style scoped>
.loading-spinner {
display: inline-block;
position: relative;
}
.spinner {
animation: rotate 2s linear infinite;
}
.path {
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<el-autocomplete
v-model="query"
:fetch-suggestions="fetchSuggestions"
placeholder="请输入搜索内容"
clearable
@select="handleSelect"
@input="handleInput"
class="search-input"
>
<template #prepend>
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
</template>
<script setup lang="ts">
import { ref, defineEmits, defineProps, withDefaults } from 'vue';
import { ElAutocomplete, ElIcon } from 'element-plus';
import { Search } from '@element-plus/icons-vue';
import { debounce } from 'lodash-es';
interface Suggestion {
value: string;
[key: string]: any;
}
const props = withDefaults(defineProps<{
modelValue: string;
suggestions?: (queryString: string) => Promise<Suggestion[]> | Suggestion[];
debounceTime?: number;
}>(), {
suggestions: () => [],
debounceTime: 300,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'search', value: string): void;
(e: 'select', item: Suggestion): void;
}>();
const query = ref(props.modelValue);
const fetchSuggestions = (queryString: string, cb: (suggestions: Suggestion[]) => void) => {
const results = props.suggestions(queryString);
if (results instanceof Promise) {
results.then(cb);
} else {
cb(results);
}
};
const debouncedSearch = debounce((value: string) => {
emit('search', value);
}, props.debounceTime);
const handleInput = (value: string) => {
query.value = value;
emit('update:modelValue', value);
debouncedSearch(value);
};
const handleSelect = (item: Record<string, any>) => {
const suggestion = item as Suggestion;
emit('select', suggestion);
emit('search', suggestion.value);
};
</script>
<style scoped>
.search-input {
width: 100%;
}
</style>

View File

@@ -1,50 +0,0 @@
<template>
<el-tag :type="tagType" effect="light" round>
{{ statusText }}
</el-tag>
</template>
<script setup lang="ts">
import { computed, defineProps, withDefaults } from "vue";
import { ElTag } from "element-plus";
type APIKeyStatus = "active" | "inactive" | "error";
const props = withDefaults(
defineProps<{
status: APIKeyStatus;
statusMap?: Record<APIKeyStatus, string>;
}>(),
{
status: "inactive",
statusMap: () => ({
active: "启用",
inactive: "禁用",
error: "错误",
}),
}
);
const tagType = computed(() => {
switch (props.status) {
case "active":
return "success";
case "inactive":
return "warning";
case "error":
return "danger";
default:
return "info";
}
});
const statusText = computed(() => {
return props.statusMap[props.status] || "未知";
});
</script>
<style scoped>
.el-tag {
cursor: default;
}
</style>