1370 lines
52 KiB
Vue
1370 lines
52 KiB
Vue
<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>
|