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

3371 lines
116 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="shell-page shell-page-fluid page-section websites-page">
<div class="panel">
<div class="panel-head">
<div class="panel-title">检测与变更控制台</div>
<div class="panel-actions">
<el-button size="small" text @click="loadAll">刷新</el-button>
<el-button size="small" type="primary" @click="openWebsiteCreate">新增网站</el-button>
</div>
</div>
<el-table :data="websites" v-loading="websiteLoading" row-key="id" style="width:100%">
<el-table-column label="名称" min-width="180">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-url">{{ row.base_url }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span :class="['status-badge', row.last_status]"><span class="dot" />{{ statusLabel(row.last_status) }}</span>
</template>
</el-table-column>
<el-table-column label="自动同步" width="100">
<template #default="{ row }">
<el-switch v-model="row.auto_sync_enabled" @change="toggleWebsiteSync(row)" />
</template>
</el-table-column>
<el-table-column label="超时" width="80">
<template #default="{ row }">{{ row.timeout_seconds }}s</template>
</el-table-column>
<el-table-column label="最近错误" min-width="180">
<template #default="{ row }">
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
<span class="error-text">{{ row.last_error.substring(0, 42) }}</span>
</el-tooltip>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="340">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" text @click="openWebsiteEdit(row)" title="编辑网站配置(API凭证、参数等)">编辑</el-button>
<el-button size="small" text @click="selectWebsite(row)" title="查看当前网站的分组和绑定配置">查看</el-button>
<el-button size="small" text @click="testWebsite(row)" :loading="row._testing" title="仅验证连通性,测试API是否正常响应">测试连接</el-button>
<el-button size="small" text @click="openBindingCreate(row)" title="为此网站创建一个新的上游倍率绑定规则">新增绑定</el-button>
<el-button size="small" text @click="openImportGroups(row)" title="一键从上游导入可用分组到此网站">导入分组</el-button>
<el-button size="small" text @click="openImportAccounts(row)" title="将此网站下的所有账户批量导入账号管理">导入账号</el-button>
<el-button size="small" text type="danger" @click="deleteWebsite(row)" title="删除此网站及其所有的绑定配置">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="content-grid">
<div class="panel website-groups-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 || groupsLoading" :loading="groupsLoading" @click="loadWebsiteGroups">拉取分组</el-button>
<el-button size="small" type="primary" :disabled="!selectedWebsite || groupsLoading" @click="openGroupCreate">新增分组</el-button>
</div>
<div class="website-groups-body">
<div v-if="!selectedWebsite" class="groups-state-panel">
<div class="groups-state-title">请选择网站</div>
<div class="groups-state-sub">选择一个目标网站后查看分组</div>
</div>
<div v-else-if="groupsLoading && sortedWebsiteGroups.length === 0" class="groups-state-panel groups-state-loading">
<span class="groups-spinner" aria-hidden="true" />
<div class="groups-state-title">正在拉取网站分组...</div>
<div class="groups-state-sub">如果上游响应较慢请稍等超时后会显示具体错误</div>
</div>
<div v-else-if="groupsError" class="groups-state-panel groups-state-error">
<div class="groups-state-title">分组拉取失败</div>
<div class="groups-state-sub">{{ groupsError }}</div>
<el-button size="small" :loading="groupsLoading" @click="loadWebsiteGroups">重新拉取</el-button>
</div>
<div v-else-if="sortedWebsiteGroups.length === 0" class="groups-state-panel">
<div class="groups-state-title">暂无分组</div>
<div class="groups-state-sub">当前网站没有返回分组或还没有点击拉取</div>
<el-button size="small" :loading="groupsLoading" @click="loadWebsiteGroups">拉取分组</el-button>
</div>
<template v-else>
<div v-if="groupsLoading" class="groups-refresh-strip">
<span class="groups-spinner groups-spinner-small" aria-hidden="true" />
正在刷新分组...
</div>
<el-table :data="sortedWebsiteGroups" 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 prop="description" label="描述" min-width="150" />
<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="140">
<template #default="{ row }">
<el-button size="small" text :disabled="!selectedWebsite || groupsLoading" @click="openBindingCreate(selectedWebsite, row)">绑定</el-button>
<el-button size="small" text :disabled="!selectedWebsite || groupsLoading" @click="openGroupEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
</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="!selectedWebsite"
@click="openCleanupDialog"
title="清理当前网站中手动删除残留、已归档 Key 关联、关联不一致等异常账号"
>
清理异常账号
</el-button>
<el-button
size="small"
text
:disabled="!selectedWebsite"
@click="openConcurrencyDialog"
title="一键设置当前网站已导入账号的并发和上游池参数"
>
设置账号参数
</el-button>
<el-button
size="small"
text
:disabled="!selectedWebsite"
@click="triggerSyncUpstreamModels"
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">
{{ binding.algorithm === 'priority_weighted_plus_percent' ? `${algorithmLabel(binding.algorithm)} · 自动约 ${binding.percent}%` : `${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 class="custom-binding-drawer">
<el-form ref="bindingFormRef" :model="bindingForm" :rules="bindingRules" label-position="top">
<el-form-item label="目标网站">
<div class="custom-select-wrapper">
<el-select v-model="bindingForm.website_id" style="width:100%" @change="onBindingWebsiteChange" class="custom-theme-select">
<el-option v-for="site in websites" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
</el-form-item>
<el-form-item label="目标分组" prop="target_group_id">
<div class="custom-select-wrapper">
<el-select v-model="bindingForm.target_group_id" filterable style="width:100%" @change="onTargetGroupChange" class="custom-theme-select">
<el-option v-for="group in bindingWebsiteGroups" :key="group.id" :label="`${group.name} (${group.rate_multiplier ?? '—'})`" :value="group.id" />
</el-select>
</div>
</el-form-item>
<el-form-item label="监听上游分组" prop="source_group_keys">
<!-- 整合选择联动的主容器 -->
<div class="custom-selector-container">
<div class="selector-info-area">
<div class="selected-count-line">
已选 <span class="accent-bold-text">{{ sourceGroupKeys.length }}</span> 个上游分组
</div>
<!-- 空白占位描述 -->
<p class="empty-placeholder-text" v-if="sourceGroupKeys.length === 0">暂无选择请点击按钮进行选择</p>
<!-- 已选 Tag 轻量级展示列表 -->
<div class="tags-container-inline" v-else>
<el-tag
v-for="key in sourceGroupKeys"
:key="key"
closable
class="custom-inline-tag"
@close="removeSelectedKey(key)"
>
{{ getGroupLabel(key) }}
</el-tag>
</div>
</div>
<button type="button" @click="openGroupSelectModal" class="btn-select-trigger">
选择分组
</button>
</div>
</el-form-item>
<!-- 算法与百分比 -->
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="算法">
<div class="custom-select-wrapper">
<el-select v-model="bindingForm.algorithm" style="width:100%" class="custom-theme-select">
<el-option label="自动保底盈利" value="priority_weighted_plus_percent" />
<el-option label="最高倍率 + 百分比" value="max_plus_percent" />
<el-option label="平均倍率 + 百分比" value="average_plus_percent" />
<el-option label="最低倍率 + 百分比" value="min_plus_percent" />
</el-select>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="百分比">
<div class="custom-percent-stepper" :class="{ disabled: bindingForm.algorithm === 'priority_weighted_plus_percent' }">
<button type="button" @click="adjustPercent(-5)" class="stepper-btn" :disabled="bindingForm.algorithm === 'priority_weighted_plus_percent'">
<el-icon><Minus /></el-icon>
</button>
<input
type="number"
step="0.01"
min="0"
max="100"
v-model.number="bindingForm.percent"
class="stepper-input"
:disabled="bindingForm.algorithm === 'priority_weighted_plus_percent'"
:placeholder="bindingForm.algorithm === 'priority_weighted_plus_percent' ? '自动计算' : ''"
/>
<button type="button" @click="adjustPercent(5)" class="stepper-btn" :disabled="bindingForm.algorithm === 'priority_weighted_plus_percent'">
<el-icon><Plus /></el-icon>
</button>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="启用">
<div class="switch-toggle-line">
<button
type="button"
class="custom-switch-toggle"
:class="{ active: bindingForm.enabled }"
@click="bindingForm.enabled = !bindingForm.enabled"
>
<span class="toggle-circle" />
</button>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer-actions">
<el-button @click="bindingDrawer = false" class="btn-cancel">取消</el-button>
<el-button type="primary" :loading="savingBinding" @click="saveBinding" class="btn-save">保存</el-button>
</div>
</template>
</el-drawer>
<!-- 新增/编辑分组弹窗 -->
<el-dialog
v-model="groupFormVisible"
:title="groupFormTitle"
width="450px"
destroy-on-close
class="custom-theme-dialog"
>
<el-form :model="groupForm" :rules="groupFormRules" ref="groupFormRef" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="groupForm.name" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="groupForm.description" type="textarea" :rows="3" placeholder="请输入描述/备注(可选)" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="groupFormVisible = false" class="btn-cancel">取消</el-button>
<el-button type="primary" :loading="groupFormSaving" @click="saveWebsiteGroup" class="btn-save">保存</el-button>
</div>
</template>
</el-dialog>
<!-- 选择上游分组弹窗 -->
<el-dialog v-model="groupSelectVisible" title="选择上游分组" width="min(85vw, 1200px)" destroy-on-close class="group-select-dialog custom-theme-dialog">
<!-- 搜索和倍率筛选条 -->
<div class="dialog-filter-bar" style="margin-bottom: 16px;">
<el-input v-model="groupSearchQuery" placeholder="输入分组名称、分组 ID..." clearable class="custom-theme-search keyword-search-input">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="rate-range-inputs">
<div class="rate-input-box">
<span class="rate-input-label">倍率 &gt;=</span>
<input type="number" step="0.01" v-model.number="groupRateMin" placeholder="不限" class="rate-number-input" />
</div>
<div class="rate-input-box">
<span class="rate-input-label">倍率 &lt;=</span>
<input type="number" step="0.01" v-model.number="groupRateMax" placeholder="不限" class="rate-number-input" />
</div>
</div>
</div>
<div class="group-selector-layout">
<!-- 左栏按上游站点分类 -->
<div class="left-panel">
<div class="panel-header">上游站点</div>
<div class="list-container">
<button
type="button"
class="site-filter-btn"
:class="{ active: selectedUpstreamId === '' }"
@click="selectedUpstreamId = ''"
>
<span>全部上游</span>
<span class="site-count-badge" :class="{ 'badge-active': selectedUpstreamId === '' }">{{ upstreamGroupOptions.length }}</span>
</button>
<button
type="button"
v-for="upstream in upstreams"
:key="upstream.id"
class="site-filter-btn"
:class="{ active: selectedUpstreamId === upstream.id }"
@click="selectedUpstreamId = upstream.id"
>
<span class="site-name-text" :title="upstream.name">{{ upstream.name }}</span>
<span class="site-count-badge" :class="{ 'badge-active': selectedUpstreamId === upstream.id }">{{ upstreamCounts[upstream.id] || 0 }}</span>
</button>
</div>
</div>
<!-- 中栏展示该上游下的分组 Checkbox -->
<div class="middle-panel">
<div class="panel-header flex-header">
<el-checkbox
:model-value="isAllFilteredSelected"
:indeterminate="isSomeFilteredSelected"
@change="toggleSelectAllFiltered"
class="custom-theme-checkbox"
>
<span class="checkbox-text-label">可选分组列表 (已选 <span class="accent-text-bold">{{ tempSelectedKeys.filter(k => filteredGroupsForModal.some(g => g.key === k)).length }}</span>/{{ 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-card-item"
:class="{ selected: tempSelectedKeys.includes(item.key) }"
@click="toggleGroupKey(item.key)"
>
<div class="card-left-part">
<el-checkbox
:model-value="tempSelectedKeys.includes(item.key)"
@click.stop
@change="toggleGroupKey(item.key)"
class="custom-theme-checkbox"
/>
<div class="card-details-info">
<div class="card-meta-line">
<span class="card-upstream-badge">{{ item.source.upstream_name }}</span>
<span class="card-id-text">ID: {{ item.source.group_id }}</span>
</div>
<h4 class="card-name-title">{{ item.source.group_name }}</h4>
</div>
</div>
<div class="card-right-part">
<span class="card-rate-label">倍率:</span>
<span class="card-rate-value">{{ formatRate(item.rate) }}</span>
</div>
</div>
</div>
</div>
<!-- 右栏已选分组列表 -->
<div class="right-panel">
<div class="panel-header flex-header">
<span>已选分组 ({{ tempSelectedKeys.length }})</span>
<button type="button" v-if="tempSelectedKeys.length > 0" class="btn-clear-link" @click="clearAllTempKeys">清空</button>
</div>
<div class="list-container selected-list-scroll">
<div v-if="tempSelectedKeys.length === 0" class="empty-hint-dialog small">
<el-icon class="empty-layers-icon"><Grid /></el-icon>
<p>暂无选定分组</p>
</div>
<div
v-else
v-for="key in tempSelectedKeys"
:key="key"
class="selected-card-item"
>
<div class="selected-card-info-box" v-if="getGroupDetails(key)">
<p class="selected-card-meta-line">
{{ getGroupDetails(key)?.source.upstream_name }} · ID {{ getGroupDetails(key)?.source.group_id }}
</p>
<h5 class="selected-card-title-text">{{ getGroupDetails(key)?.source.group_name }}</h5>
</div>
<div class="selected-card-info-box" v-else>
<h5 class="selected-card-title-text">{{ key }}</h5>
</div>
<button type="button" class="btn-trash-item" @click="removeTempKey(key)">
<el-icon><Delete /></el-icon>
</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="Grok" value="grok" />
<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
:show-close="!organizeLoading"
:close-on-click-modal="!organizeLoading"
:close-on-press-escape="!organizeLoading"
:before-close="handleOrganizeDialogBeforeClose"
>
<div>
<div style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; font-size: 14px;">
<div>
<span style="margin-right: 15px;">已处理: <strong style="color: var(--el-color-primary);">{{ organizeProcessedCount }}{{ organizeTotal ? '/' + organizeTotal : '' }}</strong></span>
<span style="margin-right: 15px;">成功: <strong style="color: var(--el-color-success);">{{ organizeSuccessCount }}</strong></span>
<span style="margin-right: 15px;">跳过: <strong style="color: var(--el-color-warning);">{{ organizeSkippedCount }}</strong></span>
<span style="margin-right: 15px;">失败: <strong style="color: var(--el-color-danger);">{{ organizeFailedCount }}</strong></span>
</div>
<div v-if="organizeLoading" style="color: var(--el-color-info); display: flex; align-items: center;">
<span class="is-loading" style="margin-right: 5px; display: inline-flex;"><el-icon><Refresh /></el-icon></span>
正在整理分组...
</div>
</div>
<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%; max-height: 400px; overflow-y: auto;">
<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">{{ keyStatusLabel(row.status) }}</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>
</div>
<template #footer>
<el-button @click="organizeDialog = false" :disabled="organizeLoading">关闭</el-button>
</template>
</el-dialog>
<!-- 清理失效账号弹窗 -->
<el-dialog
v-model="cleanupDialog"
title="清理异常账号候选预览"
width="1000px"
destroy-on-close
>
<div v-loading="cleanupLoading">
<el-alert
title="清理规则说明"
type="info"
show-icon
:closable="false"
style="margin-bottom: 15px;"
>
<div style="font-size: 13px; line-height: 1.5;">
系统已自动分析出三类异常账号候选<br />
<strong>手动删除残留</strong>下游账号已被手动删除 SmartUp 本地仍记录着导入关联<br />
<strong>已归档 Key 关联</strong>本地上游 Key 已归档orphaned),但其关联的下游账号仍存在<br />
<strong>关联不一致</strong>下游账号备注引用了不存在的或关联不一致的本地 Key<br />
确认清理时将根据处理动作自动执行清标或删除无需手动判断
</div>
</el-alert>
<el-table :data="cleanupItems" border stripe style="width: 100%; max-height: 500px; overflow-y: auto;">
<el-table-column prop="account_id" label="账号 ID" width="160" show-overflow-tooltip />
<el-table-column prop="account_name" label="账号名" width="180" show-overflow-tooltip />
<el-table-column prop="upstream_name" label="来源上游" width="110" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="row.upstream_name" size="small">{{ row.upstream_name }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="source_group_name" label="来源分组" width="120" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="row.source_group_name" type="info" size="small">{{ row.source_group_name }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="upstream_key_id" label="Key ID" width="90" align="center">
<template #default="{ row }">
<span v-if="row.upstream_key_id" style="font-family: monospace;">#{{ row.upstream_key_id }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
<el-table-column prop="test_status" label="测试结果" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.test_status === 'test_passed'" type="success" size="small">测试通过</el-tag>
<el-tag v-else-if="row.test_status === 'failed'" type="danger" size="small">测试失败</el-tag>
<el-tag v-else-if="row.test_status === '404'" type="warning" size="small">404 不存在</el-tag>
<el-tag v-else-if="row.test_status === 'not_needed'" type="info" size="small">无需测试</el-tag>
<el-tag v-else-if="row.test_status === 'executed'" type="info" size="small">已执行</el-tag>
<el-tag v-else type="info" size="small">未知 / 异常</el-tag>
</template>
</el-table-column>
<el-table-column label="处理动作" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.cleanup_action === 'clear_local_marker'" type="warning" size="small">清除本地标记</el-tag>
<el-tag v-else-if="row.cleanup_action === 'delete_remote_account'" type="danger" size="small">删除下游账号</el-tag>
<el-tag v-else-if="row.cleanup_action === 'delete_remote_and_clear_marker'" type="danger" size="small">删除并清标记</el-tag>
<el-tag v-else type="info" size="small">跳过</el-tag>
</template>
</el-table-column>
<el-table-column v-if="cleanupHasExecuted" prop="action_status" label="执行结果" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.action_status === 'deleted'" type="success" effect="dark" size="small">已删除</el-tag>
<el-tag v-else-if="row.action_status === 'already_deleted'" type="warning" effect="dark" size="small">已不存在</el-tag>
<el-tag v-else-if="row.action_status === 'cleared'" type="success" effect="dark" size="small">已清标记</el-tag>
<el-tag v-else-if="row.action_status === 'skipped'" type="info" size="small">已跳过</el-tag>
<el-tag v-else-if="row.action_status === 'failed'" type="danger" effect="dark" size="small">失败</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<div v-if="cleanupItems.length === 0" style="padding: 30px; text-align: center; color: var(--el-text-color-secondary);">
没有找到可以清理的异常账号
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="cleanupDialog = false" :disabled="cleanupExecuting">
{{ cleanupHasExecuted ? '关闭' : '取消' }}
</el-button>
<el-button
v-if="!cleanupHasExecuted"
type="danger"
:loading="cleanupExecuting"
:disabled="cleanupLoading || cleanupItems.length === 0"
@click="executeCleanup"
>
确认清理 ({{ cleanupItems.length }} )
</el-button>
</div>
</template>
</el-dialog>
<!-- 设置账号参数弹窗 -->
<el-dialog
v-model="concurrencyDialog"
title="一键设置账号参数"
width="850px"
destroy-on-close
>
<div v-if="!concurrencyHasExecuted">
<el-alert
title="功能说明"
type="warning"
show-icon
:closable="false"
style="margin-bottom: 15px;"
>
<div style="font-size: 13px; line-height: 1.5;">
该操作将<strong>仅修改由 SmartUp 导入并绑定到当前网站的账号</strong>不会影响任何由目标站手工创建的其他账号<br />
只设置并发时优先使用批量更新启用上游池参数时会逐个合并账号凭据避免覆盖已有 base_url / key 等字段
</div>
</el-alert>
<el-form label-width="120px" style="margin-top: 20px;">
<el-form-item label="目标并发数">
<el-input-number
v-model="targetConcurrency"
:min="1"
:max="1000"
:step="10"
style="width: 200px;"
/>
<div style="font-size: 12px; color: var(--el-text-color-secondary); margin-top: 5px;">
请输入 1 1000 之间的并发值推荐设置为 100
</div>
</el-form-item>
<el-form-item label="上游池模式">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<el-switch
v-model="configurePoolMode"
active-text="写入上游池参数"
inactive-text="不修改"
/>
<el-button size="small" text :disabled="!configurePoolMode" @click="applyPoolModePreset">套用保守预设</el-button>
</div>
<div style="font-size: 12px; color: var(--el-text-color-secondary); margin-top: 5px;">
适用于账号背后是上游池/中转池的场景保守预设pool_mode=true重试 1 状态码 429/502/503/529
</div>
</el-form-item>
<template v-if="configurePoolMode">
<el-form-item label="启用 pool_mode">
<el-switch v-model="poolModeEnabled" />
</el-form-item>
<el-form-item label="重试次数">
<el-input-number
v-model="poolModeRetryCount"
:min="0"
:max="10"
style="width: 200px;"
/>
</el-form-item>
<el-form-item label="重试状态码">
<el-input
v-model="poolModeRetryStatusCodes"
placeholder="429,502,503,529"
style="max-width: 360px;"
/>
<div style="font-size: 12px; color: var(--el-text-color-secondary); margin-top: 5px;">
用逗号或空格分隔关闭 pool_mode 时会清空同账号重试状态码
</div>
</el-form-item>
</template>
</el-form>
</div>
<div v-else>
<div style="margin-bottom: 15px; font-weight: bold; color: var(--el-color-primary);">
{{ concurrencyMessage }}
</div>
<el-table :data="concurrencyResults" border stripe size="small" style="width: 100%; max-height: 400px; overflow-y: auto;">
<el-table-column prop="account_id" label="账号 ID" width="150" />
<el-table-column prop="account_name" label="账号名" min-width="180" show-overflow-tooltip />
<el-table-column prop="old_concurrency" label="当前并发" width="110" align="center">
<template #default="{ row }">
<span v-if="row.old_concurrency !== null">{{ row.old_concurrency }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="target_concurrency" label="目标并发" width="110" align="center" />
<el-table-column label="Pool Mode" width="120" align="center">
<template #default="{ row }">
<span v-if="row.target_pool_mode === true">开启</span>
<span v-else-if="row.target_pool_mode === false">关闭</span>
<span v-else class="text-muted">未修改</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 'success'" type="success" size="small">成功</el-tag>
<el-tag v-else-if="row.status === 'skipped'" type="warning" size="small">跳过</el-tag>
<el-tag v-else-if="row.status === 'failed'" type="danger" size="small">失败</el-tag>
<el-tag v-else type="info" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明/原因" min-width="150" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="concurrencyDialog = false" :disabled="concurrencyExecuting">
{{ concurrencyHasExecuted ? '关闭' : '取消' }}
</el-button>
<el-button
v-if="!concurrencyHasExecuted"
type="primary"
:loading="concurrencyExecuting"
@click="submitSetConcurrency"
>
确认执行
</el-button>
</div>
</template>
</el-dialog>
<!-- 同步上游模型结果弹窗 -->
<el-dialog
v-model="syncModelsDialog"
title="一键同步上游模型结果"
width="850px"
destroy-on-close
:show-close="!syncModelsExecuting"
:close-on-click-modal="!syncModelsExecuting"
:close-on-press-escape="!syncModelsExecuting"
:before-close="handleSyncModelsDialogBeforeClose"
>
<div>
<div style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; font-size: 14px;">
<div>
<span style="margin-right: 15px;">已处理: <strong style="color: var(--el-color-primary);">{{ syncModelsProcessedCount }}/{{ syncModelsTotal }}</strong></span>
<span style="margin-right: 15px;">成功: <strong style="color: var(--el-color-success);">{{ syncModelsSuccessCount }}</strong></span>
<span style="margin-right: 15px;">失败: <strong style="color: var(--el-color-danger);">{{ syncModelsFailedCount }}</strong></span>
<span>跳过: <strong style="color: var(--el-color-warning);">{{ syncModelsSkippedCount }}</strong></span>
</div>
<div v-if="syncModelsExecuting" style="color: var(--el-color-info); display: flex; align-items: center;">
<span class="is-loading" style="margin-right: 5px; display: inline-flex;"><el-icon><Refresh /></el-icon></span>
正在同步模型...
</div>
</div>
<div v-if="syncModelsMessage" style="margin-bottom: 15px; font-weight: bold; color: var(--el-color-primary);">
{{ syncModelsMessage }}
</div>
<el-table :data="syncModelsResults" border stripe size="small" style="width: 100%; max-height: 400px; overflow-y: auto;">
<el-table-column prop="account_id" label="账号 ID" width="150" />
<el-table-column prop="account_name" label="账号名" min-width="180" show-overflow-tooltip />
<el-table-column prop="model_count" label="同步模型数" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.models && row.models.length > 0"
class="box-item"
effect="dark"
placement="top"
>
<template #content>
<div style="max-width: 300px; word-break: break-all;">
{{ row.models.join(', ') }}
</div>
</template>
<el-tag type="info" size="small" style="cursor: pointer;">
{{ row.model_count }}
</el-tag>
</el-tooltip>
<span v-else class="text-muted">0 </span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 'success'" type="success" size="small">成功</el-tag>
<el-tag v-else-if="row.status === 'skipped'" type="warning" size="small">跳过</el-tag>
<el-tag v-else-if="row.status === 'failed'" type="danger" size="small">失败</el-tag>
<el-tag v-else type="info" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明/原因" min-width="200" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="syncModelsDialog = false" :disabled="syncModelsExecuting">
关闭
</el-button>
</div>
</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, Minus, Grid, Connection, Link, Upload, Key, Refresh, Sort, WarningFilled, Search, Close } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
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,
type CleanupInvalidAccountsItem,
type SetConcurrencyItem,
type SyncUpstreamModelsItem,
readNdjsonStream,
} from '@/api'
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
const upstreams = ref<UpstreamData[]>([])
const selectedWebsite = ref<WebsiteData | null>(null)
const websiteGroups = ref<WebsiteGroup[]>([])
const groupsError = ref('')
const groupRequestSeq = ref(0)
const sortedWebsiteGroups = computed(() => {
return [...websiteGroups.value].sort((a, b) => {
const rateA = a.rate_multiplier !== null && a.rate_multiplier !== undefined && a.rate_multiplier !== '—'
? parseFloat(String(a.rate_multiplier))
: NaN
const rateB = b.rate_multiplier !== null && b.rate_multiplier !== undefined && b.rate_multiplier !== '—'
? parseFloat(String(b.rate_multiplier))
: NaN
const isNaN_A = isNaN(rateA)
const isNaN_B = isNaN(rateB)
if (isNaN_A && isNaN_B) {
return a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id))
}
if (isNaN_A) return 1
if (isNaN_B) return -1
if (rateA !== rateB) {
return rateA - rateB
}
return a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id))
})
})
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 keyStatusLabel = (s: string) => ({ created: '已创建', exists: '已存在', imported: '已导入', orphaned: '已归档', failed: '失败', import_failed: '导入失败', recreated: '清理后重建', missing_key: '缺少 Key' }[s] || '未知状态')
const algorithmLabel = (s: string) => ({ max_plus_percent: '最高倍率', average_plus_percent: '平均倍率', min_plus_percent: '最低倍率', priority_weighted_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: 'priority_weighted_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 groupFormVisible = ref(false)
const groupFormSaving = ref(false)
const editingGroupId = ref<string | null>(null)
const groupFormRef = ref<FormInstance>()
const groupForm = ref({
name: '',
description: '',
})
const groupFormRules = {
name: [{ required: true, message: '请输入分组名称', trigger: 'blur' }],
}
const groupFormTitle = computed(() => {
return editingGroupId.value ? '编辑分组' : '新增分组'
})
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: 100,
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 organizeTotal = ref(0)
const organizeSuccessCount = computed(() => organizeResults.value.filter(r => r.status === 'created' || r.status === 'recreated' || r.status === 'exists').length)
const organizeFailedCount = computed(() => organizeResults.value.filter(r => r.status === 'failed').length)
const organizeSkippedCount = computed(() => organizeResults.value.filter(r => r.status === 'missing_key').length)
const organizeProcessedCount = computed(() => organizeResults.value.length)
function handleOrganizeDialogBeforeClose(done: () => void) {
if (organizeLoading.value) {
return
}
done()
}
const cleanupDialog = ref(false)
const cleanupLoading = ref(false)
const cleanupExecuting = ref(false)
const cleanupHasExecuted = ref(false)
const cleanupItems = ref<CleanupInvalidAccountsItem[]>([])
const concurrencyDialog = ref(false)
const concurrencyExecuting = ref(false)
const concurrencyHasExecuted = ref(false)
const targetConcurrency = ref(100)
const configurePoolMode = ref(true)
const poolModeEnabled = ref(true)
const poolModeRetryCount = ref(1)
const poolModeRetryStatusCodes = ref('429,502,503,529')
const concurrencyMessage = ref('')
const concurrencyResults = ref<SetConcurrencyItem[]>([])
const syncModelsDialog = ref(false)
const syncModelsExecuting = ref(false)
const syncModelsMessage = ref('')
const syncModelsResults = ref<SyncUpstreamModelsItem[]>([])
const syncModelsTotal = ref(0)
const syncModelsSuccessCount = computed(() => syncModelsResults.value.filter(r => r.status === 'success').length)
const syncModelsFailedCount = computed(() => syncModelsResults.value.filter(r => r.status === 'failed').length)
const syncModelsSkippedCount = computed(() => syncModelsResults.value.filter(r => r.status === 'skipped').length)
const syncModelsProcessedCount = computed(() => syncModelsResults.value.length)
function handleSyncModelsDialogBeforeClose(done: () => void) {
if (syncModelsExecuting.value) {
return
}
done()
}
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
websiteGroups.value = []
groupsError.value = ''
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) {
groupRequestSeq.value += 1
websiteGroups.value = []
groupsError.value = ''
groupsLoading.value = false
return
}
const websiteId = selectedWebsite.value.id
const requestSeq = ++groupRequestSeq.value
groupsLoading.value = true
groupsError.value = ''
try {
const res = await websitesApi.groups(websiteId)
if (requestSeq !== groupRequestSeq.value || selectedWebsite.value?.id !== websiteId) return
websiteGroups.value = res.data
} catch (e: any) {
if (requestSeq !== groupRequestSeq.value || selectedWebsite.value?.id !== websiteId) return
groupsError.value = e.response?.data?.detail || e.message || '拉取分组失败'
ElMessage.error(groupsError.value)
} finally {
if (requestSeq === groupRequestSeq.value && selectedWebsite.value?.id === websiteId) {
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
websiteGroups.value = []
groupsError.value = ''
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 = []
groupsError.value = ''
groupRequestSeq.value += 1
groupsLoading.value = false
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
} else {
const first = bindingWebsiteGroups.value[0]
if (first) {
bindingForm.value.target_group_id = first.id
bindingForm.value.target_group_name = first.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)
const first = bindingWebsiteGroups.value[0]
if (first) {
bindingForm.value.target_group_id = first.id
bindingForm.value.target_group_name = first.name
}
}
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))
}
function openGroupCreate() {
editingGroupId.value = null
groupForm.value = {
name: '',
description: '',
}
groupFormVisible.value = true
}
function openGroupEdit(row: WebsiteGroup) {
editingGroupId.value = row.id
groupForm.value = {
name: row.name,
description: row.description || '',
}
groupFormVisible.value = true
}
async function saveWebsiteGroup() {
if (!selectedWebsite.value) return
const websiteId = selectedWebsite.value.id
if (!groupFormRef.value) return
await groupFormRef.value.validate(async (valid) => {
if (!valid) return
groupFormSaving.value = true
try {
if (editingGroupId.value) {
await websitesApi.updateGroup(websiteId, editingGroupId.value, {
name: groupForm.value.name,
description: groupForm.value.description,
})
ElMessage.success('编辑成功')
} else {
await websitesApi.createGroup(websiteId, {
name: groupForm.value.name,
description: groupForm.value.description,
})
ElMessage.success('新增成功')
}
groupFormVisible.value = false
await Promise.all([loadWebsiteGroups(), loadBindings()])
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || e.message || '保存失败')
} finally {
groupFormSaving.value = false
}
})
}
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 groupRateMin = ref<number | null>(null)
const groupRateMax = ref<number | null>(null)
const selectedUpstreamId = ref<number | ''>('')
const tempSelectedKeys = ref<string[]>([])
function openGroupSelectModal() {
tempSelectedKeys.value = [...sourceGroupKeys.value]
groupSearchQuery.value = ''
selectedUpstreamId.value = ''
groupRateMin.value = null
groupRateMax.value = null
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
})
function normalizeRateBound(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const filteredGroupsForModal = computed(() => {
const query = groupSearchQuery.value.trim().toLowerCase()
return upstreamGroupOptions.value.filter(item => {
// 1. 上游站点过滤
if (selectedUpstreamId.value !== '' && item.source.upstream_id !== selectedUpstreamId.value) {
return false
}
// 2. 关键词过滤:只匹配 group_name、group_id
if (query) {
const matchGroupName = item.source.group_name.toLowerCase().includes(query)
const matchGroupId = String(item.source.group_id).toLowerCase().includes(query)
if (!matchGroupName && !matchGroupId) {
return false
}
}
// 3. 倍率范围过滤
const min = normalizeRateBound(groupRateMin.value)
const max = normalizeRateBound(groupRateMax.value)
if (min !== null || max !== null) {
const rateVal = parseFloat(String(item.rate))
if (isNaN(rateVal)) {
return false
}
if (min !== null && rateVal < min) {
return false
}
if (max !== null && rateVal > max) {
return false
}
}
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
}
function adjustPercent(amount: number) {
let val = Number(bindingForm.value.percent) || 0
val = Math.max(0, Math.min(100, val + amount))
bindingForm.value.percent = Number(val.toFixed(2))
}
function formatRate(rate: string | number) {
if (typeof rate === 'number') return rate.toFixed(2)
const parsed = parseFloat(String(rate))
if (!isNaN(parsed)) return parsed.toFixed(2)
return rate
}
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
}
organizeResults.value = []
organizeMessage.value = ''
organizeTotal.value = 0
organizeLoading.value = true
organizeDialog.value = true
const authStore = useAuthStore()
const { ok, message } = await readNdjsonStream(
`/api/websites/${selectedWebsite.value.id}/groups/organize/stream`,
{ method: 'POST', token: authStore.token },
{
onStart(data) { organizeTotal.value = (data.total_items as number) || 0 },
onItem(data: any) { organizeResults.value.push(data) },
onComplete(data) {
organizeMessage.value = (data.message as string) || ''
if (data.success) ElMessage.success('整理完成')
else ElMessage.warning((data.message as string) || '部分账号整理失败')
},
onError(data) { throw new Error((data.message as string) || '流式整理出错') },
},
)
if (!ok) {
if (message.includes('正在执行中')) {
ElMessage.warning(message)
} else {
ElMessage.error(message || '一键整理分组失败')
}
if (organizeResults.value.length === 0) {
organizeDialog.value = false
}
} else {
// 刷新相关数据
await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()])
}
organizeLoading.value = false
}
async function openCleanupDialog() {
if (!selectedWebsite.value) return
cleanupItems.value = []
cleanupHasExecuted.value = false
cleanupDialog.value = true
cleanupLoading.value = true
try {
const res = await websitesApi.cleanupPreview(selectedWebsite.value.id)
if (res.data.success) {
cleanupItems.value = res.data.items
} else {
ElMessage.error(res.data.message || '加载失效账号候选预览失败')
cleanupDialog.value = false
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '加载失效账号候选预览失败')
cleanupDialog.value = false
} finally {
cleanupLoading.value = false
}
}
async function executeCleanup() {
if (!selectedWebsite.value) return
try {
await ElMessageBox.confirm(
'确认清理异常账号?此操作将按预览中的处理动作执行:清除本地标记仅清 SmartUp 导入关联(不调远端接口),删除下游账号/删除并清标记会永久从目标站删除对应账号(即使远端测试通过也会删除)。',
'确认清理',
{ type: 'warning' }
)
} catch {
return
}
cleanupExecuting.value = true
try {
const res = await websitesApi.cleanupExecute(selectedWebsite.value.id)
if (res.data.success) {
cleanupItems.value = res.data.items
cleanupHasExecuted.value = true
ElMessage.success(res.data.message || '清理完成')
await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()])
} else {
ElMessage.error(res.data.message || '执行清理失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '执行清理失败')
} finally {
cleanupExecuting.value = false
}
}
function openConcurrencyDialog() {
if (!selectedWebsite.value) return
targetConcurrency.value = 100
applyPoolModePreset()
concurrencyHasExecuted.value = false
concurrencyResults.value = []
concurrencyMessage.value = ''
concurrencyDialog.value = true
}
function applyPoolModePreset() {
configurePoolMode.value = true
poolModeEnabled.value = true
poolModeRetryCount.value = 1
poolModeRetryStatusCodes.value = '429,502,503,529'
}
function parsePoolModeRetryStatusCodes() {
const seen = new Set<number>()
poolModeRetryStatusCodes.value
.split(/[,\s]+/)
.map(part => Number(part.trim()))
.forEach(code => {
if (Number.isInteger(code) && code >= 100 && code <= 599) {
seen.add(code)
}
})
return [...seen].sort((a, b) => a - b)
}
async function submitSetConcurrency() {
if (!selectedWebsite.value) return
const retryStatusCodes = poolModeEnabled.value ? parsePoolModeRetryStatusCodes() : []
const confirmMessage = configurePoolMode.value
? `确认将当前网站中由 SmartUp 导入账号的并发数设置为 ${targetConcurrency.value},并写入上游池参数?`
: `确认将当前网站中由 SmartUp 导入账号的并发数一键设置为 ${targetConcurrency.value}`
try {
await ElMessageBox.confirm(
confirmMessage,
'确认修改账号参数',
{ type: 'warning' }
)
} catch {
return
}
concurrencyExecuting.value = true
try {
const res = await websitesApi.setConcurrency(selectedWebsite.value.id, {
concurrency: targetConcurrency.value,
configure_pool_mode: configurePoolMode.value,
pool_mode: poolModeEnabled.value,
pool_mode_retry_count: poolModeRetryCount.value,
pool_mode_retry_status_codes: retryStatusCodes,
})
concurrencyMessage.value = res.data.message
concurrencyResults.value = res.data.items
concurrencyHasExecuted.value = true
if (res.data.success) {
ElMessage.success('设置完成')
} else {
ElMessage.warning(res.data.message || '部分账号设置并发失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '设置并发失败')
} finally {
concurrencyExecuting.value = false
}
}
async function triggerSyncUpstreamModels() {
if (!selectedWebsite.value) return
try {
await ElMessageBox.confirm(
'确认开始同步上游模型?此操作将仅拉取当前网站中由 SmartUp 导入账号的上游支持模型,并自动更新写入对应的模型白名单/映射配置。',
'确认同步上游模型',
{ type: 'warning' }
)
} catch {
return
}
syncModelsResults.value = []
syncModelsMessage.value = ''
syncModelsTotal.value = 0
syncModelsExecuting.value = true
syncModelsDialog.value = true
let hasProcessedStart = false
const authStore = useAuthStore()
const { ok, message } = await readNdjsonStream(
`/api/websites/${selectedWebsite.value.id}/accounts/sync-upstream-models/stream`,
{ method: 'POST', token: authStore.token },
{
onStart(data) {
syncModelsTotal.value = (data.total_accounts as number) || 0
hasProcessedStart = true
},
onItem(data: any) { syncModelsResults.value.push(data) },
onComplete(data) {
syncModelsMessage.value = (data.message as string) || ''
if (data.success) ElMessage.success('同步完成')
else ElMessage.warning((data.message as string) || '部分账号同步模型失败')
},
onError() { /* handled via !ok */ },
},
)
if (!ok) {
if (message.includes('正在执行中')) {
ElMessage.warning(message)
} else {
ElMessage.error(message || '同步上游模型失败')
}
if (!hasProcessedStart || syncModelsResults.value.length === 0) {
syncModelsDialog.value = false
}
}
syncModelsExecuting.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: 100,
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('grok') || text.includes('xai')) return 'Grok'
if (text.includes('antigravity')) return 'Antigravity'
return 'OpenAI 兼容'
}
function platformLabel(platform: string) {
const map: Record<string, string> = {
openai: 'OpenAI 兼容',
anthropic: 'Anthropic',
gemini: 'Gemini',
grok: 'Grok',
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;
}
.website-groups-body {
min-height: 220px;
position: relative;
}
.groups-state-panel {
min-height: 220px;
padding: 32px 18px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
color: var(--text-muted);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent),
rgba(255, 255, 255, 0.01);
}
.groups-state-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.groups-state-sub {
max-width: 360px;
font-size: 12px;
line-height: 1.6;
color: var(--text-muted);
overflow-wrap: anywhere;
}
.groups-state-loading .groups-state-title {
color: var(--color-primary-strong);
}
.groups-state-error .groups-state-title {
color: var(--color-danger);
}
.groups-spinner {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(166, 141, 133, 0.25);
border-top-color: var(--color-primary-strong);
animation: groups-spin 0.8s linear infinite;
}
.groups-spinner-small {
width: 14px;
height: 14px;
border-width: 2px;
}
.groups-refresh-strip {
min-height: 34px;
padding: 0 14px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 12px;
background: rgba(255, 255, 255, 0.02);
}
@keyframes groups-spin {
to {
transform: rotate(360deg);
}
}
.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 */
.custom-selector-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
background: #160c0a;
border: 1px solid #3e2821;
border-radius: 6px;
}
.selector-info-area {
flex: 1;
min-width: 0;
}
.selected-count-line {
font-size: 12px;
color: #a68d85;
}
.accent-bold-text {
color: #df9b56;
font-weight: 700;
}
.empty-placeholder-text {
font-size: 12px;
color: rgba(166, 141, 133, 0.6);
margin-top: 4px;
}
.tags-container-inline {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.custom-inline-tag {
background: rgba(223, 155, 86, 0.08) !important;
border-color: rgba(223, 155, 86, 0.2) !important;
color: #df9b56 !important;
max-width: 100%;
}
.btn-select-trigger {
padding: 8px 16px;
background: #df9b56;
color: #1b110e;
border: none;
font-size: 12px;
font-weight: 700;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.btn-select-trigger:hover {
background: #cb8440;
}
/* Custom Stepper Input */
.custom-percent-stepper {
display: flex;
border: 1px solid #3e2821;
border-radius: 4px;
background: #160c0a;
overflow: hidden;
height: 36px;
align-items: center;
width: 100%;
}
.stepper-btn {
width: 36px;
height: 100%;
border: none;
background: transparent;
color: #a68d85;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.stepper-btn:hover {
background: #271a16;
color: #f5ecea;
}
.stepper-input {
flex: 1;
border: none;
background: transparent;
text-align: center;
font-size: 13px;
color: #f5ecea;
outline: none;
width: 100%;
}
.stepper-input::-webkit-inner-spin-button,
.stepper-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.custom-percent-stepper.disabled {
opacity: 0.6;
background: #0f0706;
border-color: #2b1c18;
}
.custom-percent-stepper.disabled .stepper-btn {
cursor: not-allowed;
color: #5c4a45;
}
.custom-percent-stepper.disabled .stepper-btn:hover {
background: transparent;
color: #5c4a45;
}
.custom-percent-stepper.disabled .stepper-input {
cursor: not-allowed;
color: #8c746b;
}
/* Custom Switch Toggle */
.switch-toggle-line {
display: flex;
align-items: center;
min-height: 36px;
}
.custom-switch-toggle {
width: 48px;
height: 24px;
border-radius: 12px;
background: #160c0a;
border: 1px solid #3e2821;
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
position: relative;
transition: all 0.2s;
}
.custom-switch-toggle.active {
border-color: #df9b56;
}
.toggle-circle {
width: 18px;
height: 18px;
border-radius: 50%;
background: #a68d85;
transition: transform 0.2s, background-color 0.2s;
transform: translateX(0);
}
.custom-switch-toggle.active .toggle-circle {
transform: translateX(24px);
background: #df9b56;
}
/* Redesigned Button Actions */
.btn-cancel, .btn-save {
font-size: 12px;
font-weight: 700;
border-radius: 4px;
border: none !important;
transition: all 0.2s;
}
.btn-cancel {
background: #f5ecea !important;
color: #1b110e !important;
}
.btn-cancel:hover {
background: rgba(245, 236, 234, 0.9) !important;
}
.btn-save {
background: #df9b56 !important;
color: #1b110e !important;
}
.btn-save:hover {
background: #cb8440 !important;
}
</style>
<style>
/* Custom Theme Dialog & Drawer */
.custom-binding-drawer .el-drawer__body,
.custom-binding-drawer .el-drawer__header,
.custom-binding-drawer .el-drawer__footer {
background: #271a16 !important;
color: #f5ecea !important;
}
.custom-binding-drawer .el-drawer__header {
border-bottom: 1px solid #3e2821;
margin-bottom: 0;
padding: 16px 20px;
}
.custom-binding-drawer .el-drawer__title {
color: #f5ecea !important;
font-weight: 700;
}
.custom-binding-drawer .el-drawer__footer {
border-top: 1px solid #3e2821;
background: rgba(22, 12, 10, 0.3) !important;
padding: 16px 20px;
}
.custom-binding-drawer .el-form-item__label {
color: #a68d85 !important;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Dialog Theme */
.custom-theme-dialog .el-dialog {
background: #271a16 !important;
border: 1px solid #3e2821;
border-radius: 8px;
width: min(85vw, 1200px) !important;
}
.custom-theme-dialog .el-dialog__header {
border-bottom: 1px solid #3e2821;
margin-right: 0;
padding: 16px 20px 12px;
}
.custom-theme-dialog .el-dialog__title {
color: #f5ecea !important;
font-weight: 700;
font-size: 15px;
}
.custom-theme-dialog .el-dialog__footer {
border-top: 1px solid #3e2821;
background: rgba(22, 12, 10, 0.3);
padding: 12px 20px;
}
/* Select Option Input Search box */
.custom-theme-search .el-input__wrapper {
background: #160c0a !important;
border: 1px solid #3e2821 !important;
box-shadow: none !important;
}
.custom-theme-search .el-input__inner {
color: #f5ecea !important;
font-size: 12px;
}
.custom-theme-search .el-input__inner::placeholder {
color: rgba(166, 141, 133, 0.4) !important;
}
/* Element Select Box Theme */
.custom-select-wrapper {
width: 100%;
}
.custom-theme-select {
width: 100% !important;
}
.custom-theme-select .el-select__wrapper {
background: #160c0a !important;
border: 1px solid #3e2821 !important;
box-shadow: none !important;
width: 100% !important;
min-height: 36px;
}
.custom-theme-select .el-select__placeholder {
color: #f5ecea !important;
}
.custom-theme-select .el-select__selected-item {
color: #f5ecea !important;
}
</style>
<style scoped>
/* Layout for selectors dialog */
.group-selector-layout {
display: grid;
grid-template-columns: 200px 1fr 240px;
gap: 16px;
height: 55vh;
min-height: 450px;
max-height: 750px;
border: 1px solid #3e2821;
border-radius: 8px;
background: #271a16;
overflow: hidden;
}
.left-panel, .middle-panel, .right-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.left-panel {
border-right: 1px solid #3e2821;
}
.right-panel {
border-left: 1px solid #3e2821;
}
.panel-header {
padding: 10px 14px;
font-size: 12px;
font-weight: 600;
color: #f5ecea;
background: rgba(22, 12, 10, 0.3);
border-bottom: 1px solid #3e2821;
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 sidebar category filters */
.site-filter-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 4px;
background: transparent;
border: 1px solid transparent;
font-size: 12px;
color: #a68d85;
width: 100%;
text-align: left;
transition: all 0.15s ease;
}
.site-filter-btn:hover {
background: #160c0a;
color: #f5ecea;
}
.site-filter-btn.active {
background: rgba(223, 155, 86, 0.1);
border-color: rgba(223, 155, 86, 0.4);
color: #df9b56;
font-weight: 600;
}
.site-count-badge {
background: #160c0a;
color: #a68d85;
padding: 2px 6px;
border-radius: 10px;
font-size: 9px;
}
.site-count-badge.badge-active {
background: #f5ecea;
color: #1b110e;
font-weight: 700;
}
/* Middle panel group card items */
.group-card-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 8px;
background: #160c0a;
border: 1px solid rgba(62, 40, 33, 0.6);
transition: all 0.15s ease;
}
.group-card-item:hover {
border-color: #df9b56;
}
.group-card-item.selected {
background: rgba(223, 155, 86, 0.04);
border-color: #df9b56;
}
.card-left-part {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.card-details-info {
min-width: 0;
flex: 1;
}
.card-meta-line {
display: flex;
align-items: center;
}
.card-upstream-badge {
background: rgba(223, 155, 86, 0.1);
color: #df9b56;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
}
.card-id-text {
color: #a68d85;
font-size: 10px;
margin-left: 8px;
}
.card-name-title {
font-size: 13px;
color: #f5ecea;
margin-top: 6px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-right-part {
text-align: right;
flex-shrink: 0;
}
.card-rate-label {
font-size: 9px;
color: #a68d85;
display: block;
}
.card-rate-value {
font-size: 13px;
font-weight: 700;
color: #df9b56;
}
.accent-text-bold {
color: #df9b56;
font-weight: 700;
}
/* Right panel selected card items */
.selected-card-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
background: #160c0a;
border: 1px solid #3e2821;
border-radius: 4px;
margin-bottom: 8px;
}
.selected-card-info-box {
flex: 1;
min-width: 0;
}
.selected-card-meta-line {
font-size: 9px;
color: #a68d85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-card-title-text {
font-size: 12px;
color: #f5ecea;
font-weight: 500;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-trash-item {
background: transparent;
border: none;
color: #a68d85;
cursor: pointer;
padding: 4px;
transition: color 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-trash-item:hover {
color: #df9b56;
}
.btn-clear-link {
background: transparent;
border: none;
color: #df9b56;
font-size: 11px;
cursor: pointer;
transition: color 0.15s;
}
.btn-clear-link:hover {
color: #cb8440;
}
.empty-layers-icon {
font-size: 24px;
color: rgba(166, 141, 133, 0.2);
margin-bottom: 8px;
}
/* Combination Search and Filter Bar */
.dialog-filter-bar {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.keyword-search-input {
flex: 1;
}
.rate-range-inputs {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.rate-input-box {
display: flex;
align-items: center;
background: #160c0a;
border: 1px solid #3e2821;
border-radius: 4px;
height: 36px;
padding: 0 10px;
gap: 8px;
}
.rate-input-label {
font-size: 11px;
color: #a68d85;
white-space: nowrap;
font-weight: 600;
text-transform: uppercase;
}
.rate-number-input {
width: 80px;
border: none;
background: transparent;
color: #f5ecea;
outline: none;
font-size: 12px;
height: 100%;
text-align: center;
}
.rate-number-input::placeholder {
color: rgba(166, 141, 133, 0.3);
}
.rate-number-input::-webkit-inner-spin-button,
.rate-number-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Responsive dialog styling */
@media (max-width: 768px) {
.dialog-filter-bar {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.rate-range-inputs {
justify-content: space-between;
width: 100%;
}
.rate-input-box {
flex: 1;
justify-content: center;
}
.rate-number-input {
width: 100%;
}
.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 #3e2821;
max-height: 120px;
height: auto;
}
.left-panel .list-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
}
.left-panel .site-filter-btn {
margin-bottom: 0;
padding: 4px 10px;
width: auto;
}
.middle-panel {
max-height: 240px;
}
.right-panel {
border-left: none;
border-top: 1px solid #3e2821;
max-height: 160px;
}
}
</style>