Merge branch 'dev' of github.com:tbphp/gpt-load into dev

This commit is contained in:
tbphp
2025-07-08 22:02:45 +08:00
5 changed files with 151 additions and 96 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 627 KiB

View File

@@ -10,7 +10,7 @@ import NavBar from "@/components/NavBar.vue";
<div class="header-content">
<div class="header-brand">
<div class="brand-icon">
<img src="@/assets/logo.png" width="50" alt="" />
<img src="@/assets/logo.png" alt="" />
</div>
<h1 class="brand-title">GPT Load</h1>
</div>
@@ -75,8 +75,11 @@ import NavBar from "@/components/NavBar.vue";
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: 35px;
height: 35px;
img {
height: 100%;
}
}
.brand-title {

View File

@@ -75,6 +75,11 @@ const rules: FormRules = {
message: "请输入分组名称",
trigger: ["blur", "input"],
},
{
pattern: /^[a-z]+$/,
message: "只能输入小写字母",
trigger: ["blur", "input"],
},
],
channel_type: [
{
@@ -214,6 +219,10 @@ function handleClose() {
// 提交表单
async function handleSubmit() {
if (loading.value) {
return;
}
try {
await formRef.value?.validate();
@@ -306,17 +315,6 @@ async function handleSubmit() {
<n-input v-model:value="formData.display_name" placeholder="可选,用于显示的友好名称" />
</n-form-item>
<n-form-item label="描述" path="description">
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="可选,分组描述信息"
:rows="2"
:autosize="{ minRows: 2, maxRows: 2 }"
style="resize: none"
/>
</n-form-item>
<n-form-item label="渠道类型" path="channel_type">
<n-select
v-model:value="formData.channel_type"
@@ -336,6 +334,17 @@ async function handleSubmit() {
placeholder="排序值,数字越小越靠前"
/>
</n-form-item>
<n-form-item label="描述" path="description">
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="可选,分组描述信息"
:rows="2"
:autosize="{ minRows: 2, maxRows: 2 }"
style="resize: none"
/>
</n-form-item>
</div>
<!-- 上游地址 -->

View File

@@ -36,6 +36,8 @@ const stats = ref<GroupStats | null>(null);
const loading = ref(false);
const dialog = useDialog();
const showEditModal = ref(false);
const delLoading = ref(false);
const expandedName = ref<string[]>([]);
onMounted(() => {
loadStats();
@@ -44,6 +46,7 @@ onMounted(() => {
watch(
() => props.group,
() => {
resetPage();
loadStats();
}
);
@@ -76,16 +79,19 @@ function handleGroupEdited(newGroup: Group) {
}
async function handleDelete() {
if (!props.group) {
if (!props.group || delLoading.value) {
return;
}
dialog.warning({
const d = dialog.warning({
title: "删除分组",
content: `确定要删除分组 "${getGroupDisplayName(props.group)}" 吗?此操作不可恢复。`,
positiveText: "确定",
negativeText: "取消",
onPositiveClick: async () => {
d.loading = true;
delLoading.value = true;
try {
if (props.group?.id) {
await keysApi.deleteGroup(props.group.id);
@@ -93,6 +99,9 @@ async function handleDelete() {
}
} catch (error) {
console.error("删除分组失败:", error);
} finally {
d.loading = false;
delLoading.value = false;
}
},
});
@@ -122,6 +131,11 @@ function copyUrl(url: string) {
window.$message.error("复制失败");
});
}
function resetPage() {
showEditModal.value = false;
expandedName.value = [];
}
</script>
<template>
@@ -202,30 +216,44 @@ function copyUrl(url: string) {
<!-- 详细信息区可折叠 -->
<div class="details-section">
<n-collapse>
<n-collapse accordion v-model:expanded-names="expandedName">
<n-collapse-item title="详细信息" name="details">
<div class="details-content">
<div class="detail-section">
<h4 class="section-title">基础信息</h4>
<n-form label-placement="left" label-width="100px">
<n-form-item label="分组名称:">
{{ group?.name || "-" }}
</n-form-item>
<n-form-item label="显示名称:">
{{ group?.display_name || "-" }}
</n-form-item>
<n-form-item label="描述:">
{{ group?.description || "-" }}
</n-form-item>
<n-form-item label="渠道类型:">
{{ group?.channel_type || "-" }}
</n-form-item>
<n-form-item label="测试模型:">
{{ group?.test_model || "-" }}
</n-form-item>
<n-form-item label="排序:">
{{ group?.sort || 0 }}
</n-form-item>
<n-form label-placement="left" label-width="85px" label-align="right">
<n-grid :cols="2">
<n-grid-item>
<n-form-item label="分组名称:">
{{ group?.name || "-" }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="显示名称:">
{{ group?.display_name || "-" }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="渠道类型:">
{{ group?.channel_type || "-" }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="测试模型:">
{{ group?.test_model || "-" }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="排序:">
{{ group?.sort || 0 }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="描述:">
{{ group?.description || "-" }}
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
@@ -235,12 +263,13 @@ function copyUrl(url: string) {
<n-form-item
v-for="(upstream, index) in group?.upstreams ?? []"
:key="index"
class="upstream-item"
:label="`上游 ${index + 1}:`"
>
<span class="upstream-url">{{ upstream.url }}</span>
<n-tag size="small" type="info" class="upstream-weight">
权重: {{ upstream.weight }}
</n-tag>
<span class="upstream-weight">
<n-tag size="small" type="info">权重: {{ upstream.weight }}</n-tag>
</span>
<n-input class="upstream-url" :value="upstream.url" readonly size="small" />
</n-form-item>
</n-form>
</div>
@@ -386,11 +415,11 @@ function copyUrl(url: string) {
font-family: monospace;
font-size: 0.9rem;
color: #374151;
margin-right: 8px;
margin-left: 5px;
}
.upstream-weight {
margin-left: 8px;
min-width: 70px;
}
.config-json {

View File

@@ -4,10 +4,13 @@ import type { APIKey, Group, KeyStatus } from "@/types/models";
import { getGroupDisplayName } from "@/utils/display";
import {
AddCircleOutline,
AlertCircleOutline,
CheckmarkCircle,
CopyOutline,
EyeOffOutline,
EyeOutline,
RemoveCircleOutline,
Search,
} from "@vicons/ionicons5";
import {
NButton,
@@ -63,11 +66,9 @@ const moreOptions = [
{ label: "清空所有无效 Key", key: "clearInvalid", props: { style: { color: "#d03050" } } },
];
// 防抖定时器
let searchTimer: ReturnType<typeof setTimeout> | undefined = undefined;
let testingMsg: any = null;
let restoreMsg: any = null;
let deleteMsg: any = null;
const isDeling = ref(false);
const isRestoring = ref(false);
const createDialogShow = ref(false);
const deleteDialogShow = ref(false);
@@ -76,7 +77,7 @@ watch(
() => props.selectedGroup,
async newGroup => {
if (newGroup) {
currentPage.value = 1;
resetPage();
await loadKeys();
}
},
@@ -89,15 +90,8 @@ watch([currentPage, pageSize, statusFilter], async () => {
// 处理搜索输入的防抖
function handleSearchInput() {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(async () => {
currentPage.value = 1; // 搜索时重置到第一页
await loadKeys();
}, 500);
currentPage.value = 1; // 搜索时重置到第一页
loadKeys();
}
// 处理更多操作菜单
@@ -178,6 +172,7 @@ async function testKey(_key: KeyRow) {
try {
const res = await keysApi.testKeys(props.selectedGroup.id, _key.key_value);
const curValid = res?.[0] || {};
_key.status = curValid.is_valid ? "active" : "invalid";
if (curValid.is_valid) {
window.$message.success("密钥测试成功");
} else {
@@ -196,11 +191,11 @@ function toggleKeyVisibility(key: KeyRow) {
}
async function restoreKey(key: KeyRow) {
if (!props.selectedGroup?.id || !key.key_value || restoreMsg) {
if (!props.selectedGroup?.id || !key.key_value || isRestoring.value) {
return;
}
dialog.warning({
const d = dialog.warning({
title: "恢复密钥",
content: `确定要恢复密钥"${maskKey(key.key_value)}"吗?`,
positiveText: "确定",
@@ -209,9 +204,9 @@ async function restoreKey(key: KeyRow) {
if (!props.selectedGroup?.id) {
return;
}
restoreMsg = window.$message.info("正在恢复密钥...", {
duration: 0,
});
isRestoring.value = true;
d.loading = true;
try {
await keysApi.restoreKeys(props.selectedGroup.id, key.key_value);
@@ -219,19 +214,19 @@ async function restoreKey(key: KeyRow) {
} catch (_error) {
console.error("恢复失败");
} finally {
restoreMsg?.destroy();
restoreMsg = null;
d.loading = false;
isRestoring.value = false;
}
},
});
}
async function deleteKey(key: KeyRow) {
if (!props.selectedGroup?.id || !key.key_value || deleteMsg) {
if (!props.selectedGroup?.id || !key.key_value || isDeling.value) {
return;
}
dialog.warning({
const d = dialog.warning({
title: "删除密钥",
content: `确定要删除密钥"${maskKey(key.key_value)}"吗?`,
positiveText: "确定",
@@ -240,9 +235,9 @@ async function deleteKey(key: KeyRow) {
if (!props.selectedGroup?.id) {
return;
}
deleteMsg = window.$message.info("正在删除密钥...", {
duration: 0,
});
d.loading = true;
isDeling.value = true;
try {
await keysApi.deleteKeys(props.selectedGroup.id, key.key_value);
@@ -250,8 +245,8 @@ async function deleteKey(key: KeyRow) {
} catch (_error) {
console.error("删除失败");
} finally {
deleteMsg?.destroy();
deleteMsg = null;
d.loading = false;
isDeling.value = false;
}
},
});
@@ -309,11 +304,11 @@ async function copyInvalidKeys() {
}
async function restoreAllInvalid() {
if (!props.selectedGroup?.id || restoreMsg) {
if (!props.selectedGroup?.id || isRestoring.value) {
return;
}
dialog.warning({
const d = dialog.warning({
title: "恢复密钥",
content: "确定要恢复所有无效密钥吗?",
positiveText: "确定",
@@ -322,18 +317,17 @@ async function restoreAllInvalid() {
if (!props.selectedGroup?.id) {
return;
}
restoreMsg = window.$message.info("正在恢复密钥...", {
duration: 0,
});
isRestoring.value = true;
d.loading = true;
try {
await keysApi.restoreAllInvalidKeys(props.selectedGroup.id);
await loadKeys();
} catch (_error) {
console.error("恢复失败");
} finally {
restoreMsg?.destroy();
restoreMsg = null;
d.loading = false;
isRestoring.value = false;
}
},
});
@@ -360,11 +354,11 @@ async function validateAllKeys() {
}
async function clearAllInvalid() {
if (!props.selectedGroup?.id || deleteMsg) {
if (!props.selectedGroup?.id || isDeling.value) {
return;
}
dialog.warning({
const d = dialog.warning({
title: "清除密钥",
content: "确定要清除所有无效密钥吗?此操作不可恢复!",
positiveText: "确定",
@@ -373,10 +367,9 @@ async function clearAllInvalid() {
if (!props.selectedGroup?.id) {
return;
}
deleteMsg = window.$message.info("正在清除密钥...", {
duration: 0,
});
isDeling.value = true;
d.loading = true;
try {
const { data } = await keysApi.clearAllInvalidKeys(props.selectedGroup.id);
window.$message.success(data?.message || "清除成功");
@@ -384,8 +377,8 @@ async function clearAllInvalid() {
} catch (_error) {
console.error("删除失败");
} finally {
deleteMsg?.destroy();
deleteMsg = null;
d.loading = false;
isDeling.value = false;
}
},
});
@@ -399,6 +392,12 @@ function changePageSize(size: number) {
pageSize.value = size;
currentPage.value = 1;
}
function resetPage() {
currentPage.value = 1;
searchText.value = "";
statusFilter.value = "all";
}
</script>
<template>
@@ -427,14 +426,19 @@ function changePageSize(size: number) {
size="small"
style="width: 100px"
/>
<n-input
v-model:value="searchText"
placeholder="Key 模糊查询"
size="small"
style="width: 180px"
clearable
@input="handleSearchInput"
/>
<n-input-group>
<n-input
v-model:value="searchText"
placeholder="Key 模糊查询"
size="small"
style="width: 180px"
clearable
@keyup.enter="handleSearchInput"
/>
<n-button ghost size="small" :disabled="loading" @click="handleSearchInput">
<n-icon :component="Search" />
</n-button>
</n-input-group>
<n-dropdown :options="moreOptions" trigger="click" @select="handleMoreAction">
<n-button size="small" secondary>
<template #icon>
@@ -462,8 +466,18 @@ function changePageSize(size: number) {
<!-- 主要信息行Key + 快速操作 -->
<div class="key-main">
<div class="key-section">
<n-tag v-if="key.status === 'active'" type="info">有效</n-tag>
<n-tag v-else>无效</n-tag>
<n-tag v-if="key.status === 'active'" type="success" :bordered="false" round>
<template #icon>
<n-icon :component="CheckmarkCircle" />
</template>
有效
</n-tag>
<n-tag v-else :bordered="false" round>
<template #icon>
<n-icon :component="AlertCircleOutline" />
</template>
无效
</n-tag>
<n-input
class="key-text"
:value="key.is_visible ? key.key_value : maskKey(key.key_value)"
@@ -773,8 +787,8 @@ function changePageSize(size: number) {
}
.key-card.status-invalid {
border-color: #d030503b;
background: #d0305014;
border-color: #ddd;
background: rgb(250, 250, 252);
}
.key-card.status-error {