2022 lines
71 KiB
Vue
2022 lines
71 KiB
Vue
<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>
|