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

View File

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

View File

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

View File

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