1676 lines
58 KiB
Vue
1676 lines
58 KiB
Vue
<template>
|
||
<div class="shell-page shell-page-fluid page-section upstreams-page">
|
||
<section class="surface-card data-stage">
|
||
<div class="section-header data-stage-head">
|
||
<div>
|
||
<div class="section-caption">Upstream Registry</div>
|
||
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
||
</div>
|
||
<div class="toolbar-cluster">
|
||
<el-button
|
||
id="btn-test-all"
|
||
size="small"
|
||
text
|
||
:loading="testingAll"
|
||
:disabled="list.length === 0 || testingAll || checkingAll"
|
||
@click="testAll"
|
||
title="批量测试所有启用上游的连接(不写快照)"
|
||
>一键测试</el-button>
|
||
<el-button
|
||
id="btn-check-now-all"
|
||
size="small"
|
||
type="warning"
|
||
plain
|
||
:loading="checkingAll"
|
||
:disabled="list.length === 0 || testingAll || checkingAll"
|
||
@click="checkNowAll"
|
||
title="批量完整同步所有启用上游(拉取倍率、写快照、触发 Webhook)"
|
||
>一键同步</el-button>
|
||
<el-button size="small" text @click="loadList" :loading="tableLoading">刷新</el-button>
|
||
<el-button size="small" type="primary" @click="openCreate">新增上游</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
||
<el-table-column label="来源" min-width="220">
|
||
<template #default="{ row }">
|
||
<div class="cell-name">{{ row.name }}</div>
|
||
<div class="cell-url mono">{{ row.base_url }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="状态" width="118">
|
||
<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="140" align="right">
|
||
<template #default="{ row }">
|
||
<span v-if="row.balance !== null && row.balance !== undefined" class="balance-value mono">
|
||
{{ formatBalance(row.balance) }}
|
||
</span>
|
||
<span v-else class="muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="启用" width="88" align="center">
|
||
<template #default="{ row }">
|
||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="认证" width="132">
|
||
<template #default="{ row }">
|
||
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="检测间隔" width="112">
|
||
<template #default="{ row }">
|
||
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="最近检测" min-width="168">
|
||
<template #default="{ row }">
|
||
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
|
||
<span v-else class="muted">未检测</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="最近错误" min-width="220">
|
||
<template #default="{ row }">
|
||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
|
||
</el-tooltip>
|
||
<span v-else class="muted">无异常</span>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="操作" width="280">
|
||
<template #default="{ row }">
|
||
<div class="action-row">
|
||
<el-button size="small" text @click="openEdit(row)" title="编辑上游配置(认证、接口、余额等)">编辑</el-button>
|
||
<el-button size="small" text @click="testUpstream(row)" :loading="row._testing" title="仅验证连通性:登录 + 拉取分组列表,不写快照、不触发通知">测试连接</el-button>
|
||
<el-button size="small" text @click="checkNow(row)" :loading="row._checking" title="完整同步:拉取倍率 → 生成快照 → 对比变化 → 触发 Webhook → 同步 Key">立即同步</el-button>
|
||
<el-button size="small" text @click="openKeyGenerate(row)" title="为每个分组确保存在一个 SmartUp 托管 Key">生成Key</el-button>
|
||
<el-button size="small" text @click="openDetail(row)" title="查看快照历史、分组倍率与已创建 Key">详情</el-button>
|
||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除该上游及其所有快照和 Key 记录">删除</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
|
||
<el-drawer
|
||
v-model="drawerVisible"
|
||
:title="editingId ? '编辑上游' : '新增上游'"
|
||
size="480px"
|
||
destroy-on-close
|
||
>
|
||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||
<el-form-item label="名称" prop="name">
|
||
<el-input v-model="form.name" placeholder="例:ai98pro" />
|
||
</el-form-item>
|
||
<el-form-item v-if="!editingId" label="系统类型(快捷配置)">
|
||
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
|
||
<el-option label="Sub2API" value="sub2api" />
|
||
<el-option label="New-API" value="new-api-user" />
|
||
<el-option label="Nox-API" value="nox-api" />
|
||
<el-option label="自定义" value="custom" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="Base URL" prop="base_url">
|
||
<el-input v-model="form.base_url" placeholder="https://example.com" />
|
||
</el-form-item>
|
||
<el-form-item label="API Prefix">
|
||
<el-input v-model="form.api_prefix" :placeholder="apiPrefixPlaceholder" />
|
||
<div v-if="apiPrefixHint" class="form-hint">{{ apiPrefixHint }}</div>
|
||
</el-form-item>
|
||
<el-form-item label="认证方式">
|
||
<el-select v-model="form.auth_type" style="width: 100%" @change="handleAuthTypeChange">
|
||
<el-option label="无认证" value="none" />
|
||
<el-option label="Bearer Token" value="bearer" />
|
||
<el-option label="New-API Access Token" value="new_api_token" />
|
||
<el-option label="Nox Access Token" value="nox_token" />
|
||
<el-option label="Cookie" value="cookie" />
|
||
<el-option label="API Key" value="api_key" />
|
||
<el-option label="邮箱密码登录" value="login_password" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<template v-if="form.auth_type === 'bearer'">
|
||
<el-form-item label="Bearer Token">
|
||
<div class="auth-field-row">
|
||
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
|
||
<el-button size="small" @click="openAuthCapture">
|
||
<el-icon><Pointer /></el-icon>
|
||
提取
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item v-if="form.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1'" label="Refresh Token">
|
||
<el-input v-model="form.auth_config.refresh_token" type="password" show-password placeholder="可选,真实浏览器提取后自动填入" />
|
||
<div class="form-hint">没有 refresh token 时,access token 过期后需要重新提取或改用邮箱密码登录。</div>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-else-if="form.auth_type === 'new_api_token'">
|
||
<el-form-item label="New-API Access Token">
|
||
<div class="auth-field-row">
|
||
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="上游 /api/user/token 生成的 access token" />
|
||
<el-button size="small" @click="openAuthCapture">
|
||
<el-icon><Pointer /></el-icon>
|
||
提取
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="New-API User ID">
|
||
<el-input v-model="form.auth_config.user_id" placeholder="例如 1" />
|
||
<div class="form-hint">请求 <code>/api/user/self/groups</code> 时需要同时发送 <code>New-Api-User</code>。不要反复点上游「重新生成」,<code>/api/user/token</code> 会覆盖旧 access token。</div>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-else-if="form.auth_type === 'nox_token'">
|
||
<el-form-item label="Nox Access Token">
|
||
<div class="auth-field-row">
|
||
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
|
||
<el-button size="small" @click="openAuthCapture">
|
||
<el-icon><Pointer /></el-icon>
|
||
提取
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="Nox User ID">
|
||
<el-input v-model="form.auth_config.user_id" placeholder="例如 1" />
|
||
<div class="form-hint">请求 <code>/api/user/self/groups</code> 时需要同时发送 <code>Nox-Api-User</code></div>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-else-if="form.auth_type === 'cookie'">
|
||
<el-form-item label="Cookie">
|
||
<div class="auth-field-row">
|
||
<el-input v-model="form.auth_config.cookie_string" type="password" show-password placeholder="name=value; name2=value2" />
|
||
<el-button size="small" @click="openAuthCapture">
|
||
<el-icon><Pointer /></el-icon>
|
||
提取
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<div class="form-hint">Cookie 仅作为 fallback;它受 session、Cloudflare 校验和上游重启影响,通常不如 access token 稳定。</div>
|
||
</template>
|
||
<template v-else-if="form.auth_type === 'api_key'">
|
||
<el-form-item label="API Key">
|
||
<el-input v-model="form.auth_config.key" type="password" show-password placeholder="***" />
|
||
</el-form-item>
|
||
<el-form-item label="Header 名称">
|
||
<el-input v-model="form.auth_config.header" placeholder="Authorization" />
|
||
</el-form-item>
|
||
</template>
|
||
<template v-else-if="form.auth_type === 'login_password'">
|
||
<el-form-item label="账号字段">
|
||
<el-select v-model="form.auth_config.username_field" style="width: 100%">
|
||
<el-option label="邮箱 email" value="email" />
|
||
<el-option label="用户名 username" value="username" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'">
|
||
<el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" />
|
||
</el-form-item>
|
||
<el-form-item label="登录密码">
|
||
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
|
||
</el-form-item>
|
||
<el-form-item label="登录接口路径">
|
||
<el-input v-model="form.auth_config.login_path" :placeholder="loginPathPlaceholder" />
|
||
</el-form-item>
|
||
</template>
|
||
<el-form-item label="分组接口">
|
||
<el-input v-model="form.groups_endpoint" :placeholder="groupsEndpointPlaceholder" />
|
||
</el-form-item>
|
||
<el-form-item label="倍率接口">
|
||
<el-input v-model="form.rate_endpoint" :placeholder="rateEndpointPlaceholder" />
|
||
</el-form-item>
|
||
<el-form-item label="余额接口">
|
||
<el-input v-model="form.balance_endpoint" :placeholder="balanceEndpointPlaceholder" />
|
||
</el-form-item>
|
||
<el-form-item label="余额字段路径">
|
||
<el-input v-model="form.balance_response_path" :placeholder="balanceResponsePathPlaceholder" />
|
||
<div class="form-hint">JSON 点分路径,例如 <code>balance</code> 或 <code>data.quota</code></div>
|
||
</el-form-item>
|
||
<el-form-item label="余额除数">
|
||
<el-input-number v-model="form.balance_divisor" :min="1" :max="999999999" style="width: 100%" />
|
||
<div class="form-hint">原始值除以该数得到实际余额。New-API / Nox-API 填 <code>500000</code>,Sub2API 填 <code>1</code></div>
|
||
</el-form-item>
|
||
<el-form-item label="余额告警阈值">
|
||
<el-input-number v-model="form.balance_alert_threshold" :min="0" :precision="2" :value-on-clear="null" style="width: 100%" />
|
||
<div class="form-hint">余额低于此值时发送 Webhook 通知,留空/0 表示不监控</div>
|
||
</el-form-item>
|
||
<el-row :gutter="12">
|
||
<el-col :span="12">
|
||
<el-form-item label="检测间隔(秒)">
|
||
<el-input-number v-model="form.check_interval_seconds" :min="60" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="超时(秒)">
|
||
<el-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="启用">
|
||
<el-switch v-model="form.enabled" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="drawerVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-drawer
|
||
v-model="detailVisible"
|
||
:title="`检测详情 — ${detailUpstream?.name || ''}`"
|
||
size="700px"
|
||
destroy-on-close
|
||
@open="loadSnapshots"
|
||
>
|
||
<div v-if="detailUpstream" class="info-cards">
|
||
<div class="surface-card info-card">
|
||
<div class="info-label">状态</div>
|
||
<span :class="['status-badge', detailUpstream.last_status]">
|
||
<span class="dot" />{{ statusLabel(detailUpstream.last_status) }}
|
||
</span>
|
||
</div>
|
||
<div class="surface-card info-card" v-if="detailUpstream.balance !== null">
|
||
<div class="info-label">余额</div>
|
||
<div class="info-value mono">{{ formatBalance(detailUpstream.balance) }}</div>
|
||
</div>
|
||
<div class="surface-card info-card" v-if="detailUpstream.balance_updated_at">
|
||
<div class="info-label">余额更新于</div>
|
||
<div class="info-value mono">{{ fmtTimeFull(detailUpstream.balance_updated_at) }}</div>
|
||
</div>
|
||
<div class="surface-card info-card">
|
||
<div class="info-label">最近检测</div>
|
||
<div class="info-value mono">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
|
||
</div>
|
||
<div class="surface-card info-card">
|
||
<div class="info-label">检测间隔</div>
|
||
<div class="info-value mono">{{ detailUpstream.check_interval_seconds }}s</div>
|
||
</div>
|
||
<div class="surface-card info-card">
|
||
<div class="info-label">超时</div>
|
||
<div class="info-value mono">{{ detailUpstream.timeout_seconds }}s</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="detailUpstream?.last_error" class="error-box">
|
||
<el-icon><Warning /></el-icon>
|
||
<span>{{ detailUpstream.last_error }}</span>
|
||
</div>
|
||
|
||
<div class="section-title">
|
||
<el-icon><Key /></el-icon>
|
||
已创建 Key
|
||
<span class="section-sub">最近 {{ generatedKeys.length }} 条</span>
|
||
</div>
|
||
<el-table :data="generatedKeys" v-loading="keysLoading" size="small" style="width: 100%" class="generated-key-table">
|
||
<el-table-column prop="group_name" label="分组" min-width="120" />
|
||
<el-table-column prop="key_name" label="名称" min-width="180" />
|
||
<el-table-column label="Key" min-width="140">
|
||
<template #default="{ row }"><span class="mono">{{ row.masked_key || '—' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="96">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="row.status === 'import_failed' || row.status === 'failed' ? 'danger' : row.status === 'imported' ? 'success' : row.status === 'created_pending_key' ? 'warning' : 'info'">
|
||
{{ keyStatusLabel(row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="section-title">
|
||
<el-icon><Clock /></el-icon>
|
||
检测历史
|
||
<span class="section-sub">最近 {{ snapshots.length }} 条</span>
|
||
</div>
|
||
|
||
<div v-loading="snapshotLoading" class="snapshot-list">
|
||
<div
|
||
v-for="snap in snapshots"
|
||
:key="snap.id"
|
||
class="snap-item"
|
||
:class="{ expanded: expandedId === snap.id }"
|
||
>
|
||
<div class="snap-header" @click="toggleExpand(snap)">
|
||
<div class="snap-left">
|
||
<el-icon class="expand-icon"><ArrowRight /></el-icon>
|
||
<span class="snap-time mono">{{ fmtTimeFull(snap.captured_at) }}</span>
|
||
</div>
|
||
<div class="snap-right">
|
||
<el-tag size="small" type="info">{{ snap.snapshot._groups_count }} 个分组</el-tag>
|
||
<template v-if="snap.snapshot._changes_count !== null && snap.snapshot._changes_count !== undefined">
|
||
<el-tag size="small" :type="snap.snapshot._changes_count > 0 ? 'warning' : 'success'">
|
||
{{ snap.snapshot._changes_count > 0 ? `${snap.snapshot._changes_count} 处变化` : '无变化' }}
|
||
</el-tag>
|
||
</template>
|
||
<el-tag v-else size="small" type="primary">初始快照</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="expandedId === snap.id" class="snap-body">
|
||
<el-table
|
||
:data="groupRows(snap)"
|
||
size="small"
|
||
:header-cell-style="{ background: 'rgba(255, 244, 232, 0.02)', color: 'var(--text-soft)' }"
|
||
:cell-style="{ background: 'transparent', color: 'var(--text-primary)' }"
|
||
>
|
||
<el-table-column prop="group_name" label="分组名称" min-width="140" />
|
||
<el-table-column prop="platform" label="平台" width="110" />
|
||
<el-table-column label="当前倍率" width="110">
|
||
<template #default="{ row }">
|
||
<span class="rate-value mono">{{ row.rate || '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="default_rate" label="默认倍率" width="110" />
|
||
<el-table-column prop="override_rate" label="覆盖倍率" width="110">
|
||
<template #default="{ row }">
|
||
<span v-if="row.override_rate" class="override-value mono">{{ row.override_rate }}</span>
|
||
<span v-else class="muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!snapshotLoading && snapshots.length === 0" class="empty-hint">
|
||
暂无检测历史,请先触发「立即检测」
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="snapshots.length > 0 || snapshotOffset > 0" class="snap-pagination">
|
||
<el-button size="small" :disabled="snapshotOffset === 0" @click="prevSnapPage">上一页</el-button>
|
||
<span class="page-info">第 {{ snapshotOffset / snapshotLimit + 1 }} 页</span>
|
||
<el-button size="small" :disabled="snapshots.length < snapshotLimit" @click="nextSnapPage">下一页</el-button>
|
||
</div>
|
||
</el-drawer>
|
||
|
||
<el-dialog v-model="keyDialogVisible" title="按分组创建 Key" width="620px" destroy-on-close>
|
||
<el-form label-position="top">
|
||
<el-form-item label="上游">
|
||
<el-input :model-value="keyTarget?.name || ''" disabled />
|
||
</el-form-item>
|
||
<el-form-item label="选择分组">
|
||
<el-select v-model="keyForm.group_ids" multiple filterable style="width:100%" placeholder="不选则创建全部分组">
|
||
<el-option v-for="group in keyGroupOptions" :key="group.group_id" :label="`${group.group_name || group.group_id} (${group.rate || '—'})`" :value="group.group_id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-row :gutter="12">
|
||
<el-col :span="12">
|
||
<el-form-item label="名称前缀"><el-input v-model="keyForm.name_prefix" /></el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="创建接口"><el-input v-model="keyForm.endpoint" /></el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="12">
|
||
<el-col :span="12">
|
||
<el-form-item label="配额 USD(0 不限)"><el-input-number v-model="keyForm.quota" :min="0" :precision="2" style="width:100%" /></el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="有效天数"><el-input-number v-model="keyExpiresDays" :min="1" :disabled="!useKeyExpiry" style="width:100%" /></el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-checkbox v-model="useKeyExpiry">设置过期时间</el-checkbox>
|
||
</el-form>
|
||
<div v-if="keyResults.length" class="result-panel">
|
||
<div class="result-title">操作结果</div>
|
||
<el-table :data="keyResults" size="small">
|
||
<el-table-column prop="group_name" label="分组" min-width="120" />
|
||
<el-table-column prop="key_name" label="名称" min-width="180" />
|
||
<el-table-column label="Key" min-width="160">
|
||
<template #default="{ row }"><span class="mono">{{ row.key_value || row.masked_key || '—' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<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-if="row.status === 'created_pending_key'" size="small" type="warning">待回填</el-tag>
|
||
<el-tag v-else size="small" type="danger">失败</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="keyDialogVisible = false">关闭</el-button>
|
||
<el-button type="primary" :loading="generatingKeys" :disabled="generatingKeys" @click="generateKeys">确保 Key 存在</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<AuthCaptureDialog
|
||
v-model="authCaptureVisible"
|
||
:initial-url="authCaptureInitialUrl"
|
||
:preferred-types="authCapturePreferredTypes"
|
||
@select="handleAuthCaptureSelect"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import type { FormInstance } from 'element-plus'
|
||
import dayjs from 'dayjs'
|
||
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue'
|
||
import { upstreamsApi, type AuthCaptureCandidate, type GeneratedUpstreamKey, type UpstreamData, type UpstreamBatchActionResponse } from '@/api'
|
||
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
|
||
|
||
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
|
||
const tableLoading = ref(false)
|
||
const testingAll = ref(false)
|
||
const checkingAll = ref(false)
|
||
|
||
const drawerVisible = ref(false)
|
||
const saving = ref(false)
|
||
const editingId = ref<number | null>(null)
|
||
const formRef = ref<FormInstance>()
|
||
|
||
type QuickPlatform = 'sub2api' | 'new-api-user' | 'nox-api' | 'custom'
|
||
type KnownQuickPlatform = Exclude<QuickPlatform, 'custom'>
|
||
type AuthType = 'none' | 'bearer' | 'new_api_token' | 'nox_token' | 'cookie' | 'api_key' | 'login_password'
|
||
|
||
const USER_API_GROUPS_ENDPOINT = '/api/user/self/groups'
|
||
const USER_API_BALANCE_ENDPOINT = '/api/user/self'
|
||
const USER_API_LOGIN_PATH = '/api/user/login'
|
||
const SUB2API_LOGIN_PATH = '/auth/login'
|
||
|
||
const platformDefaults: Record<KnownQuickPlatform, {
|
||
api_prefix: string
|
||
auth_type: AuthType
|
||
auth_config: Record<string, any>
|
||
rate_endpoint: string
|
||
groups_endpoint: string
|
||
balance_endpoint: string
|
||
balance_response_path: string
|
||
balance_divisor: number
|
||
}> = {
|
||
sub2api: {
|
||
api_prefix: '/api/v1',
|
||
auth_type: 'login_password',
|
||
auth_config: { email: '', password: '', login_path: SUB2API_LOGIN_PATH, username_field: 'email' },
|
||
rate_endpoint: '/groups/rates',
|
||
groups_endpoint: '/groups/available',
|
||
balance_endpoint: '/auth/me',
|
||
balance_response_path: 'data.balance',
|
||
balance_divisor: 1.0,
|
||
},
|
||
'new-api-user': {
|
||
api_prefix: '',
|
||
auth_type: 'login_password',
|
||
auth_config: { email: '', password: '', login_path: USER_API_LOGIN_PATH, username_field: 'username', provider: 'new-api' },
|
||
rate_endpoint: USER_API_GROUPS_ENDPOINT,
|
||
groups_endpoint: USER_API_GROUPS_ENDPOINT,
|
||
balance_endpoint: USER_API_BALANCE_ENDPOINT,
|
||
balance_response_path: 'data.quota',
|
||
balance_divisor: 500000,
|
||
},
|
||
'nox-api': {
|
||
api_prefix: '',
|
||
auth_type: 'login_password',
|
||
auth_config: { email: '', password: '', login_path: USER_API_LOGIN_PATH, username_field: 'username', provider: 'nox-api' },
|
||
rate_endpoint: USER_API_GROUPS_ENDPOINT,
|
||
groups_endpoint: USER_API_GROUPS_ENDPOINT,
|
||
balance_endpoint: USER_API_BALANCE_ENDPOINT,
|
||
balance_response_path: 'data.quota',
|
||
balance_divisor: 500000,
|
||
},
|
||
}
|
||
|
||
const platformLabels: Record<QuickPlatform, string> = {
|
||
sub2api: 'Sub2API',
|
||
'new-api-user': 'New-API',
|
||
'nox-api': 'Nox-API',
|
||
custom: '自定义',
|
||
}
|
||
|
||
const quickPlatform = ref<QuickPlatform>('sub2api')
|
||
|
||
const defaultForm = () => ({
|
||
name: '',
|
||
base_url: '',
|
||
api_prefix: platformDefaults.sub2api.api_prefix,
|
||
auth_type: platformDefaults.sub2api.auth_type,
|
||
auth_config: { ...platformDefaults.sub2api.auth_config } as Record<string, any>,
|
||
rate_endpoint: platformDefaults.sub2api.rate_endpoint,
|
||
groups_endpoint: platformDefaults.sub2api.groups_endpoint,
|
||
enabled: true,
|
||
check_interval_seconds: 600,
|
||
timeout_seconds: 30,
|
||
balance_endpoint: platformDefaults.sub2api.balance_endpoint,
|
||
balance_response_path: platformDefaults.sub2api.balance_response_path,
|
||
balance_divisor: platformDefaults.sub2api.balance_divisor,
|
||
balance_alert_threshold: null as number | null,
|
||
})
|
||
const form = ref(defaultForm())
|
||
const rules = {
|
||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
|
||
}
|
||
|
||
const authCaptureVisible = ref(false)
|
||
|
||
const selectedPlatformDefaults = computed(() =>
|
||
quickPlatform.value === 'custom' ? null : platformDefaults[quickPlatform.value],
|
||
)
|
||
|
||
const apiPrefixPlaceholder = computed(() => {
|
||
const defaults = selectedPlatformDefaults.value
|
||
if (!defaults) return '/api/v1 或留空'
|
||
return defaults.api_prefix || `留空(${platformLabels[quickPlatform.value]} 默认)`
|
||
})
|
||
|
||
const apiPrefixHint = computed(() => {
|
||
if (quickPlatform.value !== 'new-api-user' && quickPlatform.value !== 'nox-api') return ''
|
||
return `${platformLabels[quickPlatform.value]} 用户接口默认不加 API Prefix,留空即可。`
|
||
})
|
||
|
||
const loginPathPlaceholder = computed(() => {
|
||
const defaults = selectedPlatformDefaults.value
|
||
return defaults?.auth_config.login_path || SUB2API_LOGIN_PATH
|
||
})
|
||
|
||
const groupsEndpointPlaceholder = computed(() =>
|
||
selectedPlatformDefaults.value?.groups_endpoint || '/groups/available',
|
||
)
|
||
|
||
const rateEndpointPlaceholder = computed(() =>
|
||
selectedPlatformDefaults.value?.rate_endpoint || '/groups/rates',
|
||
)
|
||
|
||
const balanceEndpointPlaceholder = computed(() =>
|
||
selectedPlatformDefaults.value?.balance_endpoint || '留空则不获取余额,如 /auth/me',
|
||
)
|
||
|
||
const balanceResponsePathPlaceholder = computed(() =>
|
||
selectedPlatformDefaults.value?.balance_response_path || '如 balance、data.quota',
|
||
)
|
||
|
||
const authCaptureInitialUrl = computed(() => {
|
||
const base = (form.value.base_url || '').replace(/\/+$/, '')
|
||
if (!base) return ''
|
||
return base + '/login'
|
||
})
|
||
|
||
const authCapturePreferredTypes = computed<AuthCaptureCandidate['type'][]>(() => {
|
||
if (quickPlatform.value === 'sub2api') return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
|
||
if (quickPlatform.value === 'new-api-user') return ['bearer_token', 'cookie_bundle', 'cookie', 'api_key']
|
||
if (quickPlatform.value === 'nox-api') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key']
|
||
return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
|
||
})
|
||
|
||
function openAuthCapture() {
|
||
authCaptureVisible.value = true
|
||
}
|
||
|
||
function handleAuthCaptureSelect(candidate: {
|
||
type: string
|
||
value: string
|
||
cookie_name?: string
|
||
cookie_value?: string
|
||
cookie_count?: number
|
||
cookie_names?: string[]
|
||
new_api_user?: string
|
||
user_id?: string
|
||
refresh_token?: string
|
||
}) {
|
||
if (candidate.type === 'bearer_token') {
|
||
if (quickPlatform.value === 'nox-api') {
|
||
form.value.auth_type = 'nox_token'
|
||
form.value.auth_config.token = candidate.value
|
||
form.value.auth_config.provider = 'nox-api'
|
||
applyUserApiEndpoints()
|
||
if (candidate.user_id || candidate.new_api_user) {
|
||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||
}
|
||
ElMessage.success('已填入 Nox Access Token')
|
||
} else if (quickPlatform.value === 'new-api-user') {
|
||
form.value.auth_type = 'new_api_token'
|
||
form.value.auth_config.token = candidate.value
|
||
form.value.auth_config.provider = 'new-api'
|
||
if (candidate.user_id || candidate.new_api_user) {
|
||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||
form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
|
||
}
|
||
applyUserApiEndpoints()
|
||
ElMessage.success('已填入 New-API Access Token')
|
||
} else {
|
||
form.value.auth_type = 'bearer'
|
||
form.value.auth_config.token = candidate.value
|
||
if (candidate.refresh_token) {
|
||
form.value.auth_config.refresh_token = candidate.refresh_token
|
||
ElMessage.success('已填入 Bearer Token 和 Refresh Token')
|
||
} else {
|
||
ElMessage.success('已填入 Bearer Token')
|
||
if (quickPlatform.value === 'sub2api' || form.value.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') {
|
||
ElMessage.warning('未提取到 Refresh Token,过期后需要重新提取或改用邮箱密码登录')
|
||
}
|
||
}
|
||
}
|
||
} else if (candidate.type === 'cookie_bundle') {
|
||
// 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串
|
||
form.value.auth_type = 'cookie'
|
||
form.value.auth_config.cookie_string = candidate.value
|
||
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
|
||
form.value.auth_config.provider = quickPlatform.value === 'nox-api' ? 'nox-api' : 'new-api'
|
||
}
|
||
if (quickPlatform.value === 'sub2api') {
|
||
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
||
}
|
||
if (candidate.new_api_user) {
|
||
form.value.auth_config.new_api_user = candidate.new_api_user
|
||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||
applyUserApiEndpoints()
|
||
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||
applyUserApiEndpoints()
|
||
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||
ElMessage.warning(`已填入完整 Cookie 组,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
||
}
|
||
}
|
||
const countLabel = candidate.cookie_count ? `(${candidate.cookie_count} 个 cookie)` : ''
|
||
ElMessage.success(`已填入完整 Cookie 组${countLabel}`)
|
||
} else if (candidate.type === 'cookie') {
|
||
form.value.auth_type = 'cookie'
|
||
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
||
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
||
: candidate.value
|
||
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
|
||
form.value.auth_config.provider = quickPlatform.value === 'nox-api' ? 'nox-api' : 'new-api'
|
||
}
|
||
if (quickPlatform.value === 'sub2api') {
|
||
ElMessage.warning('Sub2API 通常需要 Bearer Token;Cookie 只能在确认上游支持 Cookie 鉴权时使用')
|
||
}
|
||
if (candidate.new_api_user) {
|
||
form.value.auth_config.new_api_user = candidate.new_api_user
|
||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||
applyUserApiEndpoints()
|
||
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||
applyUserApiEndpoints()
|
||
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
|
||
ElMessage.warning(`已填入 Cookie,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
|
||
}
|
||
}
|
||
ElMessage.success('已填入 Cookie')
|
||
} else if (candidate.type === 'api_key') {
|
||
form.value.auth_type = 'api_key'
|
||
form.value.auth_config.key = candidate.value
|
||
form.value.auth_config.header = 'X-API-Key'
|
||
ElMessage.success('已填入 API Key')
|
||
} else if (candidate.type === 'credential') {
|
||
// Try to guess — if value starts with 'sk-', treat as bearer
|
||
if (candidate.value.startsWith('sk-')) {
|
||
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : quickPlatform.value === 'new-api-user' ? 'new_api_token' : 'bearer'
|
||
form.value.auth_config.token = candidate.value
|
||
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
|
||
if (quickPlatform.value === 'new-api-user') form.value.auth_config.provider = 'new-api'
|
||
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') applyUserApiEndpoints()
|
||
if ((quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') && (candidate.user_id || candidate.new_api_user)) {
|
||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||
if (quickPlatform.value === 'new-api-user') form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
|
||
}
|
||
ElMessage.success(quickPlatform.value === 'new-api-user' ? '已填入 New-API Access Token' : '已填入 Bearer Token (sk-key)')
|
||
} else {
|
||
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : quickPlatform.value === 'new-api-user' ? 'new_api_token' : 'bearer'
|
||
form.value.auth_config.token = candidate.value
|
||
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
|
||
if (quickPlatform.value === 'new-api-user') form.value.auth_config.provider = 'new-api'
|
||
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') applyUserApiEndpoints()
|
||
if ((quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') && (candidate.user_id || candidate.new_api_user)) {
|
||
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
|
||
if (quickPlatform.value === 'new-api-user') form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
|
||
}
|
||
ElMessage.success('已填入认证信息')
|
||
}
|
||
}
|
||
}
|
||
|
||
function isKnownQuickPlatform(value: string): value is KnownQuickPlatform {
|
||
return value === 'sub2api' || value === 'new-api-user' || value === 'nox-api'
|
||
}
|
||
|
||
function normalizeQuickPlatform(value: string): QuickPlatform {
|
||
return isKnownQuickPlatform(value) ? value : 'custom'
|
||
}
|
||
|
||
function platformProvider(platform: QuickPlatform): string {
|
||
if (platform === 'nox-api') return 'nox-api'
|
||
if (platform === 'new-api-user') return 'new-api'
|
||
return ''
|
||
}
|
||
|
||
function normalizeAuthType(value: string): AuthType {
|
||
if (value === 'none'
|
||
|| value === 'bearer'
|
||
|| value === 'new_api_token'
|
||
|| value === 'nox_token'
|
||
|| value === 'cookie'
|
||
|| value === 'api_key'
|
||
|| value === 'login_password') {
|
||
return value
|
||
}
|
||
return 'none'
|
||
}
|
||
|
||
function applyUserApiEndpoints() {
|
||
form.value.api_prefix = ''
|
||
form.value.groups_endpoint = USER_API_GROUPS_ENDPOINT
|
||
form.value.rate_endpoint = USER_API_GROUPS_ENDPOINT
|
||
}
|
||
|
||
function inferPlatform(row: Pick<UpstreamData, 'api_prefix' | 'groups_endpoint' | 'auth_type' | 'auth_config_masked'>): QuickPlatform {
|
||
if (row.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') return 'sub2api'
|
||
if (row.api_prefix === '' && row.groups_endpoint === USER_API_GROUPS_ENDPOINT) {
|
||
if (row.auth_type === 'nox_token' || row.auth_config_masked?.provider === 'nox-api') return 'nox-api'
|
||
if (row.auth_type === 'new_api_token' || row.auth_config_masked?.provider === 'new-api') return 'new-api-user'
|
||
return 'new-api-user'
|
||
}
|
||
return 'custom'
|
||
}
|
||
|
||
function handlePlatformChange(val: string) {
|
||
const platform = normalizeQuickPlatform(val)
|
||
if (platform === 'custom') {
|
||
form.value.balance_endpoint = ''
|
||
form.value.balance_response_path = ''
|
||
form.value.balance_divisor = 1.0
|
||
return
|
||
}
|
||
|
||
const defaults = platformDefaults[platform]
|
||
form.value.api_prefix = defaults.api_prefix
|
||
form.value.groups_endpoint = defaults.groups_endpoint
|
||
form.value.rate_endpoint = defaults.rate_endpoint
|
||
form.value.auth_type = defaults.auth_type
|
||
form.value.auth_config = { ...defaults.auth_config }
|
||
form.value.balance_endpoint = defaults.balance_endpoint
|
||
form.value.balance_response_path = defaults.balance_response_path
|
||
form.value.balance_divisor = defaults.balance_divisor
|
||
}
|
||
|
||
function isUserApiPasswordLoginPlatform(): boolean {
|
||
return quickPlatform.value === 'new-api-user'
|
||
|| quickPlatform.value === 'nox-api'
|
||
|| (form.value.api_prefix === '' && form.value.groups_endpoint === USER_API_GROUPS_ENDPOINT)
|
||
}
|
||
|
||
function handleAuthTypeChange(val: string) {
|
||
const provider = platformProvider(quickPlatform.value)
|
||
if (val === 'none') {
|
||
form.value.auth_config = {}
|
||
return
|
||
}
|
||
if (val === 'bearer') {
|
||
form.value.auth_config = {
|
||
token: form.value.auth_config.token || '',
|
||
refresh_token: form.value.auth_config.refresh_token || '',
|
||
}
|
||
return
|
||
}
|
||
if (val === 'new_api_token') {
|
||
form.value.auth_config = {
|
||
token: form.value.auth_config.token || '',
|
||
user_id: form.value.auth_config.user_id || form.value.auth_config.new_api_user || '',
|
||
provider: 'new-api',
|
||
}
|
||
return
|
||
}
|
||
if (val === 'nox_token') {
|
||
form.value.auth_config = {
|
||
token: form.value.auth_config.token || '',
|
||
user_id: form.value.auth_config.user_id || form.value.auth_config.new_api_user || '',
|
||
provider: 'nox-api',
|
||
}
|
||
return
|
||
}
|
||
if (val === 'cookie') {
|
||
form.value.auth_config = {
|
||
cookie_string: form.value.auth_config.cookie_string || '',
|
||
...(provider ? { provider } : {}),
|
||
}
|
||
return
|
||
}
|
||
if (val === 'api_key') {
|
||
form.value.auth_config = {
|
||
key: form.value.auth_config.key || '',
|
||
header: form.value.auth_config.header || 'Authorization',
|
||
}
|
||
return
|
||
}
|
||
if (val !== 'login_password') return
|
||
|
||
const userApiLogin = isUserApiPasswordLoginPlatform()
|
||
form.value.auth_config = {
|
||
email: form.value.auth_config.email || '',
|
||
password: form.value.auth_config.password || '',
|
||
login_path: userApiLogin ? USER_API_LOGIN_PATH : (form.value.auth_config.login_path || SUB2API_LOGIN_PATH),
|
||
username_field: userApiLogin ? 'username' : (form.value.auth_config.username_field || 'email'),
|
||
...(provider ? { provider } : {}),
|
||
}
|
||
}
|
||
|
||
const detailVisible = ref(false)
|
||
const detailUpstream = ref<UpstreamData | null>(null)
|
||
const snapshots = ref<any[]>([])
|
||
const generatedKeys = ref<GeneratedUpstreamKey[]>([])
|
||
const snapshotLoading = ref(false)
|
||
const keysLoading = ref(false)
|
||
const expandedId = ref<number | null>(null)
|
||
const snapshotOffset = ref(0)
|
||
const snapshotLimit = 20
|
||
|
||
const keyDialogVisible = ref(false)
|
||
const keyTarget = ref<UpstreamData | null>(null)
|
||
const keyGroupOptions = ref<any[]>([])
|
||
const generatingKeys = ref(false)
|
||
const keyResults = ref<GeneratedUpstreamKey[]>([])
|
||
const useKeyExpiry = ref(false)
|
||
const keyExpiresDays = ref(30)
|
||
const keyForm = ref({
|
||
group_ids: [] as string[],
|
||
name_prefix: 'SmartUp',
|
||
quota: 0,
|
||
rate_limit_5h: 0,
|
||
rate_limit_1d: 0,
|
||
rate_limit_7d: 0,
|
||
endpoint: '/keys',
|
||
})
|
||
|
||
const metrics = computed(() => ({
|
||
total: list.value.length,
|
||
healthy: list.value.filter((item) => item.last_status === 'healthy').length,
|
||
enabled: list.value.filter((item) => item.enabled).length,
|
||
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
|
||
balanceCount: list.value.filter((item) => item.balance_endpoint).length,
|
||
}))
|
||
|
||
const healthyRate = computed(() => {
|
||
if (metrics.value.total === 0) return 0
|
||
return Math.round((metrics.value.healthy / metrics.value.total) * 100)
|
||
})
|
||
|
||
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
|
||
|
||
function usesTokenEndpointUpstream(row: UpstreamData | null) {
|
||
if (!row) return false
|
||
return row.api_prefix === ''
|
||
&& (
|
||
row.groups_endpoint === USER_API_GROUPS_ENDPOINT
|
||
|| row.auth_config_masked?.login_path === USER_API_LOGIN_PATH
|
||
|| Boolean(row.auth_config_masked?.new_api_user)
|
||
|| row.auth_type === 'new_api_token'
|
||
|| row.auth_type === 'nox_token'
|
||
)
|
||
}
|
||
|
||
const latestCheckedAt = computed(() => {
|
||
const times = list.value
|
||
.map((item) => item.last_checked_at)
|
||
.filter((value): value is string => Boolean(value))
|
||
.sort((a, b) => new Date(toUTC(b)).getTime() - new Date(toUTC(a)).getTime())
|
||
return times[0] || ''
|
||
})
|
||
|
||
const attentionList = computed(() =>
|
||
list.value
|
||
.filter((item) => item.last_status === 'unhealthy' || item.last_error)
|
||
.slice(0, 4),
|
||
)
|
||
|
||
const recentChecks = computed(() =>
|
||
[...list.value]
|
||
.filter((item) => Boolean(item.last_checked_at))
|
||
.sort((a, b) => new Date(toUTC(b.last_checked_at as string)).getTime() - new Date(toUTC(a.last_checked_at as string)).getTime())
|
||
.slice(0, 5),
|
||
)
|
||
|
||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', new_api_token: 'New-API Token', nox_token: 'Nox Token', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
||
|
||
function formatBalance(value: number | null | undefined): string {
|
||
if (value === null || value === undefined) return '—'
|
||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
}
|
||
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 fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
|
||
|
||
const _groupRowsCache = new Map<number, any[]>()
|
||
|
||
function groupRows(snap: { id: number; snapshot: any }) {
|
||
if (!snap?.snapshot?.groups) return []
|
||
const cached = _groupRowsCache.get(snap.id)
|
||
if (cached) return cached
|
||
const rows = Object.values(snap.snapshot.groups) as any[]
|
||
_groupRowsCache.set(snap.id, rows)
|
||
return rows
|
||
}
|
||
|
||
function shrinkError(value: string) {
|
||
return value.length > 40 ? `${value.slice(0, 40)}…` : value
|
||
}
|
||
|
||
const keyStatusLabel = (s: string) => ({ created: '已创建', imported: '已导入', import_failed: '导入失败', failed: '失败', created_pending_key: '待回填' }[s] || s)
|
||
|
||
async function loadList() {
|
||
tableLoading.value = true
|
||
try {
|
||
const res = await upstreamsApi.list()
|
||
list.value = res.data
|
||
} finally {
|
||
tableLoading.value = false
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
editingId.value = null
|
||
quickPlatform.value = 'sub2api'
|
||
form.value = defaultForm()
|
||
drawerVisible.value = true
|
||
}
|
||
|
||
function openEdit(row: UpstreamData) {
|
||
editingId.value = row.id
|
||
quickPlatform.value = inferPlatform(row)
|
||
form.value = {
|
||
name: row.name,
|
||
base_url: row.base_url,
|
||
api_prefix: row.api_prefix,
|
||
auth_type: normalizeAuthType(row.auth_type),
|
||
auth_config: { ...(row.auth_config_masked as Record<string, any>) },
|
||
rate_endpoint: row.rate_endpoint,
|
||
groups_endpoint: row.groups_endpoint,
|
||
enabled: row.enabled,
|
||
check_interval_seconds: row.check_interval_seconds,
|
||
timeout_seconds: row.timeout_seconds,
|
||
balance_endpoint: row.balance_endpoint || '',
|
||
balance_response_path: row.balance_response_path || '',
|
||
balance_divisor: row.balance_divisor ?? 1.0,
|
||
balance_alert_threshold: row.balance_alert_threshold ?? null,
|
||
}
|
||
drawerVisible.value = true
|
||
}
|
||
|
||
async function handleSave() {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
saving.value = true
|
||
try {
|
||
const payload = {
|
||
...form.value,
|
||
auth_config: { ...(form.value.auth_config as Record<string, any>) },
|
||
}
|
||
if (payload.auth_type === 'new_api_token') {
|
||
payload.auth_config.provider = 'new-api'
|
||
if (payload.auth_config.user_id && !payload.auth_config.new_api_user) {
|
||
payload.auth_config.new_api_user = payload.auth_config.user_id
|
||
}
|
||
} else if (payload.auth_type === 'nox_token') {
|
||
payload.auth_config.provider = 'nox-api'
|
||
}
|
||
if (editingId.value) {
|
||
await upstreamsApi.update(editingId.value, payload)
|
||
ElMessage.success('保存成功')
|
||
} else {
|
||
await upstreamsApi.create(payload as any)
|
||
ElMessage.success('创建成功')
|
||
}
|
||
drawerVisible.value = false
|
||
loadList()
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function toggleEnabled(row: UpstreamData) {
|
||
try {
|
||
await upstreamsApi.update(row.id, { enabled: row.enabled })
|
||
ElMessage.success(row.enabled ? '已启用' : '已停用')
|
||
} catch {
|
||
row.enabled = !row.enabled
|
||
ElMessage.error('操作失败')
|
||
}
|
||
}
|
||
|
||
async function testUpstream(row: any) {
|
||
row._testing = true
|
||
try {
|
||
const res = await upstreamsApi.test(row.id)
|
||
if (res.data.success) ElMessage.success(res.data.message)
|
||
else ElMessage.error(res.data.detail || res.data.message)
|
||
await loadList()
|
||
} finally {
|
||
row._testing = false
|
||
}
|
||
}
|
||
|
||
async function checkNow(row: any) {
|
||
row._checking = true
|
||
try {
|
||
const res = await upstreamsApi.checkNow(row.id)
|
||
ElMessage[res.data.success ? 'success' : 'error'](res.data.message)
|
||
loadList()
|
||
} finally {
|
||
row._checking = false
|
||
}
|
||
}
|
||
|
||
function openDetail(row: UpstreamData) {
|
||
detailUpstream.value = row
|
||
snapshots.value = []
|
||
generatedKeys.value = []
|
||
snapshotOffset.value = 0
|
||
expandedId.value = null
|
||
_groupRowsCache.clear()
|
||
detailVisible.value = true
|
||
}
|
||
|
||
async function loadGeneratedKeys() {
|
||
if (!detailUpstream.value) return
|
||
keysLoading.value = true
|
||
try {
|
||
const res = await upstreamsApi.generatedKeys(detailUpstream.value.id)
|
||
generatedKeys.value = res.data
|
||
} finally {
|
||
keysLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadSnapshots() {
|
||
if (!detailUpstream.value) return
|
||
loadGeneratedKeys()
|
||
snapshotLoading.value = true
|
||
try {
|
||
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
|
||
snapshots.value = res.data
|
||
_groupRowsCache.clear()
|
||
if (res.data.length > 0 && expandedId.value === null) {
|
||
expandedId.value = res.data[0].id
|
||
}
|
||
} catch {
|
||
ElMessage.error('加载历史失败')
|
||
} finally {
|
||
snapshotLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function openKeyGenerate(row: UpstreamData) {
|
||
keyTarget.value = row
|
||
keyResults.value = []
|
||
keyForm.value = {
|
||
group_ids: [],
|
||
name_prefix: 'SmartUp',
|
||
quota: 0,
|
||
rate_limit_5h: 0,
|
||
rate_limit_1d: 0,
|
||
rate_limit_7d: 0,
|
||
endpoint: usesTokenEndpointUpstream(row) ? '/api/token' : '/keys',
|
||
}
|
||
useKeyExpiry.value = false
|
||
keyExpiresDays.value = 30
|
||
try {
|
||
const res = await upstreamsApi.latestSnapshot(row.id)
|
||
keyGroupOptions.value = Object.values(res.data.snapshot?.groups || {})
|
||
} catch {
|
||
keyGroupOptions.value = []
|
||
ElMessage.warning('未找到快照,将由后端实时拉取分组')
|
||
}
|
||
keyDialogVisible.value = true
|
||
}
|
||
|
||
async function generateKeys() {
|
||
if (!keyTarget.value) return
|
||
generatingKeys.value = true
|
||
try {
|
||
const res = await upstreamsApi.generateKeysByGroups(keyTarget.value.id, {
|
||
...keyForm.value,
|
||
expires_in_days: useKeyExpiry.value ? keyExpiresDays.value : null,
|
||
})
|
||
keyResults.value = res.data.items
|
||
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
|
||
if (detailUpstream.value?.id === keyTarget.value.id) await loadGeneratedKeys()
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '创建 Key 失败')
|
||
} finally {
|
||
generatingKeys.value = false
|
||
}
|
||
}
|
||
|
||
function toggleExpand(snap: any) {
|
||
expandedId.value = expandedId.value === snap.id ? null : snap.id
|
||
}
|
||
|
||
function prevSnapPage() {
|
||
snapshotOffset.value = Math.max(0, snapshotOffset.value - snapshotLimit)
|
||
expandedId.value = null
|
||
loadSnapshots()
|
||
}
|
||
|
||
function nextSnapPage() {
|
||
snapshotOffset.value += snapshotLimit
|
||
expandedId.value = null
|
||
loadSnapshots()
|
||
}
|
||
|
||
async function confirmDelete(row: UpstreamData) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除上游 "${row.name}" ?`, '删除确认', { type: 'warning' })
|
||
await upstreamsApi.delete(row.id)
|
||
ElMessage.success('已删除')
|
||
loadList()
|
||
} catch {
|
||
// noop
|
||
}
|
||
}
|
||
|
||
function _showBatchResult(res: UpstreamBatchActionResponse, actionLabel: string) {
|
||
const { summary, items, message } = res
|
||
const failed = items.filter(i => i.status === 'failed')
|
||
|
||
if (failed.length > 0) {
|
||
const failedLines = failed
|
||
.map(i => `• ${i.upstream_name}:${i.detail || i.message}`)
|
||
.join('\n')
|
||
ElMessageBox.alert(
|
||
`${message}\n\n失败明细:\n${failedLines}`,
|
||
`${actionLabel}完成`,
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: '知道了',
|
||
customStyle: { whiteSpace: 'pre-wrap', maxHeight: '60vh', overflowY: 'auto' },
|
||
}
|
||
)
|
||
} else {
|
||
ElMessage[res.success ? 'success' : 'warning'](message)
|
||
}
|
||
}
|
||
|
||
async function testAll() {
|
||
testingAll.value = true
|
||
try {
|
||
const res = await upstreamsApi.testAll()
|
||
await loadList()
|
||
_showBatchResult(res.data, '一键测试')
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '批量测试失败')
|
||
} finally {
|
||
testingAll.value = false
|
||
}
|
||
}
|
||
|
||
async function checkNowAll() {
|
||
checkingAll.value = true
|
||
try {
|
||
const res = await upstreamsApi.checkNowAll()
|
||
await loadList()
|
||
_showBatchResult(res.data, '一键同步')
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '批量同步失败')
|
||
} finally {
|
||
checkingAll.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(loadList)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.auth-field-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.auth-field-row .el-input {
|
||
flex: 1;
|
||
}
|
||
.auth-field-row .el-button {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.upstreams-page {
|
||
padding-bottom: 1rem;
|
||
}
|
||
|
||
.upstreams-hero {
|
||
align-items: center;
|
||
padding: 1.2rem 1.25rem;
|
||
min-height: 8.7rem;
|
||
border-radius: var(--radius-shell);
|
||
background:
|
||
radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%),
|
||
linear-gradient(180deg, rgba(255, 244, 232, 0.03), transparent 28%),
|
||
var(--bg-panel);
|
||
}
|
||
|
||
.upstreams-hero .page-heading {
|
||
gap: 0.32rem;
|
||
}
|
||
|
||
.upstreams-hero .page-title {
|
||
font-size: clamp(1.95rem, 1.45rem + 1.2vw, 2.65rem);
|
||
}
|
||
|
||
.upstreams-hero .page-desc {
|
||
max-width: 50rem;
|
||
font-size: 0.9rem;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.hero-actions {
|
||
align-self: flex-end;
|
||
}
|
||
|
||
.upstreams-content {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.9fr) minmax(360px, 0.82fr);
|
||
gap: 1rem;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.data-stage {
|
||
padding: 1rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.data-stage,
|
||
.upstreams-side {
|
||
min-height: 33rem;
|
||
}
|
||
|
||
.data-stage-head {
|
||
min-height: 4.65rem;
|
||
margin-bottom: 0.85rem;
|
||
}
|
||
|
||
.data-stage-title {
|
||
margin-top: 0.28rem;
|
||
font-size: clamp(1.4rem, 1.05rem + 0.8vw, 2rem);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.data-stage-note {
|
||
color: var(--text-muted);
|
||
font-size: 0.86rem;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.upstreams-side {
|
||
display: grid;
|
||
align-content: start;
|
||
padding: 1rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.side-section {
|
||
padding-block: 1rem;
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
|
||
.side-section:first-child {
|
||
padding-top: 0;
|
||
border-top: 0;
|
||
}
|
||
|
||
.side-section:last-child {
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.overview-section {
|
||
min-height: 12.4rem;
|
||
}
|
||
|
||
.insight-head {
|
||
min-height: 3.75rem;
|
||
margin-bottom: 0.9rem;
|
||
}
|
||
|
||
.insight-head.compact {
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.insight-title {
|
||
margin: 0.24rem 0 0;
|
||
font-size: 1.05rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.insight-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0.38rem 0.7rem;
|
||
border-radius: 999px;
|
||
background: rgba(239, 175, 99, 0.12);
|
||
border: 1px solid rgba(239, 175, 99, 0.18);
|
||
color: var(--color-primary-strong);
|
||
font-size: 0.76rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.insight-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0.8rem;
|
||
}
|
||
|
||
.insight-metric {
|
||
display: grid;
|
||
gap: 0.28rem;
|
||
padding: 0.85rem 0.9rem;
|
||
border-radius: var(--radius-control);
|
||
background: rgba(255, 244, 232, 0.03);
|
||
border: 1px solid rgba(255, 244, 232, 0.06);
|
||
}
|
||
|
||
.insight-metric span {
|
||
color: var(--text-muted);
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.insight-metric strong {
|
||
color: var(--text-primary);
|
||
font-size: 1rem;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.feed-list,
|
||
.timeline-list {
|
||
display: grid;
|
||
gap: 0.7rem;
|
||
}
|
||
|
||
.feed-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.8rem;
|
||
width: 100%;
|
||
padding: 0.85rem 0.9rem;
|
||
border: 1px solid rgba(255, 244, 232, 0.06);
|
||
border-radius: var(--radius-control);
|
||
background: rgba(255, 244, 232, 0.02);
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.feed-item:hover {
|
||
border-color: rgba(239, 175, 99, 0.2);
|
||
background: rgba(255, 244, 232, 0.04);
|
||
}
|
||
|
||
.feed-main,
|
||
.timeline-main {
|
||
min-width: 0;
|
||
display: grid;
|
||
gap: 0.32rem;
|
||
}
|
||
|
||
.feed-top,
|
||
.timeline-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.7rem;
|
||
}
|
||
|
||
.feed-name,
|
||
.timeline-name {
|
||
color: var(--text-primary);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.feed-meta,
|
||
.timeline-meta {
|
||
color: var(--text-muted);
|
||
font-size: 0.82rem;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.feed-link {
|
||
color: var(--color-primary-strong);
|
||
font-size: 0.8rem;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.side-empty {
|
||
min-height: auto;
|
||
padding: 0.6rem 0;
|
||
}
|
||
|
||
.auth-badge {
|
||
background: rgba(134, 183, 199, 0.12);
|
||
color: var(--color-info);
|
||
border-color: rgba(134, 183, 199, 0.18);
|
||
}
|
||
|
||
.time-inline {
|
||
color: var(--text-secondary);
|
||
font-size: 0.84rem;
|
||
}
|
||
|
||
.info-cards {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 0.85rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.info-card {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.info-label {
|
||
margin-bottom: 0.45rem;
|
||
color: var(--text-soft);
|
||
font-size: 0.76rem;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 0.96rem;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.error-box {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.6rem;
|
||
margin-bottom: 1rem;
|
||
padding: 0.9rem 1rem;
|
||
border-radius: var(--radius-control);
|
||
color: var(--color-danger);
|
||
background: rgba(221, 126, 114, 0.08);
|
||
border: 1px solid rgba(221, 126, 114, 0.16);
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
margin-bottom: 0.8rem;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.section-sub {
|
||
color: var(--text-muted);
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.snapshot-list {
|
||
display: grid;
|
||
gap: 0.7rem;
|
||
min-height: 4rem;
|
||
}
|
||
|
||
.snap-item {
|
||
border-radius: var(--radius-control);
|
||
border: 1px solid var(--border-color);
|
||
background: rgba(255, 244, 232, 0.02);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.snap-item.expanded {
|
||
border-color: rgba(239, 175, 99, 0.24);
|
||
}
|
||
|
||
.snap-header {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.8rem;
|
||
padding: 0.95rem 1rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.snap-left,
|
||
.snap-right,
|
||
.snap-pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.55rem;
|
||
}
|
||
|
||
.expand-icon {
|
||
color: var(--text-soft);
|
||
transition: transform var(--motion-fast) var(--ease-standard);
|
||
}
|
||
|
||
.snap-item.expanded .expand-icon {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.snap-time {
|
||
color: var(--text-secondary);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.snap-body {
|
||
padding: 0 0.35rem 0.35rem;
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
|
||
.rate-value {
|
||
color: var(--color-primary-strong);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.override-value {
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
.balance-value {
|
||
color: var(--color-success);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 0.75rem;
|
||
color: var(--text-soft);
|
||
margin-top: 0.25rem;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.form-hint code {
|
||
background: var(--bg-soft);
|
||
padding: 0 0.3em;
|
||
border-radius: 3px;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
.snap-pagination {
|
||
justify-content: center;
|
||
margin-top: 0.9rem;
|
||
padding-top: 0.9rem;
|
||
border-top: 1px solid var(--border-color);
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.info-cards {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1199px) {
|
||
.upstreams-content {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (min-width: 1200px) {
|
||
.info-cards {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.upstreams-hero,
|
||
.data-stage,
|
||
.upstreams-side {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.data-stage,
|
||
.upstreams-side {
|
||
min-height: 0;
|
||
}
|
||
|
||
.hero-actions {
|
||
width: 100%;
|
||
}
|
||
|
||
.hero-actions :deep(.el-button) {
|
||
flex: 1 1 100%;
|
||
}
|
||
|
||
.insight-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.feed-item,
|
||
.timeline-item {
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
|
||
.action-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 0;
|
||
}
|
||
|
||
.action-row .el-button {
|
||
justify-self: center;
|
||
}
|
||
|
||
.action-row .el-button--danger {
|
||
--el-button-bg-color: transparent;
|
||
--el-button-border-color: transparent;
|
||
}
|
||
.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);
|
||
}
|
||
|
||
:deep(.el-table__fixed-right) {
|
||
background: var(--surface-bg, #1a1a1a);
|
||
}
|
||
|
||
:deep(.el-table__fixed-right .el-table__cell) {
|
||
background: var(--surface-bg, #1a1a1a) !important;
|
||
}
|
||
</style>
|