Files
SmartUp/frontend/src/views/Websites.vue
T
2026-05-24 23:18:40 +08:00

1370 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="shell-page shell-page-fluid page-section websites-page">
<div class="page-header surface-card page-block">
<div class="page-heading">
<p class="page-kicker">Sync Orchestration</p>
<h2 class="page-title">网站管理</h2>
<p class="page-desc">管理自己的 sub2api 网站并把网站分组倍率同步到上游监听结果</p>
</div>
<el-button type="primary" @click="openWebsiteCreate">
<el-icon><Plus /></el-icon> 新增网站
</el-button>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">网站</div>
<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">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-url">{{ row.base_url }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span :class="['status-badge', row.last_status]"><span class="dot" />{{ statusLabel(row.last_status) }}</span>
</template>
</el-table-column>
<el-table-column label="自动同步" width="100">
<template #default="{ row }">
<el-switch v-model="row.auto_sync_enabled" @change="toggleWebsiteSync(row)" />
</template>
</el-table-column>
<el-table-column label="超时" width="80">
<template #default="{ row }">{{ row.timeout_seconds }}s</template>
</el-table-column>
<el-table-column label="最近错误" min-width="180">
<template #default="{ row }">
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
<span class="error-text">{{ row.last_error.substring(0, 42) }}</span>
</el-tooltip>
<span v-else class="muted"></span>
</template>
</el-table-column>
<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-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>
<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>
</el-table>
</div>
<div class="content-grid">
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">我的网站分组</div>
<div class="panel-sub">{{ selectedWebsite?.name || '请选择网站' }}</div>
</div>
<el-select
:model-value="selectedWebsite?.id"
size="small"
filterable
class="site-switcher"
placeholder="切换网站"
@change="onSelectedWebsiteChange"
>
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
<el-button size="small" :disabled="!selectedWebsite" :loading="groupsLoading" @click="loadWebsiteGroups">拉取分组</el-button>
</div>
<el-table :data="websiteGroups" v-loading="groupsLoading" row-key="id" size="small" style="width:100%">
<el-table-column prop="name" label="分组" min-width="150" />
<el-table-column prop="id" label="ID" min-width="130" />
<el-table-column label="倍率" width="100">
<template #default="{ row }"><span class="rate-value">{{ row.rate_multiplier ?? '—' }}</span></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button size="small" text :disabled="!selectedWebsite" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">分组绑定</div>
<div class="panel-sub">{{ selectedWebsite?.name || '请选择网站' }}</div>
</div>
<el-select
:model-value="selectedWebsite?.id"
size="small"
filterable
class="site-switcher"
placeholder="切换网站"
@change="onSelectedWebsiteChange"
>
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
</div>
<div class="binding-list" v-loading="bindingLoading">
<div v-for="binding in selectedWebsiteBindings" :key="binding.id" class="binding-item">
<div class="binding-main">
<div class="binding-title">{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}</div>
<div class="binding-meta">
{{ algorithmLabel(binding.algorithm) }} + {{ binding.percent }}% ·
{{ binding.source_groups.length }} 个上游分组
</div>
</div>
<div class="binding-actions">
<el-switch v-model="binding.enabled" @change="toggleBinding(binding)" />
<el-button size="small" text :loading="binding._syncing" @click="syncBinding(binding)">同步</el-button>
<el-button size="small" text @click="openBindingEdit(binding)"><el-icon><Edit /></el-icon></el-button>
<el-button size="small" text type="danger" @click="deleteBinding(binding)"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<div v-if="!bindingLoading && selectedWebsiteBindings.length === 0" class="empty-hint">暂无绑定</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">最近同步结果</div>
<el-button size="small" text @click="loadLogs">刷新</el-button>
</div>
<el-table :data="logs" v-loading="logLoading" row-key="id" size="small" style="width:100%">
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="目标分组" min-width="160">
<template #default="{ row }">{{ row.target_group_name || row.target_group_id }}</template>
</el-table-column>
<el-table-column label="倍率" width="130">
<template #default="{ row }">
<span class="rate-value">{{ row.old_rate ?? '—' }} {{ row.new_rate ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="结果" min-width="220" />
</el-table>
</div>
<el-drawer v-model="websiteDrawer" :title="editingWebsiteId ? '编辑网站' : '新增网站'" size="460px" destroy-on-close>
<el-form ref="websiteFormRef" :model="websiteForm" :rules="websiteRules" label-position="top">
<el-form-item label="名称" prop="name"><el-input v-model="websiteForm.name" placeholder="我的 sub2api" /></el-form-item>
<el-form-item label="Base URL" prop="base_url"><el-input v-model="websiteForm.base_url" placeholder="https://sub2api.example.com" /></el-form-item>
<el-form-item label="API Prefix"><el-input v-model="websiteForm.api_prefix" placeholder="/api/v1/admin" /></el-form-item>
<el-form-item label="认证方式">
<el-select v-model="websiteForm.auth_type" style="width:100%">
<el-option label="x-api-key" value="api_key" />
<el-option label="Bearer JWT" value="bearer" />
</el-select>
</el-form-item>
<template v-if="websiteForm.auth_type === 'api_key'">
<el-form-item label="Admin API Key"><el-input v-model="websiteForm.auth_config.key" type="password" show-password placeholder="admin-..." /></el-form-item>
<el-form-item label="Header"><el-input v-model="websiteForm.auth_config.header" placeholder="x-api-key" /></el-form-item>
</template>
<template v-else>
<el-form-item label="JWT"><el-input v-model="websiteForm.auth_config.token" type="password" show-password placeholder="***" /></el-form-item>
</template>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="分组接口"><el-input v-model="websiteForm.groups_endpoint" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="更新接口"><el-input v-model="websiteForm.group_update_endpoint" /></el-form-item></el-col>
</el-row>
<el-form-item label="超时(秒)"><el-input-number v-model="websiteForm.timeout_seconds" :min="5" :max="120" style="width:100%" /></el-form-item>
<div class="switch-line">
<span>启用网站</span><el-switch v-model="websiteForm.enabled" />
</div>
<div class="switch-line">
<span>启用自动同步</span><el-switch v-model="websiteForm.auto_sync_enabled" />
</div>
</el-form>
<template #footer>
<el-button @click="websiteDrawer = false">取消</el-button>
<el-button type="primary" :loading="savingWebsite" @click="saveWebsite">保存</el-button>
</template>
</el-drawer>
<el-drawer v-model="bindingDrawer" :title="editingBindingId ? '编辑绑定' : '新增绑定'" size="560px" destroy-on-close>
<el-form ref="bindingFormRef" :model="bindingForm" :rules="bindingRules" label-position="top">
<el-form-item label="目标网站">
<el-select v-model="bindingForm.website_id" style="width:100%" @change="onBindingWebsiteChange">
<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="目标分组" prop="target_group_id">
<el-select v-model="bindingForm.target_group_id" filterable style="width:100%" @change="onTargetGroupChange">
<el-option v-for="group in bindingWebsiteGroups" :key="group.id" :label="`${group.name} (${group.rate_multiplier ?? '—'})`" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="监听上游分组" prop="source_group_keys">
<el-select v-model="sourceGroupKeys" multiple filterable style="width:100%" placeholder="选择一个或多个上游分组" @change="onSourceGroupsChange">
<el-option v-for="item in upstreamGroupOptions" :key="item.key" :label="item.label" :value="item.key" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="算法">
<el-select v-model="bindingForm.algorithm" style="width:100%">
<el-option label="最高倍率 + 百分比" value="max_plus_percent" />
<el-option label="平均倍率 + 百分比" value="average_plus_percent" />
<el-option label="最低倍率 + 百分比" value="min_plus_percent" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="百分比">
<el-input-number v-model="bindingForm.percent" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="启用"><el-switch v-model="bindingForm.enabled" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="bindingDrawer = false">取消</el-button>
<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>
<div v-if="importGeneratedKeys.length" class="import-stats">
总计 {{ importGeneratedKeys.length }} 现可导入 {{ importableGeneratedKeys.length }} 已导入 {{ importedGeneratedKeyCount }} 无明文 {{ missingPlaintextKeyCount }}
</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>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
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,
type WebsiteGroup,
type WebsiteSyncLog,
} from '@/api'
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
const upstreams = ref<UpstreamData[]>([])
const selectedWebsite = ref<WebsiteData | null>(null)
const websiteGroups = ref<WebsiteGroup[]>([])
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 {
name: '',
site_type: 'sub2api',
base_url: '',
api_prefix: '/api/v1/admin',
auth_type: 'api_key',
auth_config: { key: '', header: 'x-api-key' },
groups_endpoint: '/groups',
group_update_endpoint: '/groups/{id}',
enabled: true,
auto_sync_enabled: true,
timeout_seconds: 30,
}
}
const websiteDrawer = ref(false)
const savingWebsite = ref(false)
const editingWebsiteId = ref<number | null>(null)
const websiteFormRef = ref<FormInstance>()
const websiteForm = ref<WebsiteForm>(defaultWebsiteForm())
const websiteRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
}
function defaultBindingForm(): GroupBindingForm {
return {
website_id: websites.value[0]?.id ?? 0,
target_group_id: '',
target_group_name: '',
source_groups: [],
percent: 0,
algorithm: 'max_plus_percent',
enabled: true,
}
}
const bindingDrawer = ref(false)
const savingBinding = ref(false)
const editingBindingId = ref<number | null>(null)
const bindingFormRef = ref<FormInstance>()
const bindingForm = ref<GroupBindingForm>(defaultBindingForm())
const sourceGroupKeys = ref<string[]>([])
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) {
const groups = snapshotsByUpstream.value[upstream.id] || []
for (const group of groups) {
const source = {
upstream_id: upstream.id,
upstream_name: upstream.name,
group_id: group.group_id,
group_name: group.group_name || group.group_id,
}
rows.push({
key: `${upstream.id}::${group.group_id}`,
label: `${upstream.name} / ${source.group_name} (${group.rate || '—'})`,
source,
})
}
}
return rows
})
const importSourceGroups = computed(() => snapshotsByUpstream.value[importGroupsForm.value.upstream_id] || [])
function isImportableGeneratedKey(item: GeneratedUpstreamKey) {
return item.id !== null
&& item.has_key_value
&& item.status !== 'failed'
&& !(item.imported_website_id === importAccountsForm.value.website_id && item.imported_account_id)
}
const importableGeneratedKeys = computed(() =>
importGeneratedKeys.value.filter(isImportableGeneratedKey),
)
const importedGeneratedKeyCount = computed(() =>
importGeneratedKeys.value.filter((item) =>
item.imported_website_id === importAccountsForm.value.website_id && Boolean(item.imported_account_id),
).length,
)
const missingPlaintextKeyCount = computed(() =>
importGeneratedKeys.value.filter((item) => !item.has_key_value).length,
)
const selectedWebsiteBindings = computed(() => {
if (!selectedWebsite.value) return []
return bindings.value.filter((binding) => binding.website_id === selectedWebsite.value?.id)
})
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 {
const res = await websitesApi.list()
websites.value = res.data
if (!selectedWebsite.value && res.data.length > 0) selectedWebsite.value = res.data[0]
} finally {
websiteLoading.value = false
}
}
async function onSelectedWebsiteChange(value: number | string) {
const nextId = Number(value)
const next = websites.value.find((site) => site.id === nextId)
if (!next) return
selectedWebsite.value = next
await loadWebsiteGroups()
}
async function loadUpstreamGroups() {
const upstreamRes = await upstreamsApi.list()
upstreams.value = upstreamRes.data
const entries: Record<number, any[]> = {}
await Promise.all(upstreams.value.map(async (upstream) => {
try {
const res = await upstreamsApi.latestSnapshot(upstream.id)
entries[upstream.id] = Object.values(res.data.snapshot?.groups || {})
} catch {
entries[upstream.id] = []
}
}))
snapshotsByUpstream.value = entries
}
async function loadWebsiteGroups() {
if (!selectedWebsite.value) return
groupsLoading.value = true
try {
const res = await websitesApi.groups(selectedWebsite.value.id)
websiteGroups.value = res.data
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '拉取分组失败')
} finally {
groupsLoading.value = false
}
}
async function loadBindingWebsiteGroups(websiteId: number) {
if (!websiteId) {
bindingWebsiteGroups.value = []
return
}
try {
const res = await websitesApi.groups(websiteId)
bindingWebsiteGroups.value = res.data
} catch {
bindingWebsiteGroups.value = []
}
}
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 {
const res = await websitesApi.listBindings()
bindings.value = res.data
} finally {
bindingLoading.value = false
}
}
async function loadLogs() {
logLoading.value = true
try {
const res = await websitesApi.logs({ limit: 50 })
logs.value = res.data
} finally {
logLoading.value = false
}
}
async function syncImportStatus() {
const websiteId = importAccountsForm.value.website_id
const upstreamId = importAccountsForm.value.upstream_id
if (!websiteId || !upstreamId) return false
syncingImportStatus.value = true
let reloaded = false
try {
const res = await websitesApi.syncImportedUpstreamKeys(websiteId, { upstream_id: upstreamId })
// 校验请求完成时表单未切换
if (importAccountsForm.value.website_id !== websiteId || importAccountsForm.value.upstream_id !== upstreamId) return false
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)
reloaded = true
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '同步导入状态失败')
} finally {
syncingImportStatus.value = false
}
return reloaded
}
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()
}
function selectWebsite(row: WebsiteData) {
selectedWebsite.value = row
loadWebsiteGroups()
}
function openWebsiteCreate() {
editingWebsiteId.value = null
websiteForm.value = defaultWebsiteForm()
websiteDrawer.value = true
}
function openWebsiteEdit(row: WebsiteData) {
editingWebsiteId.value = row.id
websiteForm.value = {
name: row.name,
site_type: row.site_type,
base_url: row.base_url,
api_prefix: row.api_prefix,
auth_type: row.auth_type,
auth_config: { ...row.auth_config_masked },
groups_endpoint: row.groups_endpoint,
group_update_endpoint: row.group_update_endpoint,
enabled: row.enabled,
auto_sync_enabled: row.auto_sync_enabled,
timeout_seconds: row.timeout_seconds,
}
websiteDrawer.value = true
}
async function saveWebsite() {
const valid = await websiteFormRef.value?.validate().catch(() => false)
if (!valid) return
savingWebsite.value = true
try {
if (editingWebsiteId.value) {
await websitesApi.update(editingWebsiteId.value, websiteForm.value)
ElMessage.success('保存成功')
} else {
await websitesApi.create(websiteForm.value)
ElMessage.success('创建成功')
}
websiteDrawer.value = false
await loadWebsites()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
savingWebsite.value = false
}
}
async function toggleWebsiteSync(row: WebsiteData) {
try {
await websitesApi.update(row.id, { auto_sync_enabled: row.auto_sync_enabled })
ElMessage.success(row.auto_sync_enabled ? '已启用自动同步' : '已停用自动同步')
} catch {
row.auto_sync_enabled = !row.auto_sync_enabled
ElMessage.error('操作失败')
}
}
async function testWebsite(row: WebsiteData & { _testing?: boolean }) {
row._testing = true
try {
const res = await websitesApi.test(row.id)
ElMessage[res.data.success ? 'success' : 'error'](res.data.detail || res.data.message)
await loadWebsites()
} finally {
row._testing = false
}
}
/** 处理「更多」下拉菜单中的操作 */
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' })
await websitesApi.delete(row.id)
ElMessage.success('已删除')
selectedWebsite.value = null
websiteGroups.value = []
await loadAll()
} catch {}
}
async function openBindingCreate(site?: WebsiteData | null, target?: WebsiteGroup) {
editingBindingId.value = null
bindingForm.value = defaultBindingForm()
if (site) bindingForm.value.website_id = site.id
await loadBindingWebsiteGroups(bindingForm.value.website_id)
if (target) {
bindingForm.value.target_group_id = target.id
bindingForm.value.target_group_name = target.name
}
sourceGroupKeys.value = []
bindingDrawer.value = true
}
async function openBindingEdit(row: GroupBindingData) {
editingBindingId.value = row.id
bindingForm.value = {
website_id: row.website_id,
target_group_id: row.target_group_id,
target_group_name: row.target_group_name,
source_groups: [...row.source_groups],
percent: row.percent,
algorithm: row.algorithm,
enabled: row.enabled,
}
sourceGroupKeys.value = row.source_groups.map(item => `${item.upstream_id}::${item.group_id}`)
await loadBindingWebsiteGroups(row.website_id)
bindingDrawer.value = true
}
async function onBindingWebsiteChange(value: number) {
bindingForm.value.target_group_id = ''
bindingForm.value.target_group_name = ''
await loadBindingWebsiteGroups(value)
}
function onTargetGroupChange(value: string) {
const group = bindingWebsiteGroups.value.find(item => item.id === value)
bindingForm.value.target_group_name = group?.name || value
}
function onSourceGroupsChange(values: string[]) {
const options = new Map(upstreamGroupOptions.value.map(item => [item.key, item.source]))
bindingForm.value.source_groups = values.map(value => options.get(value)).filter((item): item is BindingSourceGroup => Boolean(item))
}
async function saveBinding() {
if (!bindingForm.value.source_groups.length) {
ElMessage.error('请选择至少一个监听上游分组')
return
}
const valid = await bindingFormRef.value?.validate().catch(() => false)
if (!valid) return
savingBinding.value = true
try {
if (editingBindingId.value) {
await websitesApi.updateBinding(editingBindingId.value, bindingForm.value)
} else {
await websitesApi.createBinding(bindingForm.value)
}
ElMessage.success('保存成功,已尝试同步')
bindingDrawer.value = false
await Promise.all([loadBindings(), loadLogs()])
if (selectedWebsite.value?.id === bindingForm.value.website_id) await loadWebsiteGroups()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally {
savingBinding.value = false
}
}
async function toggleBinding(row: GroupBindingData) {
try {
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
ElMessage.success(row.enabled ? '已启用绑定' : '已停用绑定')
} catch {
row.enabled = !row.enabled
ElMessage.error('操作失败')
}
}
async function syncBinding(row: GroupBindingData & { _syncing?: boolean }) {
row._syncing = true
try {
const res = await websitesApi.syncNow(row.id)
ElMessage[res.data.status === 'success' ? 'success' : 'error'](res.data.message)
await loadLogs()
if (selectedWebsite.value?.id === row.website_id) await loadWebsiteGroups()
} finally {
row._syncing = false
}
}
async function deleteBinding(row: GroupBindingData) {
try {
await ElMessageBox.confirm('确认删除该绑定?', '删除确认', { type: 'warning' })
await websitesApi.deleteBinding(row.id)
ElMessage.success('已删除')
await loadBindings()
} 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 loadImportTargetGroups(importAccountsForm.value.website_id)
// 打开弹窗后自动同步导入状态(校验远端账号是否仍存在)
const reloaded = await syncImportStatus()
if (!reloaded) await loadImportGeneratedKeys(importAccountsForm.value.upstream_id)
importAccountsDialog.value = true
}
async function onImportAccountWebsiteChange(value: number) {
importAccountsForm.value.target_group_map = {}
await loadImportTargetGroups(value)
const reloaded = await syncImportStatus()
if (!reloaded) await loadImportGeneratedKeys(importAccountsForm.value.upstream_id)
}
async function onImportAccountUpstreamChange(value: number) {
importAccountsForm.value.upstream_key_ids = []
importAccountsForm.value.target_group_map = {}
importAccountResults.value = []
const reloaded = await syncImportStatus()
if (!reloaded) 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>
<style scoped>
.websites-page { display: flex; flex-direction: column; gap: 18px; padding-bottom: 1rem; }
.page-block {
padding: 1rem;
}
.page-header {
border-radius: var(--radius-shell);
}
.panel-head {
min-height: 48px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.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;
gap: 18px;
}
.cell-name { font-weight: 500; font-size: 14px; }
.cell-url { font-size: 12px; color: var(--text-muted); margin-top: 2px; overflow-wrap: anywhere; }
.muted { color: var(--text-muted); font-size: 12px; }
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
.rate-value { font-weight: 600; color: var(--color-primary-strong); font-family: monospace; }
.action-row {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 6px;
min-width: 0;
}
.action-row .el-button.is-circle {
width: 26px;
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);
}
.site-switcher {
width: 180px;
flex-shrink: 0;
}
.binding-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.binding-actions .el-button--danger,
.action-row .el-button--danger {
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
}
.binding-actions .el-button--danger:hover,
.action-row .el-button--danger:hover {
--el-button-bg-color: var(--el-color-danger-light-8, #fef0f0);
--el-button-border-color: var(--el-color-danger-light-5, #fbc4c4);
}
.binding-list { min-height: 120px; }
.import-stats {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 12px;
}
.binding-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
}
.binding-item:last-child { border-bottom: none; }
.binding-main { min-width: 0; }
.binding-title {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.binding-meta { color: var(--text-muted); font-size: 12px; margin-top: 3px; }
.empty-hint {
text-align: center;
padding: 36px 16px;
color: var(--text-muted);
font-size: 13px;
}
.switch-line {
min-height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
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); }
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.page-header .el-button { min-height: 44px; }
.binding-item {
align-items: flex-start;
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>