feat: 上游 Key 唯一化、分组导入跳过、账号导入平台识别&远端校验&base_url 注入

- 上游 Key 命名改为 {prefix}-{upstream.id}-{safe_group_name}-{group_id}
- 唯一约束 (upstream_id, group_id, managed_prefix) 加 managed_prefix 列
- 上游检测成功时同步 Key 状态,远端已删/分组已删自动清理
- 重复分组导入跳过,目标网站已存在同名分组返回 exists
- 账号导入平台自动识别(auto/manual 模式)
- 全选可导入 Key 按钮 + 目标分组自动匹配
- 导入幂等:已导入过的 Key 校验远端账号,不存在则重建
- 新增同步接口 POST /sync-imported-upstream-keys
- account_exists() 通过拉取账号列表判断,避免 404 误判
- credentials.base_url 注入来源上游地址,避免 401
- 前端导入弹窗自动同步+刷新按钮+并发/优先级设置
- 新增 12 个测试覆盖同步、幂等、远端删除、校验失败路径
This commit is contained in:
liumangmang
2026-05-21 01:16:39 +08:00
parent 0a27bba296
commit 6044b00685
18 changed files with 3112 additions and 50 deletions
+96 -4
View File
@@ -1,7 +1,28 @@
import axios from 'axios'
import axiosRetry from 'axios-retry'
import router from '@/router'
import { authStorageKeys } from '@/authStorage'
/** 标记是否正在处理 401,防多个并发 */
let isHandlingUnauthorized = false
/** 统一的 401 处理:清登录态 + 提示 + 跳转 /login */
async function handleUnauthorized() {
if (isHandlingUnauthorized) return
isHandlingUnauthorized = true
try {
const { useAuthStore } = await import('@/stores/auth')
useAuthStore().clear()
const { ElMessage } = await import('element-plus')
ElMessage.warning('登录已过期,请重新登录')
if (router.currentRoute.value.path !== '/login') {
await router.replace('/login')
}
} finally {
isHandlingUnauthorized = false
}
}
export const api = axios.create({
baseURL: '/',
@@ -27,10 +48,13 @@ axiosRetry(api, {
api.interceptors.response.use(
(r) => r,
(err) => {
// 跳过登录接口的 401(密码错误等正常登录失败场景)
const requestPath = new URL(err.config?.url || '', window.location.origin).pathname
if (err.response?.status === 401 && requestPath === '/api/auth/login') {
return Promise.reject(err)
}
if (err.response?.status === 401) {
localStorage.removeItem(authStorageKeys.token)
localStorage.removeItem(authStorageKeys.email)
router.push('/login')
void handleUnauthorized()
}
return Promise.reject(err)
}
@@ -84,6 +108,34 @@ export interface UpstreamForm {
balance_divisor: number
}
export interface GeneratedUpstreamKey {
id: number | null
upstream_id: number
group_id: string
group_name: string
key_id: string | null
key_name: string
key_value: string | null
masked_key: string
status: string
error: string | null
imported_website_id: number | null
imported_account_id: string | null
imported_at: string | null
created_at: string | null
}
export interface GenerateKeysByGroupsForm {
group_ids: string[]
name_prefix: string
quota: number
expires_in_days?: number | null
rate_limit_5h: number
rate_limit_1d: number
rate_limit_7d: number
endpoint: string
}
export const upstreamsApi = {
list: () => api.get<UpstreamData[]>('/api/upstreams'),
create: (data: UpstreamForm) => api.post<UpstreamData>('/api/upstreams', data),
@@ -91,6 +143,9 @@ export const upstreamsApi = {
delete: (id: number) => api.delete(`/api/upstreams/${id}`),
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/upstreams/${id}/test`),
checkNow: (id: number) => api.post<{ success: boolean; message: string }>(`/api/upstreams/${id}/check-now`),
generatedKeys: (id: number) => api.get<GeneratedUpstreamKey[]>(`/api/upstreams/${id}/generated-keys`),
generateKeysByGroups: (id: number, data: GenerateKeysByGroupsForm) =>
api.post<{ success: boolean; message: string; items: GeneratedUpstreamKey[] }>(`/api/upstreams/${id}/keys/generate-by-groups`, data),
latestSnapshot: (id: number) => api.get(`/api/upstreams/${id}/snapshots/latest`),
listSnapshots: (id: number, limit = 20, offset = 0) =>
api.get<any[]>(`/api/upstreams/${id}/snapshots`, { params: { limit, offset } }),
@@ -185,6 +240,30 @@ export interface WebsiteSyncLog {
created_at: string
}
export interface ImportGroupItem {
source_group_id: string
source_group_name: string
target_group_id: string | null
target_group_name: string
status: string
message: string
raw: Record<string, any>
}
export interface ImportAccountItem {
upstream_key_id: number
source_group_id: string
source_group_name: string
target_group_id: string | null
account_id: string | null
account_name: string
platform: string
upstream_base_url: string
status: string
message: string
raw: Record<string, any>
}
export const websitesApi = {
list: () => api.get<WebsiteData[]>('/api/websites'),
create: (data: WebsiteForm) => api.post<WebsiteData>('/api/websites', data),
@@ -192,6 +271,19 @@ export const websitesApi = {
delete: (id: number) => api.delete(`/api/websites/${id}`),
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/websites/${id}/test`),
groups: (id: number) => api.get<WebsiteGroup[]>(`/api/websites/${id}/groups`),
importGroupsFromUpstream: (id: number, upstreamId: number, data: { group_ids: string[]; name_prefix: string }) =>
api.post<{ success: boolean; message: string; items: ImportGroupItem[] }>(`/api/websites/${id}/groups/import-from-upstream/${upstreamId}`, data),
syncImportedUpstreamKeys: (id: number, data: { upstream_id: number }) =>
api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/sync-imported-upstream-keys`, data),
importAccountsFromUpstreamKeys: (id: number, data: {
upstream_key_ids: number[]
target_group_map: Record<string, string>
account_name_prefix: string
default_platform: string
platform_mode?: string
concurrency?: number
priority?: number
}) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data),
listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
+152 -2
View File
@@ -127,6 +127,9 @@
</el-button>
<el-button size="small" text @click="testUpstream(row)" :loading="row._testing">测试</el-button>
<el-button size="small" text @click="checkNow(row)" :loading="row._checking">检测</el-button>
<el-button size="small" text @click="openKeyGenerate(row)" title="确保每个分组有一个 SmartUp Key">
<el-icon><Key /></el-icon>
</el-button>
<el-button size="small" text @click="openDetail(row)">
<el-icon><List /></el-icon>
详情
@@ -374,6 +377,26 @@
<span>{{ detailUpstream.last_error }}</span>
</div>
<div class="section-title">
<el-icon><Key /></el-icon>
已创建 Key
<span class="section-sub">最近 {{ generatedKeys.length }} </span>
</div>
<el-table :data="generatedKeys" v-loading="keysLoading" size="small" style="width: 100%" class="generated-key-table">
<el-table-column prop="group_name" label="分组" min-width="120" />
<el-table-column prop="key_name" label="名称" min-width="180" />
<el-table-column label="Key" min-width="140">
<template #default="{ row }"><span class="mono">{{ row.masked_key || '—' }}</span></template>
</el-table-column>
<el-table-column label="状态" width="96">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'import_failed' || row.status === 'failed' ? 'danger' : row.status === 'imported' ? 'success' : 'info'">
{{ keyStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="section-title">
<el-icon><Clock /></el-icon>
检测历史
@@ -440,6 +463,57 @@
</div>
</el-drawer>
<el-dialog v-model="keyDialogVisible" title="按分组创建 Key" width="620px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="上游">
<el-input :model-value="keyTarget?.name || ''" disabled />
</el-form-item>
<el-form-item label="选择分组">
<el-select v-model="keyForm.group_ids" multiple filterable style="width:100%" placeholder="不选则创建全部分组">
<el-option v-for="group in keyGroupOptions" :key="group.group_id" :label="`${group.group_name || group.group_id} (${group.rate || '—'})`" :value="group.group_id" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="名称前缀"><el-input v-model="keyForm.name_prefix" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="创建接口"><el-input v-model="keyForm.endpoint" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="配额 USD0 不限)"><el-input-number v-model="keyForm.quota" :min="0" :precision="2" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="有效天数"><el-input-number v-model="keyExpiresDays" :min="1" :disabled="!useKeyExpiry" style="width:100%" /></el-form-item>
</el-col>
</el-row>
<el-checkbox v-model="useKeyExpiry">设置过期时间</el-checkbox>
</el-form>
<div v-if="keyResults.length" class="result-panel">
<div class="result-title">操作结果</div>
<el-table :data="keyResults" size="small">
<el-table-column prop="group_name" label="分组" min-width="120" />
<el-table-column prop="key_name" label="名称" min-width="180" />
<el-table-column label="Key" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.key_value || row.masked_key || '—' }}</span></template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.status === 'created'" size="small" type="success">新创建</el-tag>
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
<el-tag v-else size="small" type="danger">失败</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="keyDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="generatingKeys" :disabled="generatingKeys" @click="generateKeys">确保 Key 存在</el-button>
</template>
</el-dialog>
<AuthCaptureDialog
v-model="authCaptureVisible"
:initial-url="authCaptureInitialUrl"
@@ -453,8 +527,8 @@ import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer } from '@element-plus/icons-vue'
import { upstreamsApi, type UpstreamData } from '@/api'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue'
import { upstreamsApi, type GeneratedUpstreamKey, type UpstreamData } from '@/api'
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
@@ -580,11 +654,30 @@ function handlePlatformChange(val: string) {
const detailVisible = ref(false)
const detailUpstream = ref<UpstreamData | null>(null)
const snapshots = ref<any[]>([])
const generatedKeys = ref<GeneratedUpstreamKey[]>([])
const snapshotLoading = ref(false)
const keysLoading = ref(false)
const expandedId = ref<number | null>(null)
const snapshotOffset = ref(0)
const snapshotLimit = 20
const keyDialogVisible = ref(false)
const keyTarget = ref<UpstreamData | null>(null)
const keyGroupOptions = ref<any[]>([])
const generatingKeys = ref(false)
const keyResults = ref<GeneratedUpstreamKey[]>([])
const useKeyExpiry = ref(false)
const keyExpiresDays = ref(30)
const keyForm = ref({
group_ids: [] as string[],
name_prefix: 'SmartUp',
quota: 0,
rate_limit_5h: 0,
rate_limit_1d: 0,
rate_limit_7d: 0,
endpoint: '/keys',
})
const metrics = computed(() => ({
total: list.value.length,
healthy: list.value.filter((item) => item.last_status === 'healthy').length,
@@ -641,6 +734,8 @@ function shrinkError(value: string) {
return value.length > 40 ? `${value.slice(0, 40)}` : value
}
const keyStatusLabel = (s: string) => ({ created: '已创建', imported: '已导入', import_failed: '导入失败', failed: '失败' }[s] || s)
async function loadList() {
tableLoading.value = true
try {
@@ -735,13 +830,26 @@ async function checkNow(row: any) {
function openDetail(row: UpstreamData) {
detailUpstream.value = row
snapshots.value = []
generatedKeys.value = []
snapshotOffset.value = 0
expandedId.value = null
detailVisible.value = true
}
async function loadGeneratedKeys() {
if (!detailUpstream.value) return
keysLoading.value = true
try {
const res = await upstreamsApi.generatedKeys(detailUpstream.value.id)
generatedKeys.value = res.data
} finally {
keysLoading.value = false
}
}
async function loadSnapshots() {
if (!detailUpstream.value) return
loadGeneratedKeys()
snapshotLoading.value = true
try {
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
@@ -756,6 +864,48 @@ async function loadSnapshots() {
}
}
async function openKeyGenerate(row: UpstreamData) {
keyTarget.value = row
keyResults.value = []
keyForm.value = {
group_ids: [],
name_prefix: 'SmartUp',
quota: 0,
rate_limit_5h: 0,
rate_limit_1d: 0,
rate_limit_7d: 0,
endpoint: '/keys',
}
useKeyExpiry.value = false
keyExpiresDays.value = 30
try {
const res = await upstreamsApi.latestSnapshot(row.id)
keyGroupOptions.value = Object.values(res.data.snapshot?.groups || {})
} catch {
keyGroupOptions.value = []
ElMessage.warning('未找到快照,将由后端实时拉取分组')
}
keyDialogVisible.value = true
}
async function generateKeys() {
if (!keyTarget.value) return
generatingKeys.value = true
try {
const res = await upstreamsApi.generateKeysByGroups(keyTarget.value.id, {
...keyForm.value,
expires_in_days: useKeyExpiry.value ? keyExpiresDays.value : null,
})
keyResults.value = res.data.items
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
if (detailUpstream.value?.id === keyTarget.value.id) await loadGeneratedKeys()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '创建 Key 失败')
} finally {
generatingKeys.value = false
}
}
function toggleExpand(snap: any) {
expandedId.value = expandedId.value === snap.id ? null : snap.id
}
+615 -23
View File
@@ -14,7 +14,11 @@
<div class="panel">
<div class="panel-head">
<div class="panel-title">网站</div>
<el-button size="small" text @click="loadAll">刷新</el-button>
<div class="panel-actions">
<el-button size="small" text :disabled="websites.length === 0" @click="openImportGroups(selectedWebsite || websites[0])">导入上游分组</el-button>
<el-button size="small" text :disabled="websites.length === 0" @click="openImportAccounts(selectedWebsite || websites[0])">导入为账号管理账号</el-button>
<el-button size="small" text @click="loadAll">刷新</el-button>
</div>
</div>
<el-table :data="websites" v-loading="websiteLoading" row-key="id" style="width:100%">
<el-table-column label="名称" min-width="180">
@@ -44,34 +48,43 @@
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="174" align="right">
<el-table-column label="操作" width="240" align="right">
<template #default="{ row }">
<div class="action-row">
<el-tooltip content="编辑网站配置" placement="top" :show-after="300">
<el-button size="small" text class="btn-edit" @click="openWebsiteEdit(row)">
<el-icon class="btn-edit-icon"><Edit /></el-icon><span>编辑</span>
</el-button>
</el-tooltip>
<el-tooltip content="查看分组" placement="top" :show-after="300">
<el-button size="small" circle text @click="selectWebsite(row)">
<el-icon><Grid /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="编辑" placement="top" :show-after="300">
<el-button size="small" circle text @click="openWebsiteEdit(row)">
<el-icon><Edit /></el-icon>
<el-dropdown trigger="click" @command="(cmd: string) => handleMoreAction(cmd, row)">
<el-button size="small" text class="btn-more" :loading="row._testing">
更多<el-icon v-if="!row._testing" class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="连接测试" placement="top" :show-after="300">
<el-button size="small" circle text :loading="row._testing" @click="testWebsite(row)">
<el-icon v-if="!row._testing"><Connection /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="新增绑定" placement="top" :show-after="300">
<el-button size="small" circle text @click="openBindingCreate(row)">
<el-icon><Link /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top" :show-after="300">
<el-button size="small" circle text type="danger" @click="deleteWebsite(row)">
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="test" :disabled="row._testing">
<el-icon><Connection /></el-icon>连接测试
</el-dropdown-item>
<el-dropdown-item command="binding">
<el-icon><Link /></el-icon>新增绑定
</el-dropdown-item>
<el-dropdown-item command="importGroups">
<el-icon><Upload /></el-icon>导入上游分组
</el-dropdown-item>
<el-dropdown-item command="importAccounts">
<el-icon><Key /></el-icon>导入为账号管理账号
</el-dropdown-item>
<el-dropdown-item divided command="delete" class="btn-more-delete">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
@@ -229,6 +242,188 @@
<el-button type="primary" :loading="savingBinding" @click="saveBinding">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="importGroupsDialog" title="导入上游分组" width="680px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="目标网站">
<el-select v-model="importGroupsForm.website_id" style="width:100%">
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
<el-form-item label="来源上游">
<el-select v-model="importGroupsForm.upstream_id" filterable style="width:100%" @change="importGroupsForm.group_ids = []">
<el-option v-for="upstream in upstreams" :key="upstream.id" :label="upstream.name" :value="upstream.id" />
</el-select>
</el-form-item>
<el-form-item label="上游分组">
<el-select v-model="importGroupsForm.group_ids" multiple filterable style="width:100%" placeholder="不选则导入全部分组">
<el-option
v-for="group in importSourceGroups"
:key="sourceGroupId(group)"
:label="`${sourceGroupName(group)} (${group.rate || group.rate_multiplier || '—'})`"
:value="sourceGroupId(group)"
/>
</el-select>
</el-form-item>
<el-form-item label="分组名前缀">
<el-input v-model="importGroupsForm.name_prefix" placeholder="可留空,参数和倍率保持上游一致" />
</el-form-item>
</el-form>
<div v-if="importGroupResults.length" class="result-panel">
<div class="result-title">导入结果</div>
<el-table :data="importGroupResults" size="small">
<el-table-column prop="source_group_name" label="上游分组" min-width="140" />
<el-table-column prop="target_group_name" label="我的分组" min-width="160" />
<el-table-column prop="target_group_id" label="目标 ID" width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'created'" size="small" type="success">已创建</el-tag>
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
<el-tag v-else size="small" type="danger">失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="结果" min-width="160" />
</el-table>
</div>
<template #footer>
<el-button @click="importGroupsDialog = false">关闭</el-button>
<el-button type="primary" :loading="importingGroups" @click="submitImportGroups">导入分组</el-button>
</template>
</el-dialog>
<el-dialog v-model="importAccountsDialog" title="导入为账号管理账号" width="760px" destroy-on-close>
<el-form label-position="top">
<el-alert
class="dialog-note"
type="info"
show-icon
:closable="false"
title="这里会把已生成的上游 Key 创建成 Sub2API 账号管理里的 apikey 账号,不会创建系统登录用户。"
/>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="目标网站">
<el-select v-model="importAccountsForm.website_id" style="width:100%" @change="onImportAccountWebsiteChange">
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账号平台">
<el-select v-model="importAccountsForm.platform_mode" style="width:100%" @change="onPlatformModeChange">
<el-option label="自动识别(按 Key/分组名判断)" value="auto" />
<el-option label="手动选择" value="manual" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="固定平台(手动模式)">
<el-select v-model="importAccountsForm.default_platform" style="width:100%" :disabled="importAccountsForm.platform_mode === 'auto'">
<el-option label="OpenAI 兼容" value="openai" />
<el-option label="Anthropic" value="anthropic" />
<el-option label="Gemini" value="gemini" />
<el-option label="Antigravity" value="antigravity" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24" style="margin-bottom:6px">
<el-button size="small" text :loading="syncingImportStatus" @click="syncImportStatus">
<el-icon><Refresh /></el-icon>刷新导入状态
</el-button>
<span v-if="importSyncStatus" style="font-size:12px;color:var(--text-muted);margin-left:8px">
已校验 {{ importSyncStatus.total }} 清除 {{ importSyncStatus.cleared }} 个失效标记
</span>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="来源上游">
<el-select v-model="importAccountsForm.upstream_id" filterable style="width:100%" @change="onImportAccountUpstreamChange">
<el-option v-for="upstream in upstreams" :key="upstream.id" :label="upstream.name" :value="upstream.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账号名前缀">
<el-input v-model="importAccountsForm.account_name_prefix" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="并发/容量">
<el-input-number v-model="importAccountsForm.concurrency" :min="1" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级">
<el-input-number v-model="importAccountsForm.priority" :min="0" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="已生成的上游 Key">
<div style="display:flex;gap:8px;margin-bottom:6px">
<el-button size="small" text @click="selectAllImportableKeys">全选可导入 Key</el-button>
<el-button size="small" text @click="clearImportAccountSelection">清空</el-button>
</div>
<el-select
v-model="importAccountsForm.upstream_key_ids"
multiple
filterable
style="width:100%"
placeholder="选择要导入为账号管理账号的 Key"
:loading="generatedKeyLoading"
@change="autoFillAccountTargetGroups()"
>
<el-option
v-for="item in importableGeneratedKeys"
:key="item.id!"
:label="`${item.group_name || item.group_id} / ${detectPlatform(item)} / ${item.key_name} / ${item.masked_key}`"
:value="item.id!"
/>
</el-select>
</el-form-item>
<div v-if="selectedAccountGroups.length" class="mapping-panel">
<div class="result-title">目标分组映射</div>
<div v-for="group in selectedAccountGroups" :key="group.group_id" class="mapping-row">
<span class="mapping-label">{{ group.group_name || group.group_id }}</span>
<el-select v-model="importAccountsForm.target_group_map[group.group_id]" clearable filterable placeholder="可不选" style="width:280px">
<el-option v-for="target in importTargetGroups" :key="target.id" :label="`${target.name} (${target.rate_multiplier ?? '—'})`" :value="target.id" />
</el-select>
</div>
</div>
</el-form>
<div v-if="importAccountResults.length" class="result-panel">
<div class="result-title">创建结果</div>
<el-table :data="importAccountResults" size="small">
<el-table-column prop="source_group_name" label="来源分组" min-width="130" />
<el-table-column prop="account_name" label="账号管理账号" min-width="180" />
<el-table-column label="识别平台" width="120">
<template #default="{ row }">
<span>{{ platformLabel(row.platform) }}</span>
</template>
</el-table-column>
<el-table-column label="请求地址" min-width="180">
<template #default="{ row }">
<span class="mono" style="font-size:12px">{{ row.upstream_base_url || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="account_id" label="账号 ID" width="110" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'created'" size="small" type="success">成功</el-tag>
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
<el-tag v-else size="small" type="danger">失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="结果" min-width="160" />
</el-table>
</div>
<template #footer>
<el-button @click="importAccountsDialog = false">关闭</el-button>
<el-button type="primary" :loading="importingAccounts" :disabled="importingAccounts" @click="submitImportAccounts">创建账号管理账号</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -237,13 +432,16 @@ import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Delete, Edit, Plus, Grid, Connection, Link } from '@element-plus/icons-vue'
import { ArrowDown, Delete, Edit, Plus, Grid, Connection, Link, Upload, Key, Refresh } from '@element-plus/icons-vue'
import {
upstreamsApi,
websitesApi,
type BindingSourceGroup,
type GeneratedUpstreamKey,
type GroupBindingData,
type GroupBindingForm,
type ImportAccountItem,
type ImportGroupItem,
type UpstreamData,
type WebsiteData,
type WebsiteForm,
@@ -259,16 +457,25 @@ const bindingWebsiteGroups = ref<WebsiteGroup[]>([])
const bindings = ref<(GroupBindingData & { _syncing?: boolean })[]>([])
const logs = ref<WebsiteSyncLog[]>([])
const snapshotsByUpstream = ref<Record<number, any[]>>({})
const importTargetGroups = ref<WebsiteGroup[]>([])
const importGeneratedKeys = ref<GeneratedUpstreamKey[]>([])
const websiteLoading = ref(false)
const groupsLoading = ref(false)
const bindingLoading = ref(false)
const logLoading = ref(false)
const importingGroups = ref(false)
const importingAccounts = ref(false)
const generatedKeyLoading = ref(false)
const importSyncStatus = ref<{ total: number; cleared: number; failed: number } | null>(null)
const syncingImportStatus = ref(false)
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
const algorithmLabel = (s: string) => ({ max_plus_percent: '最高倍率', average_plus_percent: '平均倍率', min_plus_percent: '最低倍率' }[s] || s)
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
const sourceGroupId = (group: any) => String(group?.group_id || group?.id || group?.name || '')
const sourceGroupName = (group: any) => String(group?.group_name || group?.name || sourceGroupId(group))
function defaultWebsiteForm(): WebsiteForm {
return {
@@ -318,6 +525,29 @@ const bindingRules = {
target_group_id: [{ required: true, message: '请选择目标分组', trigger: 'change' }],
}
const importGroupsDialog = ref(false)
const importGroupsForm = ref({
website_id: 0,
upstream_id: 0,
group_ids: [] as string[],
name_prefix: '',
})
const importGroupResults = ref<ImportGroupItem[]>([])
const importAccountsDialog = ref(false)
const importAccountsForm = ref({
website_id: 0,
upstream_id: 0,
upstream_key_ids: [] as number[],
target_group_map: {} as Record<string, string>,
account_name_prefix: 'SmartUp',
default_platform: 'openai',
platform_mode: 'auto',
concurrency: 10,
priority: 1,
})
const importAccountResults = ref<ImportAccountItem[]>([])
const upstreamGroupOptions = computed(() => {
const rows: Array<{ key: string; label: string; source: BindingSourceGroup }> = []
for (const upstream of upstreams.value) {
@@ -339,6 +569,31 @@ const upstreamGroupOptions = computed(() => {
return rows
})
const importSourceGroups = computed(() => snapshotsByUpstream.value[importGroupsForm.value.upstream_id] || [])
function isImportableGeneratedKey(item: GeneratedUpstreamKey) {
return item.id !== null
&& item.status !== 'failed'
&& !(item.imported_website_id === importAccountsForm.value.website_id && item.imported_account_id)
}
const importableGeneratedKeys = computed(() =>
importGeneratedKeys.value.filter(isImportableGeneratedKey),
)
const selectedAccountGroups = computed(() => {
const selected = new Set(importAccountsForm.value.upstream_key_ids)
const rows = importableGeneratedKeys.value.filter((item) => item.id !== null && selected.has(item.id))
const seen = new Set<string>()
const groups: Array<{ group_id: string; group_name: string }> = []
for (const row of rows) {
if (seen.has(row.group_id)) continue
seen.add(row.group_id)
groups.push({ group_id: row.group_id, group_name: row.group_name })
}
return groups
})
async function loadWebsites() {
websiteLoading.value = true
try {
@@ -391,6 +646,19 @@ async function loadBindingWebsiteGroups(websiteId: number) {
}
}
async function loadImportTargetGroups(websiteId: number) {
if (!websiteId) {
importTargetGroups.value = []
return
}
try {
const res = await websitesApi.groups(websiteId)
importTargetGroups.value = res.data
} catch {
importTargetGroups.value = []
}
}
async function loadBindings() {
bindingLoading.value = true
try {
@@ -411,6 +679,50 @@ async function loadLogs() {
}
}
async function syncImportStatus() {
const websiteId = importAccountsForm.value.website_id
const upstreamId = importAccountsForm.value.upstream_id
if (!websiteId || !upstreamId) return
syncingImportStatus.value = true
try {
const res = await websitesApi.syncImportedUpstreamKeys(websiteId, { upstream_id: upstreamId })
// 校验请求完成时表单未切换
if (importAccountsForm.value.website_id !== websiteId || importAccountsForm.value.upstream_id !== upstreamId) return
const items = res.data.items
importSyncStatus.value = {
total: items.length,
cleared: items.filter(i => i.status === 'stale_cleared').length,
failed: items.filter(i => i.status === 'check_failed').length,
}
if (importSyncStatus.value.cleared > 0) {
ElMessage.success(`已清除 ${importSyncStatus.value.cleared} 个失效导入标记`)
}
if (importAccountsForm.value.upstream_id === upstreamId) {
await loadImportGeneratedKeys(upstreamId)
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '同步导入状态失败')
} finally {
syncingImportStatus.value = false
}
}
async function loadImportGeneratedKeys(upstreamId: number) {
importGeneratedKeys.value = []
if (!upstreamId) return
generatedKeyLoading.value = true
const frozenId = upstreamId
try {
const res = await upstreamsApi.generatedKeys(frozenId)
if (importAccountsForm.value.upstream_id !== frozenId) return // 已切换到其他上游
importGeneratedKeys.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '加载上游 Key 失败')
} finally {
generatedKeyLoading.value = false
}
}
async function loadAll() {
await Promise.all([loadWebsites(), loadUpstreamGroups(), loadBindings(), loadLogs()])
if (selectedWebsite.value) await loadWebsiteGroups()
@@ -487,6 +799,17 @@ async function testWebsite(row: WebsiteData & { _testing?: boolean }) {
}
}
/** 处理「更多」下拉菜单中的操作 */
function handleMoreAction(cmd: string, row: WebsiteData & { _testing?: boolean }) {
switch (cmd) {
case 'test': testWebsite(row); break
case 'binding': openBindingCreate(row); break
case 'importGroups': openImportGroups(row); break
case 'importAccounts': openImportAccounts(row); break
case 'delete': deleteWebsite(row); break
}
}
async function deleteWebsite(row: WebsiteData) {
try {
await ElMessageBox.confirm(`确认删除网站 "${row.name}"`, '删除确认', { type: 'warning' })
@@ -599,6 +922,197 @@ async function deleteBinding(row: GroupBindingData) {
} catch {}
}
async function openImportGroups(site?: WebsiteData | null) {
if (upstreams.value.length === 0) await loadUpstreamGroups()
const target = site || selectedWebsite.value || websites.value[0]
importGroupsForm.value = {
website_id: target?.id || 0,
upstream_id: upstreams.value[0]?.id || 0,
group_ids: [],
name_prefix: '',
}
importGroupResults.value = []
importGroupsDialog.value = true
}
async function submitImportGroups() {
if (!importGroupsForm.value.website_id || !importGroupsForm.value.upstream_id) {
ElMessage.error('请选择目标网站和来源上游')
return
}
importingGroups.value = true
try {
const res = await websitesApi.importGroupsFromUpstream(
importGroupsForm.value.website_id,
importGroupsForm.value.upstream_id,
{
group_ids: importGroupsForm.value.group_ids,
name_prefix: importGroupsForm.value.name_prefix,
},
)
importGroupResults.value = res.data.items
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
if (selectedWebsite.value?.id === importGroupsForm.value.website_id) await loadWebsiteGroups()
await loadImportTargetGroups(importGroupsForm.value.website_id)
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '导入上游分组失败')
} finally {
importingGroups.value = false
}
}
async function openImportAccounts(site?: WebsiteData | null) {
if (upstreams.value.length === 0) await loadUpstreamGroups()
const target = site || selectedWebsite.value || websites.value[0]
importAccountsForm.value = {
website_id: target?.id || 0,
upstream_id: upstreams.value[0]?.id || 0,
upstream_key_ids: [],
target_group_map: {},
account_name_prefix: 'SmartUp',
default_platform: 'openai',
platform_mode: 'auto',
concurrency: 10,
priority: 1,
}
importAccountResults.value = []
importSyncStatus.value = null
await Promise.all([
loadImportTargetGroups(importAccountsForm.value.website_id),
loadImportGeneratedKeys(importAccountsForm.value.upstream_id),
])
// 打开弹窗后自动同步导入状态(校验远端账号是否仍存在)
await syncImportStatus()
await loadImportGeneratedKeys(importAccountsForm.value.upstream_id)
importAccountsDialog.value = true
}
async function onImportAccountWebsiteChange(value: number) {
importAccountsForm.value.target_group_map = {}
await loadImportTargetGroups(value)
await syncImportStatus()
await loadImportGeneratedKeys(importAccountsForm.value.upstream_id)
}
async function onImportAccountUpstreamChange(value: number) {
importAccountsForm.value.upstream_key_ids = []
importAccountsForm.value.target_group_map = {}
importAccountResults.value = []
await loadImportGeneratedKeys(value)
await syncImportStatus()
await loadImportGeneratedKeys(value)
}
function onPlatformModeChange(value: string) {
if (value === 'auto') {
importAccountsForm.value.default_platform = 'openai'
}
}
function detectPlatform(item: { group_name?: string; group_id?: string; key_name?: string }) {
const text = `${item.group_name || ''} ${item.group_id || ''} ${item.key_name || ''}`.toLowerCase()
if (text.includes('claude') || text.includes('anthropic')) return 'Anthropic'
if (text.includes('gemini')) return 'Gemini'
if (text.includes('antigravity')) return 'Antigravity'
return 'OpenAI 兼容'
}
function platformLabel(platform: string) {
const map: Record<string, string> = {
openai: 'OpenAI 兼容',
anthropic: 'Anthropic',
gemini: 'Gemini',
antigravity: 'Antigravity',
}
return map[platform] || platform || '—'
}
function normalizeGroupName(name: string) {
return String(name || '')
.toLowerCase()
.replace(/^smartup[-_\s]*/i, '')
.replace(/^ai\d+pro/i, '')
.replace(/[|]/g, ' ')
.replace(/\s+/g, '')
.trim()
}
function findTargetGroupForSource(sourceName: string, sourceId: string) {
const sourceNorm = normalizeGroupName(sourceName || sourceId)
if (!sourceNorm) return ''
const exact = importTargetGroups.value.find(g =>
normalizeGroupName(g.name) === sourceNorm
)
if (exact) return exact.id
const fuzzy = importTargetGroups.value.find(g => {
const targetNorm = normalizeGroupName(g.name)
return targetNorm.includes(sourceNorm) || sourceNorm.includes(targetNorm)
})
return fuzzy?.id || ''
}
function autoFillAccountTargetGroups() {
const selected = new Set(importAccountsForm.value.upstream_key_ids)
const keys = importableGeneratedKeys.value.filter(item => item.id !== null && selected.has(item.id))
const nextMap = { ...importAccountsForm.value.target_group_map }
for (const item of keys) {
if (!item.id || !selected.has(item.id)) continue
if (nextMap[item.group_id]) continue
const targetId = findTargetGroupForSource(item.group_name || item.group_id, item.group_id)
if (targetId) nextMap[item.group_id] = targetId
}
importAccountsForm.value.target_group_map = nextMap
}
function selectAllImportableKeys() {
const keys = importableGeneratedKeys.value
importAccountsForm.value.upstream_key_ids = keys.map(item => item.id!)
autoFillAccountTargetGroups()
const matched = Object.keys(importAccountsForm.value.target_group_map).length
ElMessage.success(`已选择 ${keys.length} 个 Key,自动匹配 ${matched} 个目标分组`)
}
function clearImportAccountSelection() {
importAccountsForm.value.upstream_key_ids = []
importAccountsForm.value.target_group_map = {}
}
async function submitImportAccounts() {
if (!importAccountsForm.value.website_id || !importAccountsForm.value.upstream_id) {
ElMessage.error('请选择目标网站和来源上游')
return
}
if (importAccountsForm.value.upstream_key_ids.length === 0) {
ElMessage.error('请选择要导入的上游 Key')
return
}
importingAccounts.value = true
try {
const res = await websitesApi.importAccountsFromUpstreamKeys(importAccountsForm.value.website_id, {
upstream_key_ids: importAccountsForm.value.upstream_key_ids,
target_group_map: importAccountsForm.value.target_group_map,
account_name_prefix: importAccountsForm.value.account_name_prefix,
default_platform: importAccountsForm.value.default_platform,
platform_mode: importAccountsForm.value.platform_mode,
concurrency: importAccountsForm.value.concurrency,
priority: importAccountsForm.value.priority,
})
importAccountResults.value = res.data.items
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
await loadImportGeneratedKeys(importAccountsForm.value.upstream_id)
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '创建账号管理账号失败')
} finally {
importingAccounts.value = false
}
}
onMounted(loadAll)
</script>
@@ -624,6 +1138,13 @@ onMounted(loadAll)
}
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.panel-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.panel-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
}
.content-grid {
display: grid;
grid-template-columns: 1fr;
@@ -639,7 +1160,7 @@ onMounted(loadAll)
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 2px;
gap: 6px;
min-width: 0;
}
.action-row .el-button.is-circle {
@@ -647,6 +1168,36 @@ onMounted(loadAll)
height: 26px;
margin-left: 0;
}
.action-row .btn-edit {
color: var(--text-primary);
font-weight: 500;
gap: 3px;
padding: 0 6px;
white-space: nowrap;
flex-shrink: 0;
}
.action-row .btn-edit-icon {
font-size: 13px;
}
.action-row .btn-edit:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 4px;
}
.action-row .btn-more {
color: var(--text-muted);
font-size: 12px;
padding: 0 4px;
}
.action-row .btn-more:hover {
color: var(--el-color-primary);
}
.action-row .btn-more .el-icon--right {
margin-left: 1px;
}
.btn-more-delete {
color: var(--el-color-danger);
}
.binding-actions {
display: flex;
@@ -698,6 +1249,41 @@ onMounted(loadAll)
color: var(--text-secondary);
font-size: 13px;
}
.dialog-note { margin-bottom: 12px; }
.result-panel {
margin-top: 14px;
border-top: 1px solid var(--border-color);
padding-top: 12px;
}
.result-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.mapping-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
}
.mapping-row {
min-height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mapping-row + .mapping-row {
border-top: 1px solid var(--border-color);
}
.mapping-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
font-size: 13px;
}
@media (min-width: 1024px) {
.content-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@@ -714,5 +1300,11 @@ onMounted(loadAll)
flex-direction: column;
}
.binding-actions { width: 100%; justify-content: flex-end; }
.mapping-row {
align-items: stretch;
flex-direction: column;
padding: 8px 0;
}
.mapping-row .el-select { width: 100% !important; }
}
</style>