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:
@@ -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),
|
||||
|
||||
@@ -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="配额 USD(0 不限)"><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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user