3371 lines
116 KiB
Vue
3371 lines
116 KiB
Vue
<template>
|
||
<div class="shell-page shell-page-fluid page-section websites-page">
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<div class="panel-title">检测与变更控制台</div>
|
||
<div class="panel-actions">
|
||
<el-button size="small" text @click="loadAll">刷新</el-button>
|
||
<el-button size="small" type="primary" @click="openWebsiteCreate">新增网站</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="websites" v-loading="websiteLoading" row-key="id" style="width:100%">
|
||
<el-table-column label="名称" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="cell-name">{{ row.name }}</div>
|
||
<div class="cell-url">{{ row.base_url }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }">
|
||
<span :class="['status-badge', row.last_status]"><span class="dot" />{{ statusLabel(row.last_status) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="自动同步" width="100">
|
||
<template #default="{ row }">
|
||
<el-switch v-model="row.auto_sync_enabled" @change="toggleWebsiteSync(row)" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="超时" width="80">
|
||
<template #default="{ row }">{{ row.timeout_seconds }}s</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近错误" min-width="180">
|
||
<template #default="{ row }">
|
||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||
<span class="error-text">{{ row.last_error.substring(0, 42) }}…</span>
|
||
</el-tooltip>
|
||
<span v-else class="muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="340">
|
||
<template #default="{ row }">
|
||
<div class="action-row">
|
||
<el-button size="small" text @click="openWebsiteEdit(row)" title="编辑网站配置(API凭证、参数等)">编辑</el-button>
|
||
<el-button size="small" text @click="selectWebsite(row)" title="查看当前网站的分组和绑定配置">查看</el-button>
|
||
<el-button size="small" text @click="testWebsite(row)" :loading="row._testing" title="仅验证连通性,测试API是否正常响应">测试连接</el-button>
|
||
<el-button size="small" text @click="openBindingCreate(row)" title="为此网站创建一个新的上游倍率绑定规则">新增绑定</el-button>
|
||
<el-button size="small" text @click="openImportGroups(row)" title="一键从上游导入可用分组到此网站">导入分组</el-button>
|
||
<el-button size="small" text @click="openImportAccounts(row)" title="将此网站下的所有账户批量导入账号管理">导入账号</el-button>
|
||
<el-button size="small" text type="danger" @click="deleteWebsite(row)" title="删除此网站及其所有的绑定配置">删除</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="content-grid">
|
||
<div class="panel 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">倍率 >=</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">倍率 <=</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>
|