feat: 重构前端
This commit is contained in:
5
web/src/components/BaseInfoCard.vue
Normal file
5
web/src/components/BaseInfoCard.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<n-card title="基础统计">
|
||||
<div>基础统计</div>
|
||||
</n-card>
|
||||
</template>
|
@@ -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>
|
@@ -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>
|
28
web/src/components/Layout.vue
Normal file
28
web/src/components/Layout.vue
Normal 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>
|
5
web/src/components/LineChart.vue
Normal file
5
web/src/components/LineChart.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<n-card title="拆线图">
|
||||
<div>拆线图</div>
|
||||
</n-card>
|
||||
</template>
|
@@ -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>
|
3
web/src/components/Logout.vue
Normal file
3
web/src/components/Logout.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<n-button quaternary round>退出</n-button>
|
||||
</template>
|
35
web/src/components/NavBar.vue
Normal file
35
web/src/components/NavBar.vue
Normal 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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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.5、GPT-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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
Reference in New Issue
Block a user