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

1676 lines
58 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="shell-page shell-page-fluid page-section 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它受 sessionCloudflare 校验和上游重启影响通常不如 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="配额 USD0 不限)"><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 TokenCookie 只能在确认上游支持 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 TokenCookie 只能在确认上游支持 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>