Files
SmartUp/frontend/src/views/Websites.vue
T

2022 lines
71 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="panel">
<div class="panel-head">
<div class="panel-title">检测与变更控制台</div>
<div class="panel-actions">
<el-button size="small" text @click="loadAll">刷新</el-button>
<el-button size="small" type="primary" @click="openWebsiteCreate">新增网站</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="340">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" text @click="openWebsiteEdit(row)" title="编辑网站配置(API凭证、参数等)">编辑</el-button>
<el-button size="small" text @click="selectWebsite(row)" title="查看当前网站的分组和绑定配置">查看</el-button>
<el-button size="small" text @click="testWebsite(row)" :loading="row._testing" title="仅验证连通性,测试API是否正常响应">测试连接</el-button>
<el-button size="small" text @click="openBindingCreate(row)" title="为此网站创建一个新的上游倍率绑定规则">新增绑定</el-button>
<el-button size="small" text @click="openImportGroups(row)" title="一键从上游导入可用分组到此网站">导入分组</el-button>
<el-button size="small" text @click="openImportAccounts(row)" title="将此网站下的所有账户批量导入账号管理">导入账号</el-button>
<el-button size="small" text type="danger" @click="deleteWebsite(row)" title="删除此网站及其所有的绑定配置">删除</el-button>
</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
:loading="batchSyncLoading"
:disabled="!selectedWebsite || selectedWebsiteBindings.length === 0"
@click="syncAllWebsiteBindings"
title="同步当前网站的所有分组绑定"
>
一键同步
</el-button>
<el-button
size="small"
text
:loading="organizeLoading"
:disabled="!selectedWebsite || selectedWebsiteBindings.length === 0"
@click="organizeWebsiteGroups"
title="按当前网站的现有绑定关系一键整理分组并导入账号"
>
一键整理分组
</el-button>
<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">
<div class="selected-groups-summary">
<span class="summary-text">已选 <span class="highlight-text">{{ sourceGroupKeys.length }}</span> 个上游分组</span>
<el-button type="primary" size="small" @click="openGroupSelectModal">选择分组</el-button>
</div>
<div class="selected-tags-box" v-if="sourceGroupKeys.length > 0">
<el-tag
v-for="key in sourceGroupKeys"
:key="key"
closable
class="group-tag"
@close="removeSelectedKey(key)"
>
{{ getGroupLabel(key) }}
</el-tag>
</div>
<div class="no-groups-hint" v-else>暂无选择请点击按钮进行选择</div>
</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="groupSelectVisible" title="选择上游分组" width="800px" destroy-on-close class="group-select-dialog">
<!-- 搜索框 -->
<div class="dialog-search-bar" style="margin-bottom: 16px;">
<el-input v-model="groupSearchQuery" placeholder="输入上游名称、分组名称、分组 ID、倍率进行过滤..." clearable>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="group-selector-layout">
<!-- 左侧按上游站点分类 -->
<div class="left-panel">
<div class="panel-header">上游站点</div>
<div class="list-container">
<div
class="list-item"
:class="{ active: selectedUpstreamId === '' }"
@click="selectedUpstreamId = ''"
>
<span>全部上游</span>
<el-tag size="small" type="info" round>{{ upstreamGroupOptions.length }}</el-tag>
</div>
<div
v-for="upstream in upstreams"
:key="upstream.id"
class="list-item"
:class="{ active: selectedUpstreamId === upstream.id }"
@click="selectedUpstreamId = upstream.id"
>
<span class="upstream-name-text" :title="upstream.name">{{ upstream.name }}</span>
<el-tag size="small" type="info" round>{{ upstreamCounts[upstream.id] || 0 }}</el-tag>
</div>
</div>
</div>
<!-- 中间展示该上游下的分组 Checkbox -->
<div class="middle-panel">
<div class="panel-header flex-header">
<el-checkbox
:model-value="isAllFilteredSelected"
:indeterminate="isSomeFilteredSelected"
@change="toggleSelectAllFiltered"
>
<span>可选分组列表 (已选 {{ tempSelectedKeys.filter(k => filteredGroupsForModal.some(g => g.key === k)).length }}/{{ filteredGroupsForModal.length }})</span>
</el-checkbox>
</div>
<div class="list-container group-list-scroll">
<div v-if="upstreamGroupOptions.length === 0" class="empty-hint-dialog">
暂无分组快照请先同步上游
</div>
<div v-else-if="filteredGroupsForModal.length === 0" class="empty-hint-dialog">
没有匹配的分组快照
</div>
<div
v-else
v-for="item in filteredGroupsForModal"
:key="item.key"
class="group-row-item"
:class="{ selected: tempSelectedKeys.includes(item.key) }"
@click="toggleGroupKey(item.key)"
>
<el-checkbox
:model-value="tempSelectedKeys.includes(item.key)"
@click.stop
@change="toggleGroupKey(item.key)"
/>
<div class="group-row-content">
<div class="row-top">
<span class="row-upstream-name">{{ item.source.upstream_name }}</span>
<span class="row-rate" :class="{ 'rate-highlight': item.rate && item.rate !== '—' }">
倍率: {{ item.rate }}
</span>
</div>
<div class="row-bottom">
<span class="row-group-name">{{ item.source.group_name }}</span>
<span class="row-group-id">ID: {{ item.source.group_id }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧已选分组列表 -->
<div class="right-panel">
<div class="panel-header flex-header">
<span>已选分组 ({{ tempSelectedKeys.length }})</span>
<el-button v-if="tempSelectedKeys.length > 0" type="danger" size="small" link @click="clearAllTempKeys">清空</el-button>
</div>
<div class="list-container selected-list-scroll">
<div v-if="tempSelectedKeys.length === 0" class="empty-hint-dialog small">
暂无选定分组
</div>
<div
v-else
v-for="key in tempSelectedKeys"
:key="key"
class="selected-row-item"
>
<div class="selected-row-info" v-if="getGroupDetails(key)">
<span class="selected-upstream-name" :title="getGroupDetails(key)?.source.upstream_name">
{{ getGroupDetails(key)?.source.upstream_name }}
</span>
<span class="selected-group-name" :title="getGroupDetails(key)?.source.group_name">
{{ getGroupDetails(key)?.source.group_name }}
</span>
</div>
<div class="selected-row-info" v-else>
<span class="selected-group-name">{{ key }}</span>
</div>
<el-button type="danger" size="small" link @click="removeTempKey(key)">
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="groupSelectVisible = false">取消</el-button>
<el-button type="primary" @click="confirmGroupSelection">确定</el-button>
</div>
</template>
</el-dialog>
<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>
<el-button
size="small"
text
type="primary"
:loading="reorderingPriorities"
:disabled="!importAccountsForm.website_id || !importAccountsForm.upstream_id"
@click="reorderImportPriorities"
>
<el-icon><Sort /></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="优先级">
<template #label>
<span>优先级</span>
<el-tooltip content="按倍率自动分配优先级:倍率最低的上游分组优先级最高(priority=1),后续按 10 递增:11、21..." placement="top" :show-after="300">
<el-icon style="margin-left:4px;vertical-align:middle;color:var(--text-muted)"><WarningFilled /></el-icon>
</el-tooltip>
</template>
<div style="display:flex;align-items:center;gap:8px">
<el-switch
v-model="importAccountsForm.auto_priority_by_rate"
active-text="按倍率自动分配"
inactive-text="手动设置"
/>
<el-input-number
v-if="!importAccountsForm.auto_priority_by_rate"
v-model="importAccountsForm.priority"
:min="0"
style="width:160px"
/>
</div>
</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>
<el-dialog v-model="organizeDialog" title="一键整理分组结果" width="850px" destroy-on-close>
<div v-if="organizeMessage" style="margin-bottom:12px; font-weight:bold; color:var(--el-color-primary)">
{{ organizeMessage }}
</div>
<el-table :data="organizeResults" size="small" border style="width:100%">
<el-table-column label="目标分组" min-width="150">
<template #default="{ row }">
<div>{{ row.target_group_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.target_group_id }}</div>
</template>
</el-table-column>
<el-table-column label="来源上游/分组" min-width="165">
<template #default="{ row }">
<div>{{ row.upstream_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">{{ row.source_group_name }} (ID: {{ row.source_group_id }})</div>
</template>
</el-table-column>
<el-table-column label="上游 Key" min-width="140">
<template #default="{ row }">
<span v-if="row.key_name">{{ row.key_name }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="目标账号" min-width="150">
<template #default="{ row }">
<div v-if="row.account_id">
<div>{{ row.account_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.account_id }}</div>
</div>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag v-if="row.status === 'created'" size="small" type="success">已创建</el-tag>
<el-tag v-else-if="row.status === 'recreated'" size="small" type="warning">清理后重建</el-tag>
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
<el-tag v-else-if="row.status === 'missing_key'" size="small" type="danger">缺少 Key</el-tag>
<el-tag v-else size="small" type="danger">失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明/原因" min-width="150">
<template #default="{ row }">
<span :class="{ 'error-text': row.status === 'failed' }">{{ row.message }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="organizeDialog = false">关闭</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, Sort, WarningFilled, Search, Close } 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,
type OrganizeGroupsItem,
} 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 batchSyncLoading = 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 reorderingPriorities = 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,
auto_priority_by_rate: true,
})
const importAccountResults = ref<ImportAccountItem[]>([])
const organizeDialog = ref(false)
const organizeLoading = ref(false)
const organizeResults = ref<OrganizeGroupsItem[]>([])
const organizeMessage = ref('')
const upstreamGroupOptions = computed(() => {
const rows: Array<{ key: string; label: string; rate: string | number; 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 || '—'})`,
rate: 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.status !== 'orphaned'
&& !(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
}
const frozenId = websiteId
try {
const res = await websitesApi.groups(frozenId)
if (importAccountsForm.value.website_id !== frozenId) return
importTargetGroups.value = res.data
} catch {
if (importAccountsForm.value.website_id !== frozenId) return
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 reorderImportPriorities() {
const websiteId = importAccountsForm.value.website_id
const upstreamId = importAccountsForm.value.upstream_id
if (!websiteId || !upstreamId) {
ElMessage.error('请选择目标网站和来源上游')
return
}
reorderingPriorities.value = true
try {
const res = await websitesApi.reorderAccountPriorities(websiteId, { upstream_id: upstreamId })
if (importAccountsForm.value.website_id !== websiteId || importAccountsForm.value.upstream_id !== upstreamId) return
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
await Promise.all([
loadImportGeneratedKeys(upstreamId),
loadLogs(),
])
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '重排优先级失败')
} finally {
reorderingPriorities.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()
}
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
}
}
// 选择上游分组弹窗相关状态与方法
const groupSelectVisible = ref(false)
const groupSearchQuery = ref('')
const selectedUpstreamId = ref<number | ''>('')
const tempSelectedKeys = ref<string[]>([])
function openGroupSelectModal() {
tempSelectedKeys.value = [...sourceGroupKeys.value]
groupSearchQuery.value = ''
selectedUpstreamId.value = ''
groupSelectVisible.value = true
}
function getGroupLabel(key: string) {
const found = upstreamGroupOptions.value.find(item => item.key === key)
return found ? found.label : key
}
function removeSelectedKey(key: string) {
sourceGroupKeys.value = sourceGroupKeys.value.filter(k => k !== key)
onSourceGroupsChange(sourceGroupKeys.value)
}
function getGroupDetails(key: string) {
return upstreamGroupOptions.value.find(item => item.key === key)
}
const upstreamCounts = computed(() => {
const counts: Record<number, number> = {}
for (const upstream of upstreams.value) {
const groups = snapshotsByUpstream.value[upstream.id] || []
counts[upstream.id] = groups.length
}
return counts
})
const filteredGroupsForModal = computed(() => {
const query = groupSearchQuery.value.trim().toLowerCase()
return upstreamGroupOptions.value.filter(item => {
if (selectedUpstreamId.value !== '' && item.source.upstream_id !== selectedUpstreamId.value) {
return false
}
if (query) {
const matchUpstreamName = item.source.upstream_name.toLowerCase().includes(query)
const matchGroupName = item.source.group_name.toLowerCase().includes(query)
const matchGroupId = String(item.source.group_id).toLowerCase().includes(query)
const matchRate = String(item.rate).toLowerCase().includes(query)
return matchUpstreamName || matchGroupName || matchGroupId || matchRate
}
return true
})
})
const isAllFilteredSelected = computed(() => {
if (filteredGroupsForModal.value.length === 0) return false
return filteredGroupsForModal.value.every(item => tempSelectedKeys.value.includes(item.key))
})
const isSomeFilteredSelected = computed(() => {
if (filteredGroupsForModal.value.length === 0) return false
const selectedCount = filteredGroupsForModal.value.filter(item => tempSelectedKeys.value.includes(item.key)).length
return selectedCount > 0 && selectedCount < filteredGroupsForModal.value.length
})
function toggleSelectAllFiltered(checked: boolean) {
if (checked) {
const keysToAdd = filteredGroupsForModal.value.map(item => item.key)
tempSelectedKeys.value = Array.from(new Set([...tempSelectedKeys.value, ...keysToAdd]))
} else {
const keysToRemove = new Set(filteredGroupsForModal.value.map(item => item.key))
tempSelectedKeys.value = tempSelectedKeys.value.filter(key => !keysToRemove.has(key))
}
}
function toggleGroupKey(key: string) {
const idx = tempSelectedKeys.value.indexOf(key)
if (idx > -1) {
tempSelectedKeys.value.splice(idx, 1)
} else {
tempSelectedKeys.value.push(key)
}
}
function removeTempKey(key: string) {
tempSelectedKeys.value = tempSelectedKeys.value.filter(k => k !== key)
}
function clearAllTempKeys() {
tempSelectedKeys.value = []
}
function confirmGroupSelection() {
sourceGroupKeys.value = [...tempSelectedKeys.value]
onSourceGroupsChange(sourceGroupKeys.value)
groupSelectVisible.value = false
}
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 syncAllWebsiteBindings() {
if (!selectedWebsite.value) return
batchSyncLoading.value = true
try {
const res = await websitesApi.syncWebsiteBindings(selectedWebsite.value.id)
ElMessage.success(res.data.message)
// 刷新相关数据
await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()])
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '批量同步失败')
} finally {
batchSyncLoading.value = false
}
}
async function organizeWebsiteGroups() {
if (!selectedWebsite.value) return
try {
await ElMessageBox.confirm(
'一键整理分组将根据当前的“分组管理”绑定关系,自动执行已导入账号的校验补齐,以及未导入账号的自动创建。确认执行?',
'一键整理分组',
{ type: 'info' }
)
} catch {
return
}
organizeLoading.value = true
try {
const res = await websitesApi.organizeGroups(selectedWebsite.value.id)
organizeMessage.value = res.data.message
organizeResults.value = res.data.items
organizeDialog.value = true
// 刷新相关数据
await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()])
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '一键整理分组失败')
} finally {
organizeLoading.value = false
}
}
async function toggleBinding(row: GroupBindingData) {
try {
await websitesApi.updateBinding(row.id, { enabled: row.enabled })
ElMessage.success(row.enabled ? '已启用' : '已禁用')
await loadLogs()
} catch (e: any) {
row.enabled = !row.enabled
ElMessage.error(e.response?.data?.detail || '更新失败')
}
}
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,
auto_priority_by_rate: true,
}
importAccountResults.value = []
importSyncStatus.value = null
// 1. 立即打开弹窗,实现秒开体验
importAccountsDialog.value = true
// 2. 在后台并发加载分组、Keys,并在加载完成后自动执行同步
void Promise.all([
loadImportTargetGroups(importAccountsForm.value.website_id),
loadImportGeneratedKeys(importAccountsForm.value.upstream_id)
]).then(() => {
void syncImportStatus()
})
}
async function onImportAccountWebsiteChange(value: number) {
importAccountsForm.value.target_group_map = {}
void Promise.all([
loadImportTargetGroups(value),
loadImportGeneratedKeys(importAccountsForm.value.upstream_id)
]).then(() => {
void syncImportStatus()
})
}
async function onImportAccountUpstreamChange(value: number) {
importAccountsForm.value.upstream_key_ids = []
importAccountsForm.value.target_group_map = {}
importAccountResults.value = []
void loadImportGeneratedKeys(value).then(() => {
void syncImportStatus()
})
}
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,
auto_priority_by_rate: importAccountsForm.value.auto_priority_by_rate,
})
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: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
}
.action-row .el-button {
justify-self: center;
}
.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; }
}
/* Custom group selector layout styles */
.selected-groups-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: rgba(255, 244, 232, 0.02);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 8px;
}
.summary-text {
font-size: 13px;
color: var(--text-secondary);
}
.highlight-text {
color: var(--color-primary-strong);
font-weight: 700;
}
.selected-tags-box {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px;
background: rgba(0, 0, 0, 0.1);
border: 1px solid var(--border-color);
border-radius: 8px;
max-height: 140px;
overflow-y: auto;
}
.group-tag {
background: rgba(217, 139, 66, 0.08) !important;
border-color: rgba(217, 139, 66, 0.2) !important;
color: var(--color-primary-strong) !important;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-groups-hint {
font-size: 12px;
color: var(--text-muted);
text-align: center;
padding: 12px;
border: 1px dashed var(--border-color);
border-radius: 8px;
}
.group-selector-layout {
display: grid;
grid-template-columns: 180px 1fr 200px;
gap: 16px;
height: 450px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-surface);
overflow: hidden;
}
.left-panel, .middle-panel, .right-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.left-panel {
border-right: 1px solid var(--border-color);
}
.right-panel {
border-left: 1px solid var(--border-color);
}
.panel-header {
padding: 10px 14px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
background: rgba(255, 244, 232, 0.03);
border-bottom: 1px solid var(--border-color);
min-height: 38px;
display: flex;
align-items: center;
}
.panel-header.flex-header {
justify-content: space-between;
}
.list-container {
flex: 1;
overflow-y: auto;
padding: 8px;
}
/* Left panel items */
.left-panel .list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
transition: background 0.15s ease;
font-size: 13px;
color: var(--text-secondary);
}
.left-panel .list-item:hover {
background: rgba(255, 244, 232, 0.04);
color: var(--text-primary);
}
.left-panel .list-item.active {
background: var(--color-primary-soft);
color: var(--color-primary-strong);
font-weight: 500;
}
.upstream-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Middle panel group items */
.group-row-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 6px;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.group-row-item:hover {
background: rgba(255, 244, 232, 0.03);
border-color: rgba(218, 183, 142, 0.1);
}
.group-row-item.selected {
background: rgba(217, 139, 66, 0.04);
border-color: rgba(217, 139, 66, 0.15);
}
.group-row-content {
flex: 1;
min-width: 0;
}
.row-top, .row-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.row-bottom {
margin-top: 4px;
}
.row-upstream-name {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: var(--text-muted);
}
.row-rate {
font-size: 11px;
color: var(--text-muted);
}
.rate-highlight {
color: var(--color-warning);
font-weight: 600;
}
.row-group-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-group-id {
font-size: 11px;
color: var(--text-soft);
font-family: monospace;
}
/* Right panel selected items */
.selected-row-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: rgba(255, 244, 232, 0.02);
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 6px;
font-size: 12px;
}
.selected-row-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.selected-upstream-name {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-group-name {
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty Hints */
.empty-hint-dialog {
text-align: center;
padding: 40px 16px;
color: var(--text-muted);
font-size: 13px;
}
.empty-hint-dialog.small {
padding: 24px 12px;
font-size: 12px;
}
/* Responsive dialog styling */
@media (max-width: 768px) {
.group-selector-layout {
display: flex;
flex-direction: column;
height: auto;
max-height: 480px;
overflow-y: auto;
}
.left-panel {
border-right: none;
border-bottom: 1px solid var(--border-color);
max-height: 120px;
height: auto;
}
.left-panel .list-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
}
.left-panel .list-item {
margin-bottom: 0;
padding: 4px 10px;
}
.middle-panel {
max-height: 240px;
}
.right-panel {
border-left: none;
border-top: 1px solid var(--border-color);
max-height: 160px;
}
}
</style>